Skip to main content
Cypress应用

自定义命令

Cypress提供了创建自定义命令和覆盖现有命令的API。内置的Cypress命令使用的正是下文定义的相同API。

有两种API可用于添加自定义命令:

  • Cypress.Commands.add() - 用于添加自定义命令以便在编写测试时使用
  • Cypress.Commands.overwrite() - 用于覆盖现有的内置Cypress命令或保留的内部函数。**注意:**这会同时覆盖Cypress的行为,可能影响Cypress的运行方式。
info

如果你希望方法具备内置的重试能力,特别是如果你返回一个DOM元素供后续命令操作,考虑编写自定义查询代替。

info

我们建议在cypress/support/commands.js文件中定义查询,因为该文件通过supportFile中的import语句在任何测试文件执行前加载。

语法

Cypress.Commands.add(name, callbackFn)
Cypress.Commands.add(name, options, callbackFn)
Cypress.Commands.addAll(callbackObj)
Cypress.Commands.addAll(options, callbackObj)
Cypress.Commands.overwrite(name, callbackFn)

用法

正确用法

Cypress.Commands.add('login', (email, pw) => {})
Cypress.Commands.addAll({
login(email, pw) {},
visit(orig, url, options) {},
})
Cypress.Commands.overwrite('visit', (orig, url, options) => {})

参数

name (String)

要添加或覆盖的命令名称。

callbackFn (Function)

传入一个接收命令参数的函数。

callbackObj (Object)

包含callbackFn作为属性的对象。

options (Object)

传入一个选项对象以定义自定义命令的隐式行为。

caution

options仅支持在Cypress.Commands.add()中使用,不支持在Cypress.Commands.overwrite()中使用。

选项接受值默认值描述
prevSubjectBoolean, StringArrayfalse如何处理先前产生的主题。

prevSubject接受以下值:

  • false: 忽略任何先前的主题:(父命令)
  • true: 接收先前的主题:(子命令)
  • optional: 可以开始一个新链或使用现有链:(双重命令)

除了控制命令的隐式行为外,你还可以添加声明式主题验证,如:

  • element: 要求先前主题是DOM元素
  • document: 要求先前主题是文档
  • window: 要求先前主题是窗口

示例

父命令

父命令总是开始一个新的命令链。即使你在先前命令上链式调用它,父命令也会始终开始一个新链,并忽略先前产生的主题。

父命令的示例:

点击包含文本的链接

Cypress.Commands.add('clickLink', (label) => {
cy.get('a').contains(label).click()
})
cy.clickLink('立即购买')

检查令牌

Cypress.Commands.add('checkToken', (token) => {
cy.window().its('localStorage.token').should('eq', token)
})
cy.checkToken('abc123')

下载文件

最初用于cypress-downloadfile,此命令调用其他Cypress命令。

Cypress.Commands.add('downloadFile', (url, directory, fileName) => {
return cy.getCookies().then((cookies) => {
return cy.task('downloadFile', {
url,
directory,
cookies,
fileName,
})
})
})
cy.downloadFile('https://path_to_file.pdf', 'mydownloads', 'demo.pdf')

操作sessionStorage的命令

Cypress.Commands.add('getSessionStorage', (key) => {
cy.window().then((window) => window.sessionStorage.getItem(key))
})

Cypress.Commands.add('setSessionStorage', (key, value) => {
cy.window().then((window) => {
window.sessionStorage.setItem(key, value)
})
})
cy.setSessionStorage('token', 'abc123')
cy.getSessionStorage('token').should('eq', 'abc123')

通过UI登录的命令

Cypress.Commands.add('loginViaUi', (user) => {
cy.session(
user,
() => {
cy.visit('/login')
cy.get('input[name=email]').type(user.email)
cy.get('input[name=password]').type(user.password)
cy.get('button#login').click()
cy.get('h1').contains(`欢迎回来 ${user.name}!`)
},
{
validate: () => {
cy.getCookie('auth_key').should('exist')
},
}
)
})
cy.loginViaUi({ email: 'fake@email.com', password: '$ecret1', name: 'johndoe' })

通过API登录的命令

Cypress.Commands.add('loginViaApi', (userType, options = {}) => {
// 这是一个跳过UI直接通过编程方式登录的示例

// 设置一些基本类型
// 和用户属性
const types = {
admin: {
name: '简·莱恩',
admin: true,
},
user: {
name: '吉姆·鲍勃',
admin: false,
},
}

// 获取用户
const user = types[userType]

// 首先在数据库中创建用户
cy.request({
url: '/seed/users', // 假设你暴露了一个种子路由
method: 'POST',
body: user,
})
.its('body')
.then((body) => {
// 假设服务器返回用户详情
// 包括随机生成的密码
//
// 我们现在可以以这个新创建的用户登录
cy.request({
url: '/login',
method: 'POST',
body: {
email: body.email,
password: body.password,
},
})
})
})
// 可以从cy开始一个链
cy.loginViaApi('admin')

// 可以链式调用但不会接收先前主题
cy.get('button').loginViaApi('user')

通过UI注销的命令

Cypress.Commands.add('logout', () => {
cy.contains('登录').should('not.exist')
cy.get('.avatar').click()
cy.contains('注销').click()
cy.get('h1').contains('登录')
cy.getCookie('auth_key').should('not.exist')
})

使用localStorage注销的命令
仅限端到端测试

Cypress.Commands.add('logout', () => {
cy.window().its('localStorage').invoke('removeItem', 'session')

cy.visit('/login')
})
cy.logout()

创建用户

Cypress.Commands.add('createUser', (user) => {
cy.request({
method: 'POST',
url: 'https://www.example.com/tokens',
body: {
email: 'admin_username',
password: 'admin_password',
},
}).then((resp) => {
cy.request({
method: 'POST',
url: 'https://www.example.com/users',
headers: { Authorization: 'Bearer ' + resp.body.token },
body: user,
})
})
})
cy.createUser({
id: 123,
name: '简·莱恩',
})
info
命令日志

你知道你可以控制自定义命令在命令日志中的显示方式吗?阅读更多关于命令日志的内容。

子命令

子命令总是从父命令或另一个子命令链式调用。

先前主题会自动传递给回调函数。

子命令的示例:

自定义console命令

// 不是一个非常有用的自定义命令
// 但演示了如何传递主题
// 以及参数如何移位
Cypress.Commands.add(
'console',
{
prevSubject: true,
},
(subject, method) => {
// 自动接收先前主题
// 命令参数移位

// 允许我们更改使用的控制台方法
method = method || 'log'

// 将主题记录到控制台
console[method]('主题是', subject)

// 我们返回的任何内容都会成为新主题
//
// 我们不想更改主题,所以
// 返回传入的任何内容
return subject
}
)
cy.get('button')
.console('info')
.then(($button) => {
// 主题仍然是$button
})

通过设置{ prevSubject: true },我们的新.console()命令将需要一个主题。

这样调用会出错:

cy.console() // 错误:没有主题不能调用console
info

当你使用子命令时,你可能希望在主题上使用cy.wrap()。包装它使你能够立即在该主题上使用更多Cypress命令。

双重命令

双重命令可以开始一个命令链或链式调用现有命令。它基本上是父命令和子命令的混合体。你可能很少使用这个,只有少数内部命令使用这个。

尽管如此,如果你的命令可以以多种方式工作——有或没有现有主题,它会很有用。

双重命令的示例:

自定义双重命令

Cypress.Commands.add('dismiss', {
prevSubject: 'optional'
}, (subject, arg1, arg2) => {
// 主题可能已定义或未定义
// 因此你可能希望基于此分支逻辑

if (subject) {
// 包装现有主题
// 并对其进行操作
cy.wrap(subject)
...
} else {
...
}
})
cy.dismiss() // 无主题
cy.get('#dialog').dismiss() // 有主题

覆盖现有命令

你也可以修改现有Cypress命令的行为。这对于总是设置一些默认值以避免创建另一个最终使用原始命令的命令很有用。

caution

Cypress.Commands.overwrite只能覆盖命令,不能覆盖查询。如果你想修改查询的行为,需要使用Cypress.Commands.overwriteQuery代替。

覆盖visit命令

Cypress.Commands.overwrite('visit', (originalFn, url, options) => {
const domain = Cypress.env('BASE_DOMAIN')

if (domain === '...') {
url = '...'
}

if (options.something === 'else') {
url = '...'
}

// originalFn是现有的`visit`命令,你需要调用它
// 它会接收你在这里传入的任何内容。
//
// 确保在这里添加一个返回!
return originalFn(url, options)
})
info

我们看到许多用户创建自己的visitApp命令。我们常见的是你只是为developmentproduction环境交换基本URL。

这通常是不必要的,因为Cypress已经配置为交换cy.visit()cy.request()都使用的baseUrl。在你的Cypress配置中设置baseUrl配置属性,并使用CYPRESS_BASE_URL环境变量覆盖它。

对于更复杂的用例,可以随意覆盖现有命令。

覆盖type命令

如果你在密码字段中输入内容,密码输入在你的应用程序中会自动被屏蔽。但.type()会自动将任何输入的内容记录到Cypress命令日志中。

cy.get('#username').type('username@email.com')
cy.get('#password').type('superSecret123')
Cypress命令日志显示type命令中的密码

你可能希望屏蔽传递给.type()命令的一些值,以便敏感数据不会显示在测试运行的截图或视频中。这个示例覆盖了.type()命令,允许你在Cypress命令日志中屏蔽敏感数据。

Cypress.Commands.overwrite('type', (originalFn, element, text, options) => {
if (options && options.sensitive) {
// 关闭原始日志
options.log = false
// 创建我们自己的带有屏蔽消息的日志
Cypress.log({
$el: element,
name: 'type',
message: '*'.repeat(text.length),
})
}

return originalFn(element, text, options)
})
cy.get('#username').type('username@email.com')
cy.get('#password').type('superSecret123', { sensitive: true })

现在,当传递sensitive: true作为.type()的选项时,我们的敏感密码不会打印到Cypress命令日志中。

Cypress命令日志显示type命令中密码被屏蔽为星号

覆盖screenshot命令

这个示例覆盖了cy.screenshot(),以始终等待直到某个元素可见。

Cypress.Commands.overwrite(
'screenshot',
(originalFn, subject, fileName, options) => {
// 调用另一个命令,无需返回,因为它被管理
cy.get('.app')
.should('be.visible')

// 覆盖默认超时,因为screenshot内部会这样做
// 否则`then`受限于默认命令超时
.then({ timeout: Cypress.config('responseTimeout') }, () => {
// 返回原始函数以便Cypress等待它
return originalFn(subject, fileName, options)
})
}
)

覆盖click命令

这个示例覆盖了.click(),以始终将waitForAnimations选项设置为false

Cypress.Commands.overwrite(
'click',
(originalFn, subject, positionOrX, y, options = {}) => {
options.waitForAnimations = false
return originalFn(subject, positionOrX, y, options)
}
)

验证

参数中所述,你也可以将prevSubject设置为以下之一:

  • element
  • document
  • window

这样做时,Cypress会自动验证你的主题以确保它符合其中一种类型。

info

添加验证是可选的。传递{ prevSubject: true }将需要一个主题,但不会验证其类型。

要求元素

要求主题类型为:element

// 这是.click()的实现方式
Cypress.Commands.add(
'click',
{
prevSubject: 'element',
},
(subject, options) => {
// 接收先前主题,并且它
// 保证是一个元素
}
)

正确用法

cy.get('button').click() // 有主题,且是`element`

错误用法

cy.click() // 无主题,会出错
cy.wrap([]).click() // 有主题,但不是`element`,会出错

允许多种类型

.trigger()

要求主题是以下类型之一:element, documentwindow

// 这是.trigger()的实现方式
Cypress.Commands.add(
'trigger',
{
prevSubject: ['element', 'document', 'window'],
},
(subject, eventName, options) => {
// 接收先前主题,并且它
// 保证是一个元素、文档或窗口
}
)

正确用法

cy.get('button').trigger() // 有主题,且是`element`
cy.document().trigger() // 有主题,且是`document`
cy.window().trigger() // 有主题,且是`window`

错误用法

cy.trigger() // 无主题,会出错
cy.wrap(true).trigger() // 有主题,但不是`element`,会出错

验证始终作为“或”而不是“与”工作。

可选类型

你也可以将可选命令验证混合使用。

// 这是.scrollTo()的实现方式
Cypress.Commands.add(
'scrollTo',
{
prevSubject: ['optional', 'element', 'window'],
},
(subject, ...args) => {
// 主题可能是undefined
// 因为它是可选的。
//
// 如果存在,则它是一个元素或窗口。
// - 当是窗口时,我们将滚动到页面上的位置。
// - 当是元素时,我们将滚动到与元素相关的位置。
if (subject) {
// ...
} else {
// ...
}
}
)

正确用法

cy.scrollTo() // 无主题,但有效,因为它是可选的
cy.get('#main').scrollTo() // 有主题,且是`element`
cy.visit().scrollTo() // 有主题,因为visit产生`window`,所以没问题

错误用法

cy.document().scrollTo() // 有主题,但它是`document`,会出错
cy.wrap(null).scrollTo() // 有主题,但它是`null`,会出错

注意事项

命令日志

创建自定义命令时,你可以控制它在命令日志中的显示和行为。

利用Cypress.log() API。当你发出许多内部Cypress命令时,考虑传递{ log: false }给这些命令,并以编程方式控制你的自定义命令。这将清理命令日志,使其更加视觉上吸引人和易于理解。

cy.hover()cy.mount()

Cypress默认不包含cy.hover()cy.mount()命令。查看如何制作你自己的cy.hover()cy.mount()自定义命令。

最佳实践

1. 不要将所有内容都变成自定义命令

自定义命令在描述所有测试中所需的行为时效果很好。例如cy.setup()cy.login()或扩展你的应用程序行为如cy.get('.dropdown').dropdown('Apples')。这些特定于你的应用程序,可以在任何地方使用。

然而,这种模式可能被滥用。别忘了——编写Cypress测试就是JavaScript,通常更高效的是为仅单个spec文件特定的可重复行为编写一个函数。

如果你正在处理一个search.cy.js文件,并希望组合几个可重复的操作,你应该首先问自己:

这可以写成一个函数吗?

答案通常是可以。这里有一个例子:

// 没有理由创建一个像cy.search()这样的自定义
// 命令,因为这种行为仅适用于单个spec文件
//
// 使用普通的JavaScript函数吧!
const search = (term, options = {}) => {
// 示例默认值处理
_.defaults(options, {
headers: {},
})

const { fixture, headers } = options

// 在这里返回cy链,以便我们
// 可以在下面链式调用这个函数
return cy
.log(`搜索:${term} `)
.intercept('GET', '/search/**', (req) => {
req.reply({
statusCode: 200,
body: `fixture:${fixture}`,
headers: headers,
})
})
.as('getSearchResults')
.get('#search')
.type(term)
.wait('@getSearchResults')
}

it('显示搜索结果列表', () => {
cy.visit('/page')
.then(() => {
search('cypress.io', {
fixture: 'list',
}).then((reqRes) => {
// 可以对'@getSearchResults'做一些操作
// 比如对请求体或URL参数进行断言
// {
// url: 'http://app.com/search?cypress.io'
// method: 'GET',
// duration: 123,
// request: {...},
// response: {...},
// }
})
})
.get('#results li')
.should('have.length', 5)
.get('#pagination')
.should('not.exist')
})

it('显示无搜索结果', () => {
cy.visit('/page')
.then(() => {
search('cypress.io', {
fixture: 'zero',
})
})
.get('#results')
.should('contain', '未找到结果')
})

it('分页显示多个搜索结果', () => {
cy.visit('/page')
.then(() => {
search('cypress.io', {
fixture: 'list',
headers: {
// 欺骗我们的应用程序认为
// 有很多页
'x-pagination-total': 3,
},
})
})
.get('#pagination')
.should(($pagination) => {
// 应该提供下一页选项
expect($pagination).to.contain('下一页')

// 应该提供3个页面链接
expect($pagination.find('li.page')).to.have.length(3)
})
})

2. 不要过度复杂化

你编写的自定义命令通常是一系列内部命令的抽象。这意味着你和你的团队成员需要花费更多的脑力来理解你的自定义命令的作用。

当仅包装几个命令时,没有理由增加这种复杂性。

不要做以下事情:

  • cy.clickButton(selector)
  • .shouldBeVisible()

第一个自定义命令包装了cy.get(selector).click()。沿着这条路走下去会导致创建数十甚至数百个自定义命令来覆盖每个可能的元素交互组合。这完全没有必要。

.shouldBeVisible()自定义命令不值得麻烦或抽象,因为你已经可以使用:.should('be.visible')

在Cypress中测试主要是关于可读性简单性。你不需要做太多实际的编程就能完成很多工作。你也不需要担心保持代码尽可能DRY。测试代码服务于与应用程序代码不同的目的。可理解性和可调试性应优先于一切。

尽量不要过度复杂化并创建太多抽象。有疑问时,为单个spec文件使用普通函数。

3. 不要在单个命令中做太多事情

使你的自定义命令可组合且尽可能无意见。在其中塞入太多内容会使它们不灵活,并需要传递越来越多的选项来控制其行为。

尝试在你的自定义命令中添加零或尽可能少的断言。这些往往会使你的命令形成更刚性的结构。有时这是不可避免的,但最佳实践是让调用代码选择何时以及如何使用断言。

4. 尽可能跳过你的UI

自定义命令是抽象设置(特定于你的应用程序)的好方法。在执行这些任务时,尽可能跳过UI。使用cy.request()登录,直接设置cookie或localStorage,存根和模拟你的应用程序函数,和/或以编程方式触发事件。

让自定义命令一遍又一遍地重复相同的UI操作是缓慢且不必要的。尝试尽可能多地使用快捷方式。

5. 编写TypeScript定义

你可以描述自定义命令的方法签名,使IntelliSense显示有用的文档。查看cypress-example-todomvc仓库获取工作示例。

6. 创建一个添加自定义命令的函数

Cypress 12.17.4 包含一个Webpack升级(v4到v5),它会树摇出任何副作用或仅包含副作用的文件。

如果你使用TypeScript并在package.json中设置了Webpack的sideEffects:false,自定义Cypress命令将不会通过在一个文件中编写命令并通过导入文件作为副作用运行的常见模式注册。例如:

// cypress/support/commands.ts
Cypress.Commands.add("login", (email, password) => { ... })
// cypress/support/e2e.ts
import './commands'

为了支持sideEffects:false,你可以将Cypress命令包装在一个函数中,该函数将由支持文件导入。

// cypress/support/commands.ts
export function registerCommands(){
Cypress.Commands.add("login", (email, password) => { ... })
}
// cypress/support/e2e.ts
import { registerCommands } from './commands'

registerCommands()

历史

版本变更
0.20.0添加了Cypress.Commands API

另请参阅