异步(Async)
JavaScript是单线程执行式语言,这就意味着任何一个函数都要从头到尾执行完毕之后,才会执行另一个函数。假设有一段代码需要接收用户的输入执行,那么在用户输入这段时间,JavaScript就会阻塞自己接受新的任务,这完全不能接受。于是JavaScript把任务分成了两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程而进入“任务队列”的任务,只有“任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行,这就是JavaScript的异步机制。
JavaScript异步机制的原理如下:
➢ 作为单线程语言,在JavaScript里定义的所有同步任务都在主线程上执行,形成一个执行栈。
➢ 主线程之外,还存在一个任务队列。只要异步任务有了运行结果,就在任务队列之中放置一个事件。
➢ 一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,看看里面有哪些事件。那些对应的异步任务于是结束等待状态,进入执行栈,开始执行。
➢ 主线程不断重复上面的第三步。
闭包(closure)
iTesting function outer( ){
var name = 'iTesting';
function inner( ){
console.log(name)
}
return inner
}
var closureExample = outer( ) closureExample( )
笔者定义了一个外部函数outer和一个内部函数inner。在外部函数outer内部,定义了一个局部变量name,并且在内部函数inner里引用了这个变量,最后我设置外部函数outer的返回值是内部函数inner本身,这就是闭包。
简化一下,可以理解为闭包就是满足以下条件的函数:
➢ 在一个函数的内部定义一个内部函数,并且内部函数里包含对外部函数的访问。
➢ 外部函数的返回值是内部函数本身。
闭包有什么作用呢?闭包允许你在一个函数的外部访问它的内部变量。
• 不建议使用Cypress用于网站爬虫,性能测试之目的。
• Cypress永远不会支持多标签测试。
• Cypress不支持同时打开两个及以上的浏览器。
• 每个Cypress测试用例应遵守同源策略(same-origin policy)[8]。
• 目前浏览器支持Chrome,Firefox,Microsoft Edge和Electron。
• 不支持测试移动端应用。
• 针对iframe的支持有限。
• 不能在window.fetch上使用cy.route( )。
• 没有影子DOM支持。
同源策略指协议相同,域名相同,端口相同。
describe('登陆',()=>{
const username = 'java,lane'
const passward = 'password123'
context('HTML'表单登陆测试, ()=>{
it('login sucess, turn to dashboard page', => (){
cy.visit("http://localhost:7077/login")
cy.get('input[name=username]').type(username)
cy.get('input[name = password]').type(password)
cy.get('form').submit()
//断言
cy.url().should('include','/dashboard')
cy.get('h1').should('contains','java.lane')
})
})
})
下面简要介绍一下Cypress提供的这些调试能力。
• 每个命令(Command)均有快照且支持回放
以图3-8为例,Cypress记录了每一个操作命令执行时的快照,并支持在不同操作命令快照之间切换,方便开发者了解整个测试的上下文信息。
• 支持查看测试运行时发生的特殊页面事件(例如网络请求)
Cypress会记录测试运行时发生的特殊页面事件,包括:
➢ 网络XHR请求。
➢ URL哈希更改。
➢ 页面加载。
➢ 表格提交。
例如在本例中,单击“SUMBIT”按钮后产生的就是表格提交请求,如图3-9所示。
Console输出每个命令(Command)的详细信息
仍以图3-9为例,Cypress除了记录“submitting form”这个表格提交请求,还在Console里打印出了这个请求的详细信息,可以进一步帮助开发者了解系统在运行时的详细状态信息。
• 暂停命令(Command)并单步/恢复执行
在调试测试代码时,Cypress提供了如下两个命令来暂停。
➢ cy.pause( )
把cy.pause( )添加到testLogin.js文件中,位置置于cy.get(‘form’).submit( )之前。
留意图3-10中左上角Paused标记,它的右边分别是“Resume”和“Next:‘get’”按钮。如果选择“Resume”按钮并单击,测试将恢复运行直至运行结束。如果选择“Next:‘get’”按钮并单击,测试会变成单步执行,即单击后,会执行cy.get(‘form’)请求,再次单击会执行submit动作。
想在哪儿暂停在语句下面加一行cy.pause()
describe('登陆',()=>{
const username = 'java,lane'
const passward = 'password123'
context('HTML'表单登陆测试, ()=>{
it('login sucess, turn to dashboard page', => (){
cy.visit("http://localhost:7077/login")
cy.get('input').type(username)//更改的一行
cy.get('input[name = password]').type(password)
cy.get('form').submit()
//断言
cy.url().should('include','/dashboard')
cy.get('h1').should('contains','java.lane')
})
}
}
因为不止一个元素满足要求,故执行下一命令type时测试以失败结束。
上图为我的vscode,下图为装好cypress自动生成的文件结构。
测试夹具通常配合cy.fixture( )命令使用,主要用来存储测试用例的外部静态数据。
测试夹具默认位于cypress/fixtures中,但可以配置到另一个目录。
测试夹具里的静态数据通常存储在.json后缀文件里(例如自动生成的examples.json文件)。这部分数据通常是某个网络请求的对应响应部分,包括HTTP状态码和返回值,一般是复制过来更改而不由用户手工填写。
如果你的测试需要对某些外部接口进行访问并依赖于它的返回值,则可以使用测试夹具而无须真正地访问这个接口。
使用测试夹具有如下几个好处:
• 消除了对外部功能模块的依赖。
• 你编写的测试用例可以使用测试夹具提供的固定返回值,并且你确切知道这个返回值是你想要的。
• 因为无须真正地发送网络请求从而使测试更快。
测试文件其实就是我们的测试用例。它默认位于cypress/integration中,但可以配置到另一个目录。所有位于cypress/integration文件夹下,以如下后缀结尾的文件都将被Cypress视为测试文件:
• .js文件。是以普通JavaScript编写的文件。
• .jsx文件。是带有扩展的JavaScript文件,其中可包含处理XML的ECMAScript。
• .coffee文件。是一套JavaScript的转译语言,相对于JavaScript,它拥有更严格的语法。
• .cjsx文件。CoffeeScript中的jsx文件。
要创建一个测试文件很简单,只要创建一个以上述后缀结尾的文件即可。
Cypress独一无二的优点是,测试代码运行在浏览器之内,这使得Cypress跟其他的测试框架相比,有着显著的架构优势。
尽管这提供了更加可靠的测试体验,并使编写测试变得更加容易,但这确实使在浏览器之外进行通信更加困难。
Cypress注意到了这个痛点,所以提供了一些现成的插件(Plugins),使你可以修改或者扩展Cypress的内部行为(例如动态修改配置信息和环境变量等),也可以自定义自己的插件。
默认状态,插件位于cypress/plugins/index.js中,但可以配置到另一个目录。为了方便起见,在每个测试文件运行之前,Cypress都会自动加载插件文件cypress/plugins/index.js。
插件在Cypress中的典型应用有:
• 动态更改来自cypress.json,cypress.env.json,CLI或系统环境变量的已解析配置和环境变量。
• 修改特定浏览器的启动参数。
• 将消息直接从测试代码传递到后端。
支持文件目录是放置可重用配置例如底层通用函数或全局默认配置的绝佳地方。
支持文件默认位于cypress/support/index.js中,但可以配置到另一个目录。为了方便起见,在每个测试文件运行之前,Cypress都会自动加载支持文件cypress/ support/index.js。
使用支持文件非常简单,只需要在cypress/support/index.js文件里添加beforeEach( )函数即可。例如增加下列代码到cypress/support/index.js中,将能实现每次测试运行前打印出所有的环境变量信息。
beforeEache(=>(){
cy.log('当前的环境变量为${JSON.stringify(Cypress.env( ))))‘
})
describe('登陆',()=>{
const username = 'java,lane'
const passward = 'password123'
context('HTML'表单登陆测试, ()=>{
it('login sucess, turn to dashboard page', => (){
//一个visit命令
cy.visit("http://localhost:7077/login")
//一个get命令,一个type命令
cy.get('input[name=username]').type(username)
//一个get命令,一个type命令
cy.get('input[name = password]').type(password)
//一个get命令,一个submit命令
cy.get('form').submit()
//一个url命令,一个断言
cy.url().should('include','/dashboard')
//一个get命令,一个断言
cy.get('h1').should('contains','java.lane')
})
})
})
最后一个断言,检查标签为“h1”的元素中是否包含“jane.lane”。
断言的一般步骤为用命令cy.get( )查询应用程序的DOM,找到与选择器匹配的元素,然后针对匹配到的元素或元素列表进行断言尝试(在我们的示例中为.should(‘contain’, ‘jane.lane’))。
由于现代web应用程序几乎都是异步的,请试想一下如下情况:
如果断言发生时应用程序尚未更新DOM怎么办?
如果断言发生时应用程序正在等待其后端响应,而导致页面暂无结果怎么办?
如果断言发生时应用程序正在进行密集计算,而导致页面未及时更新怎么办?
这些情况在现实测试中经常会发生,一般的处理方式是在断言前加个固定等待时间(通常硬编码,但仍有可能会发生测试失败),但Cypress更加智能。在实际运行中,如果cy.get( )命令之后的断言通过,则该命令成功完成。如果cy.get( )命令后面的断言失败,则cy.get( )命令将重新查询应用程序的DOM。然后,Cypress将尝试对cy.get( )返回的元素进行断言。如果断言仍然失败,则cy.get( )将尝试重新查询DOM,依此类推,直到断言成功或者cy.get( )命令超时为止。
与其他的测试框架相比,Cypress的这种“自动”重试能力避免了在测试代码中编写硬编码(hard code)等待,使测试代码更加健壮。
在日常的测试中,有时候需要多重断言,即单个命令后跟多个断言。在断言时,Cypress将按顺序重试每个命令。即当第一个断言通过后,在进行第二个断言时仍会重试第一个断言。当第一和第二断言通过后,在进行第三个断言时会重试第一和第二个断言,依此类推。
假设一个下拉列表,存在两个选项,第一个选项是“iTesting”,第二个选项是“testerTalk”。我们需要验证这两个选项存在,并且顺序正确,则代码片段如下:
cy.get('.list>li')
.should('have.length'.2)
.and(($li) =>{
//更多断言
//期望下拉列表的第一个选项的textContent是‘iTesting’
expect($li.get()).textContent.'first item').to.equal('iTesting')
//期望下拉列表的第二个选项的textContent是‘testertalk’
expect($li.get()).textContent.'first item'.to.equal('testertalk')
})
可以看到,上述代码共有三个断言,分别是一个.should( )和两个expect( )断言
(.and( )断言实际上是.should( )断言的别名,它是.should( )的自定义回调断言,其中包含两个expect( )断言)
在测试执行过程中,如果第二个断言失败了,第三个断言就永远不会执行,如果导致第二个断言失败的原因被找到且修复了,且此时整个命令还没有超时,那么在进行第三个断言前,会再次重试第一和第二个断言。
重试
Cypress仅会重试那些查询DOM的命令:cy.get( )、.find( )、.contains( )等。你可以通过查看其API文档中的“Assertions”部分来检查是否重试了特定命令。例如,.first( )命令将会一直重试,直到紧跟该命令后的所有断言都通过为止。
表4-5列出了一些常用的可重试命令。
重试的超时时间是4秒,配置项是defaultCommandTimeout,如果想更改自动重试的默认时间,在cypress.json里更改相应字段即可。
内置的测试报告包括Mocha的内置测试报告和直接嵌入在Cypress中的测试报告,主要有以下几种。
• spec格式报告
spec格式是Mocha的内置报告,它的输出是一个嵌套的分级视图。在Cypress中使用spec格式的报告非常简单,你只需要在命令行运行时加上“–reporter=spec”参数即可(请确保你已在package.json文件的scripts模块加入了如下键值对"cypress:run": “cypress run”)。
json测试报告格式将输出一个大的JSON对象。同样的,在Cypress中使用json格式的测试报告,只需要在命令行运行时加上“–reporter=json”参数即可(请确保已在package.json文件的scripts模块加入了键值对"cypress:run": “cypress run”)
junit
测试报告格式将输出一个xml文件。在Cypress中使用junit
格式的测试报告,只需要在命令行运行时加上“–reporter=junit”
参数即可(请确保已在package.json文件的scripts模块加入了如下键值对"cypress:run": “cypress run”)。
#进入项目根目录(本例为E:\Cypress) C:\Users\Administrator>E: E:\>cd Cypress #指定reporter为spec E:\Cypress> yarn cypress:run --reporter junit --reporter-options "mochaFile=results/test-output.xml,toConsole=true"
运行完成后,测试报告“test-output.xml”会生成在项目根目录下的results文件夹内,同时console上也会展示,如图4-5所示。
用浏览器打开“mochawesome.html”文件,可以看到mochawesome报告,如图4-7所示。
Cypress除了支持单个测试报告,还支持混合测试报告。用户通常希望看到多个报告,比如测试在CI中运行时,用户既想生成junit格式的报告,又想在测试运行时实时看到测试输出。
Cypress官方推荐使用“mocha-multi-reporters”来生成混合测试报告。使用“mocha-multi-reporters”的步骤如下。
(1)将mocha,mocha-multi-reporters,mocha-junit-reporter添加至你的项目。
#进入项目根目录(本例为E:\Cypress) C:\Users\Administrator>E: E:>cd Cypress #安装mocha,mocha-multi-reporters, mocha-junit-reporter,如已安装则可略过 E:\Cypress>npm install --save-dev [email protected] E:\Cypress>npm install mocha-multi-reporters --save-dev E:\Cypress>npm install mocha-junit-reporter --save-dev
(2)在E:\Cypress\cypress文件夹下,创建reporter文件夹,并新建一个文件,命名为“custom.json”,增加如下内容。
{ “reporterEnabled”: “spec, json, mocha-junit-reporter”, “reporterOptions”: { “mochaFile”: “cypress/results/iTesting -custom-[hash].xml” } }
(3)在E:\Cypress文件下,执行命令:
“yarn cypress:run–reporter mocha-multi-reporters --reporter-options configFile =./reporters/ custom.json”。
#进入项目根目录(本例为E:\Cypress) C:\Users\Administrator>E: E:>cd Cypress #生成mocha-multi-reporters报告 E:\Cypress>yarn cypress:run --reporter mocha-multi-reporters --reporter-options configFile=./reporters/custom.json
运行完成后,测试报告文件夹“results”会生成在项目根目录下,同时,json格式的报告也在运行中显示在console里,如图4-9所示。
图4-9 混合格式测试报告
当用户运行完一次测试(可能包括多个spec),用户希望看到一个完整的测试报告文件,而不是分割开来的独立文件。特别地,对于生成的HTML格式报告来说,用户希望能整合在同一个报告中,Cypress也提供了高阶的方法来满足此需求。
Cypress底层依赖于很多优秀的开源测试库,其中就包含Mocha。Mocha是一个适用于Node.js和浏览器的测试框架。它使异步测试变得简单、灵活和有趣。
Mocha还提供了多种接口来定义测试套件,Hooks和单个测试(Individual tests)
BDD(Behavior-Driven Development,行为驱动开发)、TDD(Test- Driven Development、测试驱动开发)、Exports、QUnit和Require。
Cypress采纳了Mocha的BDD语法,该语法非常适合集成测试和单元测试。
Cypress将Mocha硬编码在自己的框架中,在Cypress中,你要编写的所有测试用例都基于Mocha提供的如下基本功能模块:
• describe( )
• context( )
• it( )
• before( )
• beforeEach( )
• afterEach( )
• after( )
• .only( )
• .skip( )
对于一条可执行的测试来说,有以下两个必要的组成部分:
• describe( )
测试套件。可以在里面可以设定context( ),可包括多个测试用例it( ),也可以嵌套测试套件。
• it( )
用于描述测试用例。一个测试套件可以不包括任何钩子函数(Hook),但必须包含至少一个测试用例it( )。
除这两个功能模块外,其他功能模块对于一条可执行的测试来说,都是可选的。例如context( )是describe( )的别名,其行为方式与describe( )相同,使用context( )只是提供一种使测试更易于阅读和组织的方法。
Hook,常被翻译成钩子函数。Mocha提供了如下四种钩子函数。
• before( )
• after( )
• beforeEach( )
• afterEach( )
describe('钩子函数', ()=> {
before(()=> {
//当前测试套件中,所有测试用例执行之前运行 });
after(function( ) {
//当前测试套件中,所有测试用例执行结束后运行
});
beforeEach(function( ) {
//当前测试套件中,每个测试用例执行之前都会运行 });
afterEach(function( ) {
//当前测试套件中,每个测试用例执行结束后都会运行
});
//测试用例 });
排除测试套件/测试用例可使用功能模块.skip( )。
• 排除测试套件describe( )
可以用describe.skip( )来排除无须执行的测试套件
//此测试套件整个都不会执行
describe.skip('登录', function ( ) {
//此用户名和密码为本地服务器默认
const username = 'jane.lane'
const password = 'password123'
context('HTML表单登录测试', function ( ) {
it('登录成功,跳转到dashboard页', function ( ) {
cy.visit('http://localhost:7077/login')
cy.get('input[name=username]').type(username)
cy.get('input[name=password]').type(password)
cy.get('form').submit( )
//验证登录成功则跳转到/dashboard页面
cy.get('h1').should('contain', 'jane.lane') }) }) })
describe('测试1=1', function ( ) {
//只有此测试用例会执行
it('测试1=1', function ( ) {
expect(1).to.equal(1) })
//此测试套件不会执行
context.skip('排除测试套件',function( ){ it('测试1!=2', function( ){ expect(1).not.to.equal(2) }) }) })
可以看到只有第二个测试套件里的it( )下的测试用例执行了。第一个测试套件和第二个测试套件(context是describe的别名)均没有执行,Cypress标记为未执行。
• 排除测试用例it( )
可以用it.skip( )来排除无须运行的测试用例。
包含测试套件/测试用例可使用功能模块.only( )。需要注意的是,当你用.only( )装饰指定某个测试套件/测试用例时,只有这个测试套件/测试用例会执行,其他未被装饰的测试套件/测试用例不会执行。
• 包含测试套件
可以用describe.only( )来指定要运行的测试套件。
describe.only('登录', function ( ) {
//此用户名和密码为本地服务器默认
const username = 'jane.lane'
const password = 'password123'
context('HTML表单登录测试', function ( ) {
it('登录成功,跳转到dashboard页', function ( ) {
cy.visit('http://localhost:7077/login')
cy.get('input[name=username]').type(username)
cy.get('input[name=password]').type(password)
cy.get('form').submit( )
//验证登录成功则跳转到/dashboard页面
cy.get('h1').should('contain', 'jane.lane') }) }) })
describe('测试1=1', function ( ) {
it('测试1=1', function ( ) {
expect(1).to.equal(1) })
context('包含测试套件',function( ){
it('测试1!=2', function( ){
expect(1).not.to.equal(2) }) }) })
可以用it.only( )来指定要运行的测试用例
在实际的项目测试中,有时会碰见多条测试用例执行步骤和检查步骤完全一致,只有输入和输出不同的情况,此时,一条一条地手工编写测试用例的效率就比较低下。下面就来介绍一下如何根据数据动态地生成测试用例。
仍以前面几章使用的例子testLogin.js为例,假设需要登录通过和登录不通过两个测试用例,则动态生成测试用例的步骤如下。
(1)在E:\Cypress\cypress\integration文件夹下,创建一个子目录autoGenTestLogin,在此目录下新建一个testLogin.data.js文件,代码如下:
export const testLoginUser = [ {
summary: "Login pass",
username: "jane.lane",
password: "password123" },
{
summary: "Login fail",
username: "iTesting",
password: "iTesting" } ]
(2)在子目录autoGenTestLogin下,新建一个testLogin.js文件,代码如下:
import { testLoginUser } from '../autoGenTestLogin/testLogin.data'
describe('登录', ()=>{
//此用户名和密码为本地服务器默认
const username = 'jane.lane'
const password = 'password123'
context('HTML表单登录测试', ()=> {
for(const user of testLoginUser){
it(user.summary, ()=> {
cy.visit('http://localhost:7077/login')
cy.get('input[name=username]').type(user.username)
cy.get('input[name=password]').type(user.password)
cy.get('form').submit( )
cy.get('h1').should('contain', user.username) }) } })
})
然后在Test Runner中选择测试文件夹autoGenTestLogin下的用例“testLogin.js”,单击运行。运行结束后的截图如图5-10所示。
可以看到第一条测试用例执行成功,第二条执行失败了(失败是我们期望的结果),因为用户名和密码不正确,所以无法跳转到dashboard。
根据数据动态生成测试用例,可以提升测试效率,当测试数据本身改变时,无须更改测试代码。
断言是测试用例的必要组成部分。没有断言,用户就无法感知测试用例的有效性。Cypress的断言基于当下流行的Chai断言库,并且增加了对Sinon-Chai,Chai-jQuery断言库的支持。Cypress支持多种风格的断言,其中就包括BDD(expect /should)和TDD(assert)格式的断言。
常见元素的断言有:
• 针对长度(Length)的断言
//重试,直到找到3个匹配的
cy.get('li.selected').should('have.length', 3)
• 针对类(Class)的断言
//重试,直到input元素没有类被disabled为止(或者超时为止)
cy.get('form').find('input').should('not.have.class', 'disabled')
• 针对值(Value)的断言
//重试,直到textarea的值为’iTesting’
cy.get('textarea').should('have.value','iTesting' )
• 针对文本内容(Text Content)的断言
//重试,直到这个spin不包含"click me"字样
cy.get('a').parent('span.help')should('not.contain'.'click me')???
• 针对元素可见与否(Visibility)的断言
//重试,直到这个button是可见为止
cy.get('button').should('be.visible')
• 针对元素存在与否(Existence)的断言
//重试,直到id为loading的spinner不再存在
cy.get('#loading').should('not.exist')
• 针对元素状态(State)的断言
//重试,直到这个radio button是选中的状态
cy.get('.radio').should('be checked')
• 针对CSS的断言
//重试,直到completed这个类有匹配的CSS为止
cy.get('.completed').should('have.css', 'text-decoration', 'line-through')
• 针对回调函数(callback)的断言
假设源HTML如下:
<div class="main-abc123 heading-xyz987">Introduction</div>
如果需要判断类名是否一定含有heading字样,则断言如下:
cy.get('div') .should(($div) => {
expect($div).to.have.length(1)
const className = $div[0].className
//检查类名匹配通配符/heading-/
expect(className).to.match(/heading-/) })
在具体的使用上,可以按照习惯选择断言库。更多断言库及其用法,请参考如下网址:
https://github.com/chaijs/chai
https://github.com/domenic/sinon-chai
https://github.com/chaijs/chai-jquery
https://www.chaijs.com/api/assert/
测试运行器(Test Runner[3])是Cypress在一众前端测试框架中脱颖而出的一个重要原因。Cypress使测试在一个独特的交互式运行器中运行测试,使你不仅可以在执行命令时查看这些测试,同时还允许你查看被测应用程序。
Cypress自带的交互式测试运行器功能强大,它甚至允许你在测试运行期间就查看测试命令执行情况,并(同时)监控在命令执行时,被测程序所处的状态。Cypress的测试运行器由如下几个部分组成。
(1)测试状态目录(Test Status Menu)。
测试状态目录用于展示测试用例成功和失败的数目,并且展示每个测试运行的时间。
(2)命令日志(Command Log)。
命令日志用于记录每个被执行的命令。用鼠标单击命令,可在Console中查看命令应用于哪个元素及其执行的详细信息,同时应用程序预览(App Preview) 中会显示当命令执行时被测应用程序的状态。
对于一些特殊的命令例如cy.route( ),cy.stub( )和cy.spy( ),命令日志会展示一个额外的log信息方便你了解当前测试的状态。
(3)URL预览(RUL Preview)。
URL预览用于展示你的测试命令执行时被测应用程序所处的URL,它能够使你更方便地查看测试路由(Testing Routs)。
(4)应用程序预览(App Preview)。
应用程序预览用于展示当测试运行时被测程序所处的实时状态。
(5)视窗大小(ViewPoint Sizing)。
视窗大小可以通过设置视窗大小来测试页面响应式布局。你可以在cypress.json文件中通过配置viewportWidth和viewportHeight两个配置项来控制视窗大小。
(6)Cypress元素定位辅助器(Selector Playground)。
Cypress元素定位辅助器可以帮助用户识别元素唯一的定位标识。
你的每一个测试用例都将包含对元素的操作。健壮、可靠的元素定位策略将是测试成功的保障。Cypress的多种定位策略能够使你聚焦在和元素的交互上而无须过多担心因定位而导致的测试失败。
相对于其他测试框架来说,Cypress有着独一无二的定位策略,能够使你摆脱元素定位的噩梦。在你以往的测试中,一定遇见过以下类似问题。
(1)应用元素ID或者类是动态生成的。
(2)你使用了CSS定位策略,但在开发过程中CSS样式发生了改变。
这种情况下通常测试会失败。
为解决这个问题,Cypress提供了data-*属性。data-*属性包含如下3个定位器:
• data-cy
• data-test
• data-testid
它们都是Cypress专有的定位器,仅用来测试。data-*属性与元素的行为或样式无关,这意味着即使CSS样式或JS行为改变也不会导致测试失败。
举例来说,你可以为button添加如下属性:
html属性
html css
html元素
//为button添加data-cy属性
<button id="main" class="btn" data-cy="submit">Submit</button>
//为button添加data-test属性
<button id="main" class="btn" data-test="submit">Submit</button>
//为button添加data-testid属性
<button id="main" class="btn" data-testid="submit">Submit</button>
在测试用例中,采用如下方法与元素交互:
//使用data-cy属性
cy.get('[data-cy=submit]').click( )
//使用data-test属性
cy.get('[data-test=submit]').click()
//使用data-testid属性
cy.get('[data-testid=submit]').click( )
除了Cypress专有选择器外,还可以利用以下常规选择器来定位元素。
• #id选择器
#id选择器通过HTML元素的id属性选取指定的元素。
//使用button的id属性定位
cy.get('#main').click()
class类选择器
类选择器通过HTML元素的class属性选取指定的元素。
//使用button的class属性定位
cy.get('.bin').click()
• attributes属性选择器
属性选择器通过HTML元素的属性选取指定的元素。
//使用button的id属性定位,也可以写成如下形式
cy.get('button[id = "main"]').click()
:nth-child(n) 选择器
:nth-child(n) 选择器匹配属于其父元素的第n个子元素,不论元素的类型。
//例如在如下元素中找出iTesting并单击
<ul>
<li> iTesting </li>
<li>Ray</li>
<li>Kevin</li>
<li>Emily</li>
</ul>
//Cypress查找元素
cy.get('li:nth-child(1)').click( )
Cypress.$定位器
针对难以用普通方式定位的元素,Cypress还允许使用jQuery选择器Cypress.$(selector) 直接定位。
//Cypress查找元素,selector使用id Cypress.$(’#main’) //等同于 cy.get(’#main’)
//DOM元素如下
<ul>
<li id=”id”>iTesting</li>
<li>Ray</li>
<li>Kevin</li>
<li>Emily</li>
</ul>
.find(selector)方法用来在DOM树中搜索被定位的元素的后代,并用匹配元素来构造一个新的jQuery对象。
.find(selector)的语法如下:
.find(selector)
.find(selector)的用法如下:
//查找出iTesting这个节点
cy.get('ul').find('#id')
//.find( )不能直接链接cy,以下为错误示范
cy.find('#id')
.get(selector)
.get(selector)方法用来在DOM树中查找selector对应的元素。
.get(selector)的语法如下:
//以选择器定位 .get(selector)
//以别名定位,笔者将在后续章节”Cypress的独特之处”介绍 .get(alias)
.get(selector)的用法如下:
//仍以上例的DOM树为例,查找出iTesting这个元素
cy.get('#id')
.contains(selector)
.contains(selector)方法用来获取包含文本的DOM元素。
.contains(selector)的语法如下:
.contains(content)
.contains(selector, content)
.contains (selector)的用法如下:
//仍以上例的DOM树为例,查找出iTesting这个元素
//直接查找content
cy.contains('iTesting')
//通过selector查找
cy.contains('li','iTesting')
//通过正则表达式查找
cy.contains(/^i\w+/)
由于现代Web应用程序比较复杂,单一的定位方法往往不能精准地定位到所需元素,Cypress提供了一些辅助方法,可以提高查找元素的准确性。以下是一些常用的辅助方法。
假设存在DOM树如下:
//DOM元素如下
<ul>
<li id=”id”>iTesting</li>
<li>Ray</li>
<li id= ”kevin”>Kevin</li>
<li>Emily</li>
</ul>
.children( )
.children ( )方法用来获取DOM元素的子元素。
.children ( )的语法如下:
.children( ) .children(selector)
.children ( )的用法如下:
//以本节中的DOM树为例,查找出ul的所有子元素
cy.get('ul').children()
//查找出iTesting这个子元素
cy.get('ul').children('#id')
.parents( )
.parents( )方法用来获取DOM元素的所有父元素。
.parents( )的语法如下:
.parents( ) .parents(selector)
.parents( )的用法如下:
//找出iTesting的所有父元素
cy.get('#id').parents()
.parent( )
与.parents( )命令相反,.parent( )仅沿DOM树向上移动一个级别,它获得的是指定DOM元素的第一层父元素。
.parent( )的语法如下:
.parent( ) .parent(selector)
.parent( )的用法如下:
//找出iTesting的父元素
cy.get('#id').parent( )
.siblings( )
.siblings( )方法用来获取DOM元素的所有同级元素。
.siblings( )的语法如下:
.siblings( ) .siblings( )(selector)
.siblings ( )的用法如下:
//找出iTesting的同级元素
cy.get('#id').siblings( )
.first( )
.first ( )方法用来匹配给定DOM对象集的第一个元素。
.first ( )的语法如下:
.first( )
.first ( )的用法如下:
//找出iTesting
cy.get('#id').first( )
.last ( )方法用来匹配给定DOM对象集的最后一个元素。
.last ( )的语法如下:
.last( )
.last ( )的用法如下:
//找出ul的最后一个元素
cy.get('ul').last( )
.next( )
.next ( )方法用来匹配给定DOM对象紧跟着的下一个同级元素。
.next ( )的语法如下:
.next( )
.next ( )的用法如下:
//找出iTesting的下一个元素
cy.get('ul').next( )
• .nextAll( )
.nextAll ( )方法用来匹配给定DOM对象之后的所有同级元素。
.nextAll ( )的语法如下:
.nextAll( )
.next ( )的用法如下:
//找出iTesting之后的所有同级元素
cy.get('#id').nextAll( )
• .nextUntil(selector)
.nextUntil( )用来匹配给定DOM对象之后的所有同级元素直到遇到Until里定义的元素为止。
.nextUntil ( )的语法如下:
.nextUntil(selector) .nextUntil(selector, filter)
.nextUntil ( )的用法如下:
//找出Ray
cy.get('#id').nextUntil('#kevin')
• .prev( )
.prev( )方法用来匹配给定DOM对象紧跟着的上一个同级元素。
.prev( )的语法如下:
.prev( )
.prev( )的用法如下:
//找出iTesting的上一个元素
cy.get('ul').prev( )
• .prevAll( )
.prevAll( )方法用来匹配给定DOM对象之前的所有同级元素。
.prevAll( )的语法如下:
.prevAll( )
.prevAll ( )的用法如下:
//找出iTesting之前的所有同级元素
cy.get('#id').prevAll( )
• .prevUntil( )
.prevUntil( )用来匹配给定DOM对象之后的所有同级元素直到遇到Until里定义的元素为止。
.prevUntil( )的语法如下:
.prevUntil(selector) .prevUntil(selector, filter)
.prevUntil( )的用法如下:
//找出Ray
cy.get('#kevin').prevUntil('#id')
• .each( )
.each( )用来遍历数组及其类似结构(数组或对象有length属性)。
.each( )的语法如下:
.each(callbackFn)
.each( )的用法如下:
//打印ul所有子元素的文本
cy.get('#ul').each(($li)=>{ cy.log($li.text( )) })
• .eq( )
.eq( )用来在元素或者数组中的特定索引处获取DOM元素。它的作用跟jQuery中的:nth-child( )选择器相同。
.eq( )的语法如下:
.eq(index)
.eq( )的用法如下:
//获取ul的第一个字元素
cy.get('#ul').eq(0)
.click( )
单击某个元素。.click( )的语法如下:
//单击某个元素
.click( )
//带参数的单击
.click(options)
//在某个位置单击
.click(position)
其中,options可选参数包含{force:true}和{multiple:true}。
//强制单击 li 元素
cy.get('li').click({ force: true })
//单击所有的 li 元素
cy.get('li').click({ multiple: true })
有时候需要对某个元素的某个具体位置进行单击,click也提供了相应的方法。
//在li元素的右上角位置处单击
cy.get('li').click({'topRight'})
//在li元素的左上角位置处单击
cy.get('li').click({'topLeft'})
//在li元素的正上方位置处单击
cy.get('li').click({'top'})
//在li元素的左侧位置处单击
cy.get('li').click({'left'})
//在li元素的中心位置处单击
cy.get('li').click({'center'})
//在li元素的右侧位置处单击
cy.get('li').click({'right'})
//在li元素的左下角位置处单击
cy.get('li').click({'bottomLeft'})
//在li元素的正下方位置处单击
cy.get('li').click({'bottom'})
//在li元素的右下角位置处单击
cy.get('li').click({'bottomRight'})
.click( )还可以接受键值组合,例如“Shift+click”。
//在发现的第一个li元素上执行Shift+click操作
//{ release: false } 表明长按Shift键
cy.get('body').type('{shift}', { release: false })
cy.get('li:first').click( )
除Shift外,.click( )还支持如下按键:
{alt}:按住Alt键。
{ctrl}:按住Ctrl键。
• .dblclick( )
双击某个元素。.dblclick( )的语法如下:
//双击某个元素
.dblclick( ) /
/带参数的双击
.dblclick(options)
//在某个位置双击
.dblclick(position)
其中,options参数和position参数的选项跟.click( )完全一致。
• .rightclick( )
右击某个元素。.rightclick( )的语法如下:
//右击某个元素
.rightclick( )
//带参数的右击
.rightclick(options)
//在某个位置右击
.rightclick(position)
其中,options参数和position参数的选项跟.click( )完全一致。
• .type( )
往DOM元素中输入。.type( )的语法如下:
//输入文本 .type(text)
//带参数的输入
.type(text, options)
例如:
//输入用户名iTesting
cy.get('input[username=”name”]').type('iTesting')
在日常测试过程中,若需要输入一些特殊字符,text参数可以使用下列文本:
//输入“{“ cy.get('input[username=”name”]').type('{{}')
text参数支持的其他特殊字符如下:
{backspace}:删除光标左侧的字符;
{del}:删除光标右侧的字符;
{downarrow}:向下移动光标;
{end}:将光标移到行尾;
{enter}:按Enter键;
{esc}:按ESC键;
{home}:将光标移到行首;
{insert}:在光标右边插入字符;
{leftarrow}:向左移动光标;
{pagedown}:向下滚动;
{pageup}:向上滚动;
{rightarrow}:向右移动光标;
{selectall}:通过创建选择范围来选择所有文本;
{uparrow}:向上移动光标。
Options可接受如下参数:
• .clear( )
.clear( )清除输入或文本区域的值。.clear( ) 语法如下:
.clear( )
例如:
//清除用户名
cy.get('input[username=”name”]').clear( )
//也可写成
cy.get('input').type({selectall}{backspace})
• .check( )
针对类型的单选框(radio button)
或者复选框(check box)
,Cypress提供了check和uncheck方法直接操作。语法如下:
//选中 .check( )
//选中一个选项,值是value .check(value)
//选中多个选项 .check(values)
例如:
//选中US这个选项
cy.get('[type="radio"]').check('US')
//选中ga和ca这两个选项
cy.get('[type="checkbox"]').check(['ga', 'ca'])
• .uncheck( )
.uncheck( )跟.check( )的用法相反,它用于取消选中单选框或者复选框。语法如下:
//取消选中 .uncheck( )
//取消选中某选项 .uncheck(value)
//取消选中多个选项 .uncheck(values)
例如:
//取消选中US这个选项
cy.get('[type="radio"]').uncheck('US')
//取消选中ga和ca这两个选项
cy.get('[type="checkbox"]').uncheck(['ga', 'ca'])
• .select( )
.select( )用来在中选择一个。语法如下:
.select(value) .select(values)
假设DOM树如下所示:
<select>
<option value="1">iTesting</option>
<option value="2">kevin</option>
<option value="3">emily</option>
</select>
select( )写法如下:
//选中iTesting
cy.get('select').select('iTesting')
//选中iTesting和kevin
cy.get('select').select(['iTesting', 'Kevin'])
• .trigger( )
.trigger( )用来在DOM元素上触发事件。语法如下:
.trigger(eventName)
例如:
//按下光标
cy.get('button').trigger('mousedown')
//移动光标到元素之上
cy.get('button').trigger('mouseover') /
/抬起光标 cy.get('button').trigger('mouseleave')
Cypress中有如下几种常见的操作场景。
• 访问某个网站
//访问 helloqa.com
cy.visit('https://helloqa.com')
如果你在cypress.json中配置了baseUrl的值,则Cypress将自动为你加上前缀。
//cypress.json
{ "baseUrl": "http://www.helloqa.com" }
//访问http://www.helloqa.com//categories/api-test
cy.visit('/categories/api-test')
• 获取当前页面URL地址
在Cypress中,可以使用下述方式来获取当前页面地址。
//获取页面地址 cy.url( )
//检查当前页面地址是否包括api-test
cy.url().should('contains','api-test')
• 刷新当前页面
在Cypress中,可以使用cy.reload( )来刷新当前页面。
//刷新页面,等同于F5
cy.reload( )
//强制刷新页面,等同于
CTRL+ F5
cy.reload(true)
• 最大化窗口[1]
在Cypress中,默认运行时的窗口大小为1000px1660px。如果你的屏幕不够大,无法显示完整的像素,Cypress将自动缩小并居中显示你的应用程序。可以通过以下两种设置来设置运行窗口。
//cypress.json中添加
{ "viewportWidth": 1000, "viewportHeight": 660 }
//运行中设置 cy.viewpoint(1024, 768)
• 网页的前进或后退
在Cypress中使用cy.go( )来实现网页的前进或后退。前进或后退的依据是浏览历史记录中的URL。
//后退
cy.go('back')
//或者
cy.go(-1)
//前进
cy.go('forward')
//或者
cy.go(1)
• 判断元素是否可见
在Cypress里,要判断元素是否可见,可以直接使用should判断,Cypress会自动为你重试直至元素可见或者超时。
//判断 .check-box 是否可见
cy.get('.check-box ').should('be.visible')
cy.get('.check-box').should('be.visible')
• 判断元素是否存在
//判断元素存在
cy.get('.check-box').should('exist')
//判断元素不存在
cy.get('.check-box').should('not.exist')
• 条件判断
在日常测试中,有时候需要对某个元素进行条件判断,即满足条件A时执行A操作,满足条件B时执行B操作。Cypress称之为“条件测试(Conditional Testing)”
并建议避免编写包含条件测试的脚本,因为条件测试通常比较脆弱,容易导致测试失败。
一个典型的例子是如果元素A存在,则执行单击A操作,如果不存在,则什么都不做。
Cypress建议脚本编写者提供A条件出现的必要步骤来确保A条件一定会满足从而避免条件判断。
但你仍然可以用Cypress支持jQuery的特性来使用条件判断。
//利用 jquery 来判断元素是否存在
const btnLocator = '#btn'
Cypress.$(btnLocator).length>0){
cy.get(btnLocator).click( )
}
• 获取元素属性值
在Cypress中,无法直接返回元素属性值。
//获取元素btn的文本
cy.get('#btn').then(($btn) => {
const btntxt = $btn.text()
cy.log(btntxt)
})
• 清除文本
在Cypress中,可使用cy.clear( )来清除input输入框和textarea输入框的值。
//清除input输入框的值 cy.get('input[name=username]').clear( ) //等同于 cy.get('input[name=username]').type({selectall}
{backspace})
• 操作表单输入框
在Cypress中,可使用cy.clear( )和cy.type( )组合来操作输入框。
//清除 username 并输入用户名“iTesting” cy.clear(‘input[name=username]’).type(‘iTesting’)
• 操作单选/多选按钮
针对类型的单选框或者复选框,Cypress提供了check和uncheck方法直接操作。
//选中US这个选项
cy.get('[type="radio"]').check('US')
//取消选中US这个选项
cy.get('[type="radio"]').uncheck('US')
• 操作下拉菜单
如果下拉菜单是Select形式的,则直接使用如下方式操作。
cy.get('select').select('下拉选项的值')
如果下来菜单是其他形式的,例如DOM树形结构如下所示:
<div id="form">
<ul role="listbox" class="select-dropdown-menu">
<li role="option" class="select-dropdown-item">iTesting</li>
<li role="option" class="select-dropdown-item">kevin</li>
<li role="option" class="select-dropdown-item">emily</li>
</ul> </div>
则查找iTesting并选中的写法如下:
cy.get('li').eq(0).click( )
• 操作弹出框
最常见的是提交确认弹出框,解决方法跟正常的页面一样,首先使用cy.get( )或cy.find( )定位到弹出框元素,然后操作即可。
对于iframe格式的弹出框,可以通过闭包解决。
cy.get('iframe') .then(function ($iframe) {
//定义要查找的元素
const $body = $iframe.contents( ).find('body')
//在查找到的元素中查找btn并单击
cy.wrap($body).find('#btn').click( ) })
• 操作被覆盖元素
碰见元素被覆盖无法操作的情况,可以直接使用{force:true}。
//强制单击 btn 元素 cy.get('#btn').click({ force: true })
• 操作页面滚动条
滚动条操作有两种方式,一种是元素不在视图中,需要拖动滚动条直到元素出现,如下图所示。
假设DOM树如下所示:
<div id="scroll-horizontal" style="height: 300px; width: 300px; overflow: auto;">
<div style="width: 1000px; position: relative;"> 水平滚动框 <button class="btn btn-danger" style="position: absolute; top: 0; left: 500px;">提交<
/button> </div> </div>
由DOM结构得知,滚动框的视图宽度只有300px,但要操作的“提交”button却在1000px处。操作此按钮的代码如下:
//确认提交按钮不在视图中 `
cy.get('#scroll-horizontal button') .should('not.be.visible')
//滚动直到提交安装出现在视图中 c
y.get('#scroll-horizontal button').scrollIntoView( ) .should('be.visible')
//单击,提交button
cy.get('.btn btn-danger').click( )`
滚动条有时也可作为操作设置某项属性出现,如图6-2所示。
模拟键盘操作
模拟键盘操作,例如按Enter键等。
//以登录框为例,输入mail地址,然后按Enter键
cy.get('input["id=mail"]').type('[email protected]')
cy.get('input["id=mail"]').type('{enter}')
更多关于模拟键盘的操作,请参考.type( )和.click( )的参数。
Cypress在定位元素时会遵循以下的优先级:
(1)data-cy;
(2)data-test;
(3)data-testid;
(4)id;
(5)class;
(6)tag;
(7)attributes;
(8)nth-child。
Cypress会尝试从高优先级的定位策略开始,定位目标元素。如果默认的定位顺序不符合应用程序实际情况,你可以更改元素定位的优先级顺序。语法如下:
//设置定位策略优先级
Cypress.SelectorPlayground.defaults(options)
//获取元素选择器的值
Cypress.SelectorPlayground.getSelector($el)
其中options的可选值是以上八种定位策略的一种或多种:
//设置定位策略优先级,最高级是id
Cypress.SelectorPlayground.defaults({ selectorPriority: ['id', 'class', 'attributes'] })
例如,假设有如下HTML代码段:
登录
默认情况下,获取到的元素选择器的值应该是login。
const $el = Cypress.$('button')
//selector的返回值是login
const selector = Cypress.SelectorPlayground.getSelector($el)
更改元素定位策略,再次获取元素选择器值,selector的值变成了“login-class”。
Cypress.SelectorPlayground.defaults({ selectorPriority: ['class', 'id'] })
//selector的值变成了login-class
const $el = Cypress.$('button') const selector = Cypress.SelectorPlayground.getSelector($el)
如果测试过程中发生错误,大部分的测试框架都无法得知测试执行时被测应用程序所处的状态,只能在测试运行结束后通过日志、截图来猜测测试失败的原因。Cypress测试运行器则完全相反。它忠实地记录了每一条测试命令执行时被测应用程序所处的状态,并且保存起来以便随时回溯,这种能力被称为时间穿梭(time-travel)。
需要注意的是,Cypress保存的是应用程序状态,不是截图。故Cypress支持查看命令执行时发生的一切操作,用户可直接定位到错误的根本原因,无须猜测。
在测试结束后,可以通过鼠标悬停,或者用鼠标单击某个命令的方式来进行时间穿梭。
使用鼠标悬停,可以在应用程序预览中查看命令作用到被测应用的具体情况。
使用鼠标单击,将在浏览器的Console中看到当命令执行时,应用到了被测应用程序的哪个元素上,以及当时的上下文详细信息。
初次使用Cypress,你产生的第一个问题恐怕就是为什么元素赋值不成功。
我们来通过一段代码来解释。假设你使用的是Selenium。
//以下代码仅用来解释概念
//假设先查找父元素,再查找子元素
driver = webdriver.Chrome( )
driver.get('http://www.helloqa.com')
Ids = driver.find_element_by_id('id')
//此处执行成功
Ids.find_element_by_id('id2').click( )
如果你使用Cypress来“翻译”上述代码,则会出错。
const Ids = cy.get("#id") //但执行到此处会失败 Ids.find('id2').click( )
失败的原因是Cypress命令不是同步执行的。Cypress命令在被调用时并不会被马上执行,Cypress会先把所有命令排队(enquene),然后再执行。也就是说当cy.get("#id")被初次调用时,Ids的值是undefined,故测试会失败。
//普通函数
var x = function(x, y) { return x * y; }
//箭头函数
const x = (x, y) => x * y;
但在Cypress中,使用箭头函数时需注意:
///
describe('测试箭头函数', function ( ) {
beforeEach(function ( ) {
//wrap 'hello'到text中
cy.wrap('hello').as('text') })
//it里用了箭头函数后拿不到wrap的text了
it('访问不到', ( )=> {
//this.text打印为空
cy.log(this.text) }) })
cypress中赋值永远失败
同源策略是浏览器安全的基石。这也意味着当两个iframe直接有访问时,必须同时满足协议相同、域名相同、端口相同三个条件。由于Cypress是运行在浏览器之中的,要测试应用程序,Cypress必须始终能够和应用程序直接通信,但显然浏览器的同源策略不允许。
Cypress通过以下方式“绕过了”浏览器的限制。
(1)将document.domain注入text / html页面。
(2)代理所有HTTP / HTTPS通信。
(3)更改托管的URL以匹配被测应用程序的URL。
(4)使用浏览器的内部API进行网络间通信。
首次加载Cypress时,内部Cypress Web应用程序托管在一个随机端口上,类似于http://localhost:65874 / __ /。
在一次测试中,当第一个cy.visit( )命令被发出后,Cypress将更改其URL以匹配远程应用程序的来源,从而解决了同源策略的主要障碍。但这样带来的坏处是在一次测试运行中,访问的域名必须处于同一个超域(Super domain)下,否则Cypress测试将会报错。
///
describe('一次测试访问不同域名', function ( ) {
let testVar it('立刻报错', function ( ) {
cy.visit('https://helloqa.com')
cy.visit('https://www.baidu.com') }) })
在Cypress中,保存一个值或者引用的最好方式是使用闭包。.then( )是Cypress对闭包的一个典型应用。.then( )返回的是上一个命令的结果,并将其注入下一个命令中。
举例来说,获取button文本改变前后的值并用于比较。
cy.get('button').then(($btn) => {
//保存btn元素的文本信息在txt变量中
const txt = $btn.text( )
//假设提交form会改变button的文本
cy.get('form').submit( )
//再次获取button的文本并和之前的文本对比
cy.get('button').should(($btn2) => {
expect($btn2.text( )).not.to.eq(txt) }) })
考虑这样一种情形,当你的测试需要一个前置条件才能执行,比如得到数据库的某个表的值。那么,该如何做呢?
/这种方法可以实现,但是不够优雅
describe('a suite', function ( ) {
//创建闭包
let text beforeEach(function ( ) {
cy.visit('http://www.helloqa.com')
cy.contains('首页').then(($el)=>{
text = $el.text( ) }) })
it('does have access to text', function ( ) {
//text可以访问
cy.log(text) }) })
.wrap( )返回传递给它的对象。语法如下:
cy.wrap(subject)
cy.wrap(subject, options)
const getName = ( ) => {
return 'iTesting' }
//返回true
cy.wrap({ name: getName }).invoke('name').should('eq', 'iTesting')
.as( )用于分配别名以供以后使用。稍后在带有@前缀的cy.get( )或cy.wait( )命令中引用该别名。
.as(aliasName)
describe('a suite', function ( ) {
beforeEach(function ( ) {
cy.visit('http://www.helloqa.com')
cy.contains('首页').then(($el)=>{
//把$el.text( )设置成别名
text cy.wrap($el.text( )).as('text')
}) })
it('does have access to text', function ( ) {
//使用.get('@text')访问别名, text是.as('text')里定义的名字
cy.get('@text').then((el)=>{
cy.log(el)
})
}) })