条件测试
你将学到
- 为什么条件测试很困难
- 如何解决条件测试的问题
- 不依赖DOM实现条件测试的策略
- 如何从失败的Cypress命令中恢复
条件测试指的是常见的编程模式:
如果X成立,则执行Y,否则执行Z
许多用户询问如何在Cypress中实现这一点。以下是一些示例用例:
- 如何根据元素是否存在执行不同的操作?
- 我的应用进行A/B测试,如何适应这种情况?
- 新用户会看到"欢迎向导",但老用户不会。我能否总是关闭向导(如果显示)或忽略它(如果不显示)?
- 能否从失败的Cypress命令中恢复,比如cy.get()找不到元素时?
- 我尝试编写基于页面文本内容执行不同操作的动态测试。
- 我想自动查找所有
<a>
元素,并根据找到的链接检查每个链接是否有效。
问题是——虽然看起来简单,但以这种方式编写测试通常会导致测试不稳定、随机失败和难以追踪的边缘情况。
让我们探讨原因以及如何解决这些问题。
问题所在
Web应用通常是高度动态和可变的。它们的状态和DOM会在一段时间内持续变化。
条件测试的问题在于它只能在状态稳定时使用。而在Web应用中,知道状态何时稳定通常很困难。
对人类来说——如果某些内容在10毫秒或100毫秒后发生变化,我们可能根本不会注意到这种变化,并假设状态始终相同。但对机器来说,即使是10毫秒也代表着数十亿+的时钟周期。时间尺度差异巨大。
人类还有直觉。如果你点击一个按钮并看到加载旋转图标,你会假设状态正在变化,并自动等待它完成。机器没有直觉——它会严格按照编程执行。
为了说明这一点,让我们看一个尝试对不稳定状态进行条件测试的简单例子。
DOM是不稳定的
const random = Math.random() * 100 // 随机毫秒数
const btn = document.createElement('button')
document.body.appendChild(btn)
setTimeout(() => {
// 在随机时间后添加active类
btn.setAttribute('class', 'active')
}, random)
it('根据按钮的类执行不同操作', () => {
// 反复运行此测试
// 有时会为真,有时为假
cy.get('button').then(($btn) => {
if ($btn.hasClass('active')) {
// 如果active则执行某些操作
} else {
// 否则执行其他操作
}
})
})
你发现问题了吗?这个测试是非确定性的。<button>
有时会有active
类,有时不会。在大多数情况下,你不能依赖DOM的状态来决定条件执行什么。
这是不稳定测试的核心问题。在Cypress中,我们设计的API就是为了在每一步都防止这种不稳定 性。
适用场景
唯一能在DOM上进行条件测试的情况是,你100%确定状态已经"稳定"且不可能再变化。
就是这样!在其他任何情况下,如果你尝试依赖DOM状态进行条件测试,都会导致测试不稳定。
让我们看几个例子。
服务器端渲染
如果你的应用是服务器端渲染,且没有异步修改DOM的JavaScript——恭喜你,你可以在DOM上进行条件测试!
为什么?因为如果DOM在load
事件发生后不会改变,那么它可以准确代表稳定的真实状态。
你可以安全地跳到下面查看条件测试的示例。
客户端渲染
然而,在大多数应用中——当load
事件发生时,通常并不意味着屏幕上已经渲染了所有内容。通常此时你的脚本才开始加载动态内容并开始异步渲染。
不幸的是,在这种情况下你无法使用DOM进行条件测试。要做到这一点,你需要100%确定你的应用已完成所有异步渲染,并且没有待处理的网络请求、setTimeouts、intervals、postMessage或async/await代码。
如果不修改你的应用,这很难做到。换句话说,如果你希望测试100%一致地运行,你就无法安全地进行条件测试。
但别担心——仍有方法可以在不依 赖DOM的情况下实现条件测试。你需要_锚定_到另一个不可变的真实来源。
策略方法
如果你无法保证DOM的稳定性——还有其他方法可以进行条件测试或解决其固有的问题。
你可以:
- 消除条件测试的需求。
- 强制你的应用行为具有确定性。
- 检查其他真实来源(如服务器或数据库)。
- 将数据嵌入到其他位置(cookies/本地存储)进行评估。
- 向DOM添加数据以便评估如何继续。
让我们看一些100%通过或失败的条件测试示例。
A/B测试 仅限端到端测试
在这个例子中,假设你访问网站时,内容会根据服务器决定的A/B测试方案而不同。可能是基于地理位置、IP地址、时间、语言环境或其他难以控制的因素。如何在这种情况下编写测试?
控制发送的测试方案,或提供可靠的方法提前知道是哪个方案。
使用URL查询参数:
// 告诉后端服务器你想要哪个测试方案
// 这样你就可以提前确定性地知道
cy.visit('https://example.cypress.io?campaign=A')
// 测试...
cy.visit('https://example.cypress.io?campaign=B')
// 测试...
cy.visit('https://example.cypress.io?campaign=C')
// 测试...
现在甚至不需要条件测试,因为你能够提前知道发送了哪个方案。是的,这可能需要服务器端更新,但如果你想测试它,就必须让不可测试的应用变得可测试!
使用服务器:
或者,如果你的服务器用会话保存了测试方案,你可以让服务器告诉你当前是哪个方案。
// 这会发送会话cookies
cy.visit('https://example.cypress.io')
// 假设这会返回测试方案信息
cy.request('https://example.cypress.io/me')
.its('body.campaign')
.then((campaign) => {
// 根据测试方案类型运行不同的Cypress测试代码
return campaigns.test(campaign)
})
使用会话cookies:
另一种测试方法是,如果你的服务器在会话cookie中发送了测试方案,你可以读取它。
cy.visit('https://example.cypress.io')
cy.getCookie('campaign').then((campaign) => {
return campaigns.test(campaign)
})
在DOM中嵌入数据:
另一个有效策略是将数据直接嵌入DOM——但要以始终存在且可查询的方式进行。它必须100%存在,否则这种方法无效。
cy.get('html')
.should('have.attr', 'data-campaign')
.then((campaign) => {
return campaigns.test(campaign)
})
欢迎向导 仅限端到端测试
在这个例子中,假设你运行一系列测试时,每次加载应用都可能显示"欢迎向导"模态框。
在这种情况下,你想在向导出现时关闭它,不出现时忽略它。
问题在于,如果向导是异步渲染的(很可能如此),你就不能使用DOM来有条件地关闭它。
再次强调——我们需要另一种可靠的方法来实现这一点,而不涉及DOM。
这些模式与之前基本相同:
使用URL控制:
// 不显示向导
cy.visit('https://example.cypress.io?wizard=0')
// 显示向导
cy.visit('https://example.cypress.io?wizard=1')
我 们可能需要更新客户端代码来检查是否存在此查询参数。现在我们可以提前知道它是否会显示。
使用Cookies提前知道:
在无法控制的情况下,如果你知道它是否会显示,仍然可以有条件地关闭它。
cy.visit('https://example.cypress.io')
cy.getCookie('showWizard').then((val) => {
if (val) {
// 通过排队这三个额外命令有条件地关闭向导
cy.get('#wizard').contains('Close').click()
}
})
使用服务器或数据库:
如果你在服务器上存储或持久化是否显示向导,那么就询问它。
cy.visit('https://example.cypress.io')
cy.request('https://example.cypress.io/me')
.its('body.showWizard')
.then((val) => {
if (val) {
// 通过排队这三个额外命令有条件地关闭向导
cy.get('#wizard').contains('Close').click()
}
})
或者,如果你正在创建用户,提前创建用户并设置是否显示向导可能更省时。这样可以避免后续的检查。
在DOM中嵌入数据:
另一个有效策略是将数据直接嵌入DOM,但要以始终存在且可查询的方式进行。数据必须100%存在,否则此策略无效。
cy.get('html')
.should('have.attr', 'data-wizard')
.then((wizard) => {
if (wizard) {
// 通过排队这三个额外命令有条件地关闭向导
cy.get('#wizard').contains('Close').click()
}
})
元素存在性
在确实需要基于DOM进行条件测试的情况下,你可以利用Cypress中同步查询元素的能力来创建控制流。
如果你没有阅读上面的内容直接跳到这里,我们再次强调:
除非满足以下条件,否则不能在DOM上进行条件测试:
- 服务器端渲染且没有异步JavaScript。
- 使用客户端JavaScript仅进行同步渲染。
理解你的应用如何工作至关重要,否则你会编写不稳定的测试。
假设我们有一个场景,应用可能做两件不同的事情,而我们无法控制。换句话说,你尝试了上述所有策略,但由于某些原因无法提前知道应用会做什么。
在Cypress中测试这是可能的。
$('button').on('click', (e) => {
// 随机同步执行某些操作
if (Math.random() < 0.5) {
// 添加一个input
$('<input />').appendTo($('body'))
} else {
// 或者添加一个textarea
$('<textarea />').appendTo($('body'))
}
})
// 点击按钮导致新元素出现
cy.get('button').click()
cy.get('body')
.then(($body) => {
// 从body同步查询
// 找出创建了哪个元素
if ($body.find('input').length) {
// 找到input,执行其他操作
return 'input'
}
// 否则假设是textarea
return 'textarea'
})
.then((selector) => {
// selector是一个字符串,代表
// 我们可以用来找到它的选择器
cy.get(selector).type(`found the element by selector ${selector}`)
})
我们再次强调。如果<input>
或<textarea>
是异步渲染的,你就不能使用上述模式。你必须引入任意延迟,这不仅在所有情况下都无效,还会减慢测试速度,并且仍然可能导致测试不稳定。
Cypress旨在创建可靠的测试。编写好测试的秘诀是向Cypress提供尽可能多的"状态"和"事实",并"保护它"在应用达到继续所需的期望状态之前不发出新命令。
条件测试增加了一个大问题——测试编写者自己也不确定给定状态会是什么。在这些情况下,获得准确测试的唯一可靠方法是以可靠和一致的方式嵌入这种动态状态。
如果你不确定是否编写了可能不稳定的测试,有一种方法可以验证。重复测试大量次数,然后通过修改开发者工具来限制网络和CPU重复测试。这将创建不同的负载,模拟不同的环境(如CI)。如果你编写了一个好的测试,它会100%通过或失败。
Cypress._.times(100, (i) => {
it(`第 ${i + 1} 次 - 有条件地测试`, () => {
// 有条件地执行100次
})
})
动态文本
基于是否存在特定文本执行不同操作的模式与上述元素存在性相同。
有条件地检查元素是否有特定文本:
// 这仅在100%保证
// body已经完全渲染且没有任何待处理的状态变化时有效
cy.get('body').then(($body) => {
// 同步获取body的文本
// 并根据是否包含另一个字符串执行操作
if ($body.text().includes('某个字符串')) {
// 找到了
cy.get(...).should(...)
} else {
// 没找到
cy.get(...).should(...)
}
})
错误恢复
许多用户询问如何从失败的命令中恢复。
如果有错误处理,我可以尝试找到X,如果X失败就去找Y
因为错误处理是大多数编程语言中的常见习惯用法,特别是在Node中,似乎有理由期望在Cypress中也能这样做。
然而,这实际上是同一个问题,只是包装在稍微不同的实现细节中。
例如,你可能想这样做:
以下代码无效。
//! 你不能为Cypress命令添加错误处理
//! 此代码仅用于演示目的
cy.get('button')
.contains('hello')
.catch((err) => {
// 哦,按钮没找到
// (或其他错误)
cy.get('somethingElse').click()
})
如果你一直在阅读,那么你应该已经理解为什么尝试用异步渲染实现条件代码不是一个好主意。如果测试编写者无法准确预测系统的给定状态,那么Cypress也不能。错误处理并不能证明这可以确定性地完成。
你应该将Cypress中的失败命令视为类似于服务器端代码中的未捕获异常。在这些情况下无法尝试恢复,因为系统已转变为不可靠的状态。相反,你通常会选择崩溃和记录。当Cypress测试失败时,正是这样做的。中止,跳过测试中的任何剩余命令,并记录失败。
但是...为了讨论,让我们暂时想象一下你在Cypress中确实有错误处理。
启用这意味着对于每个命令,它都会从错误中恢复,但只有在每个适用的命令超时后才 会失败。由于超时从4秒开始(并超过),这意味着它只会在很长时间后失败。
让我们重新想象之前的"欢迎向导"例子。
以下代码无效。
//! 你不能为Cypress命令添加错误处理
//! 此代码仅用于演示目的
function keepCalmAndCarryOn () {
cy.get(...).should(...).click()
}
cy
.get('#wizard').contains('Close').click()
.catch((err) => {
// 没问题,我猜向导不存在
// 或其他原因...不用担心
keepCalmAndCarryOn()
})
.then(keepCalmAndCarryOn)
在最佳情况下,我们至少浪费了4秒等待<#wizard>
元素可能存在后才出错并继续。
但在最坏情况下,我们遇到<#wizard>
将要渲染,但没有在给定的超时内渲染。假设这是由于待处理的网络请求、WebSocket消息、排队的计时器或其他原因。
在这种情况下,我们不仅等待了很长时间,而且当<#wizard>
元素最终显示时,很可能导致下游其他命令出错。
如果你无法准确知道应用的状态,那么无论你有什么编程习惯可用——你都无法编写100%确定性的测试。