Skip to main content
Cypress应用

变量与别名

info
学习目标
  • Cypress中处理异步代码的常见模式
  • 何时应该赋值变量,何时不需要
  • 如何使用别名在钩子和测试间共享对象
  • 使用this的陷阱及如何避免
  • 如何为DOM元素、拦截和请求创建别名

Cypress新手可能会发现处理API的异步特性很有挑战性。有许多方法可以引用、比较和使用Cypress命令返回的对象。一旦掌握异步代码,你会意识到无需复杂技巧就能实现同步代码的所有功能。

本指南探讨了许多编写优质Cypress代码的常见模式,这些模式能处理最复杂的情况。

异步API在JavaScript中已成为常态,现代代码中随处可见。实际上,大多数新的浏览器API都是异步的,许多Node核心模块也是如此。

下面探讨的模式在Cypress内外都很有用。首先需要认识的最重要概念是...

danger

你不能直接赋值或操作任何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()外部的命令会等到所有嵌套命令完成后才执行。

info

通过回调函数,我们创建了闭包。闭包让我们能保留对之前命令结果的引用。

调试

使用.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中几乎不需要使用constletvar。使用闭包时,你总能访问命令返回的对象而无需赋值。

唯一的例外是处理可变状态对象时。当对象状态变化时,你通常需要比较前后值。

这是使用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()回调访问之前命令的值很好,但在beforebeforeEach等钩子中运行代码时怎么办?

beforeEach(() => {
cy.get('button').then(($btn) => {
const text = $btn.text()
})
})

it('无法访问text', () => {
// 如何访问text?!
})

如何访问text

我们可以用let做些丑陋的技巧来访问它。

danger
不要这样做

下面的代码仅用于演示。

describe('测试套件', () => {
// 创建闭包保存
// 'text'以便访问
let text

beforeEach(() => {
cy.get('button').then(($btn) => {
// 重新定义text引用
text = $btn.text()
})
})

it('可以访问text', () => {
// 现在text可用了
// 但这不是好方案 :(
text
})
})

幸运的是,你不必用这种技巧。Cypress提供了更好的解决方案。

tip
引入别名

别名是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)
})
danger
注意异步命令

别忘了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

caution
箭头函数

如果测试或钩子使用箭头函数,用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>,一切正常。

caution

通常,重放之前的命令会返回预期结果,但不总是如此。建议在运行命令前创建别名

  • 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()。为拦截的路由创建别名可以:

  • 确保应用发出预期请求
  • 等待服务器发送响应
  • 访问实际请求对象进行断言
�别名命令

这是为拦截路由创建别名并等待完成的例子。

cy.intercept('POST', '/users', { id: 123 }).as('postUser')

cy.get('form').submit()

cy.wait('@postUser').then(({ request }) => {
expect(request.body).to.have.property('name', 'Brian')
})

cy.contains('成功创建用户: Brian')

请求

别名也可用于请求

这是为请求创建别名并稍后访问其属性的例子。

cy.request('https://jsonplaceholder.cypress.io/comments').as('comments')

// 其他测试代码

cy.get('@comments').should((response) => {
if (response.status === 200) {
expect(response).to.have.property('duration')
} else {
// 你想检查的任何内容
}
})
})

别名在每个测试前重置

注意: 所有别名在每个测试前都会重置。常见错误是在before钩子中创建别名。这种别名只在第一个测试中有效!

// 🚨 这个例子不工作
before(() => {
// 注意这个别名只通过"before"钩子创建一次
cy.wrap('some value').as('exampleValue')
})

it('在第一个测试中有效', () => {
cy.get('@exampleValue').should('equal', 'some value')
})

// 注意第二个测试会失败,因为别名已重置
it('在第二个测试中不存在', () => {
// 没有别名因为它只在第一个测试前创建
// 在第二个测试前已重置
cy.get('@exampleValue').should('equal', 'some value')
})

解决方案是在每个测试前使用beforeEach钩子创建别名

// ✅ 正确示例
beforeEach(() => {
// 每个测试前创建新别名
cy.wrap('some value').as('exampleValue')
})

it('在第一个测试中有效', () => {
cy.get('@exampleValue').should('equal', 'some value')
})

it('在第二个测试中也有效', () => {
cy.get('@exampleValue').should('equal', 'some value')
})