变量与别名
学习目标
- Cypress中处理异步代码的常见模式
- 何时应该赋值变量,何时不需要
- 如何使用别名在钩子和测试间共享对象
- 使用
this
的陷阱及如何避免 - 如何为DOM元素、拦截和请求创建别名
Cypress新手可能会发现处理API的异步特性很有挑战性。有许多方法可以引用、比较和使用Cypress命令返回的对象。一旦掌握异步代码,你会意识到无需复杂技巧就能实现同步代码的所有功能。
本指南探讨了许多编写优质Cypress代码的常见模式,这些模式能处理最复杂的情况。
异步API在JavaScript中已成为常态,现代代码中随处可见。实际上,大多数新的浏览器API都是异步的,许多Node核心模块也是如此。
下面探讨的模式在Cypress内外都很有用。首先需要认识的最重要概念是...
你不能直接赋值或操作任何Cypress命令的返回值。命令是异步排队执行的。
// 这种方式不会按预期工作
const button = cy.get('button')
const form = cy.get('form')
button.click()
闭包
要访问Cypress命令的返回值,需要使用.then()
。
cy.get('button').then(($btn) => {
// $btn是上一个命令返回的对象
})
如果熟悉原生Promise,Cypress的.then()
工作方式类似。你可以在.then()
中继续嵌套更多Cypress命令。
每个嵌套命令都能访问之前命令的结果,这使得代码可读性很好。
cy.get('button').then(($btn) => {
// 存储按钮文本
const txt = $btn.text()
// 提交表单
cy.get('form').submit()
// 比较两个按钮文本
// 确保它们不同
cy.get('button').should(($btn2) => {
expect($btn2.text()).not.to.eq(txt)
})
})
// 这些命令会在所有
// 前面命令完成后执行
cy.get(...).find(...).should(...)
.then()
外部的命令会等到所有嵌套命令完成后才执行。
通过回调函数,我们创建了闭包。闭包让我们能保留对之前命令结果的引用。
调试
使用.then()
函数是使用debugger
的绝佳机会。这能帮助你理解命令执行顺序,也能检查每个命令返回的对象。
cy.get('button').then(($btn) => {
// 检查$btn <对象>
debugger
cy.get('[data-testid="countries"]')
.select('USA')
.then(($select) => {
// 检查$select <对象>
debugger
cy.clock().then(($clock) => {
// 检查$clock <对象>
debugger
$btn // 仍然可用
$select // 也仍然可用
})
})
})
变量
通常在Cypress中几乎不需要使用const
、let
或var
。使用闭包时,你总能访问命令返回的对象而无需赋值。
唯一的例外是处理可变状态对象时。当对象状态变化时,你通常需要比较前后值。
这是使用const
的好例子。
<button>increment</button>
你点击了按钮 <span data-testid="num">0</span> 次
// 应用代码
let count = 0
$('button').on('click', () => {
$('[data-testid="num"]').text((count += 1))
})
// Cypress测试代码
cy.get('[data-testid="num"]').then(($span) => {
// 捕获当前数值
const num1 = parseFloat($span.text())
cy.get('button')
.click()
.then(() => {
// 再次捕获
const num2 = parseFloat($span.text())
// 验证是否符合预期
expect(num2).to.eq(num1 + 1)
})
})
使用const
的原因是$span
对象是可变的。处理可变对象并需要比较时,必须存储它们的值。const
是完美选择。
别名
使用.then()
回调访问之前命令的值很好,但在before
或beforeEach
等钩子中运行代码时怎么办?
beforeEach(() => {
cy.get('button').then(($btn) => {
const text = $btn.text()
})
})
it('无法访问text', () => {
// 如何访问text?!
})
如何访问text
?
我们可以用let
做些丑陋的技巧来访问它。
下面的代码仅用于演示。
describe('测试套件', () => {
// 创建闭包保存
// 'text'以便访问
let text
beforeEach(() => {
cy.get('button').then(($btn) => {
// 重新定义text引用
text = $btn.text()
})
})
it('可以访问text', () => {
// 现在text可用了
// 但这不是好方案 :(
text
})
})
幸运的是,你不必用这种技巧。Cypress提供了更好的解决方案。
别名是Cypress中强大的功能,有多种用途。下面我们将探讨每种用法。
首先,我们用它来在钩子和测试间共享对象。
共享上下文
共享上下文是别名最简单的用法。
使用.as()
命令创建要共享的别名。
看之前例子用别名的实现:
beforeEach(() => {
// 将$btn.text()别名为'text'
cy.get('button').invoke('text').as('text')
})
it('可以访问text', function () {
this.text // 现在可用了
})
底层实现上,别名基本对象和原始值利用了Mocha的共享context
对象:即别名可作为this.*
访问。
Mocha自动在每个测试的适用钩子间共享上下文。此外,这些别名和属性会在每个测试后自动清理。
describe('父级', () => {
beforeEach(() => {
cy.wrap('one').as('a')
})
context('子级', () => {
beforeEach(() => {
cy.wrap('two').as('b')
})
describe('孙级', () => {
beforeEach(() => {
cy.wrap('three').as('c')
})
it('可以访问所有别名属性', function () {
expect(this.a).to.eq('one') // true
expect(this.b).to.eq('two') // true
expect(this.c).to.eq('three') // true
})
})
})
})
访问Fixture:
共享上下文最常见的用例是处理cy.fixture()
。
通常在beforeEach
钩子中加载fixture,但需要在测试中使用这些值。
beforeEach(() => {
// 为users fixture创建别名
cy.fixture('users.json').as('users')
})
it('以某种方式使 用users', function () {
// 访问users属性
const user = this.users[0]
// 确保标题包含第一个用户名
cy.get('header').should('contain', user.name)
})
别忘了Cypress命令是异步的!
在.as()
命令执行前不能使用this.*
引用。
it('没有正确使用别名', function () {
cy.fixture('users.json').as('users')
// 这样不行
//
// this.users未定义
// 因为'as'命令只是入队
// 还未执行
const user = this.users[0]
})
之前介绍的原则同样适用。要访问命令返回值,必须在闭包中使用.then()
。
// 这样完全正确
cy.fixture('users.json').then((users) => {
// 现在可以完全不用别名
// 直接使用回调函数
const user = users[0]
// 通过
cy.get('header').should('contain', user.name)
})
避免使用this
如果测试或钩子使用箭头函数,用this.*
访问别名将不起作用。
这就是为什么所有例子都使用常规function () {}
语法而不是lambda箭头语法() => {}
。
除了this.*
语法,还有另一种访问别名的方式。
cy.get()
命令可以通过@
字符的特殊语法访问别名:
beforeEach(() => {
// 为users fixture创建别名
cy.fixture('users.json').as('users')
})
it('以某种方式使用users', function () {
// 使用特殊的'@'语法访问别名
// 避免使用'this'
cy.get('@users').then((users) => {
// 访问users参数
const user = users[0]
// 确保标题包含第一个用户名
cy.get('header').should('contain', user.name)
})
})
使用cy.get()
可以避免使用this
。
记住两种方法各有适用场景,因为它们有个重要区别。
使用this.users
时,值在首次求值时存储在上下文中。而使用cy.get('@users')
时,每次访问别名都会重新查询。
const favorites = { color: 'blue' }
cy.wrap(favorites).its('color').as('favoriteColor')
cy.then(function () {
favorites.color = 'red'
})
cy.get('@favoriteColor').then(function (aliasValue) {
expect(aliasValue).to.eql('red')
expect(this.favoriteColor).to.eql('blue')
})
在第二个.then()
块中,cy.get('@favoriteColor')
每次都会重新运行cy.wrap(favorites).its('color')
,而this.favoriteColor
是在别名首次存储时设置的,那时颜色还是蓝色。
DOM元素
别名用于DOM元素时有其他特殊特性。
为DOM元素创建别名后,可以稍后访问它们进行复用。
// 将表格中所有tr别名为'rows'
cy.get('table').find('tr').as('rows')
内部实现上,Cypress将<tr>
集合引用为"rows"别名。要稍后引用这些"rows",可以使用cy.get()
命令。
// Cypress返回<tr>的引用
// 允许我们继续链式调用命令
// 找到第一行
cy.get('@rows').first().click()
因为在cy.get()
中使用了@
字符,它不会查询DOM元素,而是查找名为rows
的现有别名并返回引用(如果找到)。
过时元素:
在许多单页应用中,JavaScript会不断重新渲染部分DOM。这就是为什么我们获取别名时总是重新运行查询,确保你不会得到过时元素。
<ul id="todos">
<li>
遛狗
<button class="edit">编辑</button>
</li>
<li>
喂猫
<button class="edit">编辑</button>
</li>
</ul>
假设点击.edit
按钮时,<li>
会在DOM中重新渲染。不再显示编辑按钮,而是显示<input />
文本框让你编辑待办项。之前的<li>
已完全从DOM移除,新的<li>
渲染在它的位置。
cy.get('[data-testid="todos"] li').first().as('firstTodo')
cy.get('@firstTodo').find('.edit').click()
cy.get('@firstTodo')
.should('have.class', 'editing')
.find('input')
.type('清理厨房')
每次引用@firstTodo
时,Cypress会重新运行定义别名的查询。
本例中会重新查询DOM:cy.get('#todos li').first()
。因为能找到新的<li>
,一切正常。
通常,重放之前的命令会返回预期结果,但不总是如此。建议在运行命令前创建别名。
cy.get('nav').find('header').find('[data-testid="user"]').as('user').click()
(正确)cy.get('nav').find('header').find('[data-testid="user"]').click().as('user')
(错误)
拦截
别名也可用于cy.intercept()。为拦截的路由创建别名可以:
- 确保应用发出预期请求
- 等待服务器发送响应
- 访问实际请求对象进行断言
