不稳定的测试:摆脱测试中的噩梦

已发表: 2022-03-10
快速总结↬不可靠的测试对于任何编写自动化测试或关注结果的人来说都是一场噩梦。 不稳定的测试甚至给人们带来了噩梦和不眠之夜。 在本文中,Ramona Schwering 分享了她的经验,以帮助您摆脱或避免陷入困境。

有一个寓言,这些天我想了很多。 这个寓言是小时候告诉我的。 它被伊索称为“狼来了的男孩”。 这是关于一个男孩照顾他村里的羊的故事。 他感到无聊,假装一只狼正在攻击羊群,向村民们求救——结果他们失望地意识到这是一场虚惊,让男孩一个人呆着。 然后,当狼真的出现,男孩呼救时,村民们认为这是又一次虚惊,没有前来救援,结果羊被狼吃掉了。

作者自己对这个故事的寓意进行了最好的总结:

“即使他说的是真话,也不会相信一个骗子。”

一只狼袭击了羊,男孩哭着求救,但在无数次谎言之后,没有人相信他了。 这种道德可以应用于测试:Aesop 的故事是我偶然发现的匹配模式的一个很好的寓言:无法提供任何价值的片状测试。

前端测试:为什么还要麻烦?

我大部分时间都花在前端测试上。 因此,本文中的代码示例将主要来自我在工作中遇到的前端测试,这不足为奇。 但是,在大多数情况下,它们可以很容易地翻译成其他语言并应用于其他框架。 所以,我希望这篇文章对你有用——不管你有什么专业知识。

值得回顾一下前端测试的含义。 本质上,前端测试是一组用于测试 Web 应用程序 UI 的实践,包括其功能。

作为一名质量保证工程师,我知道在发布前从清单中进行无休止的手动测试的痛苦。 因此,除了确保应用程序在连续更新期间保持无错误的目标之外,我还努力减轻由您实际上不需要人工执行的常规任务引起的测试工作量。 现在,作为一名开发人员,我发现这个话题仍然很重要,尤其是当我尝试直接帮助用户和同事时。 特别是测试有一个问题让我们做噩梦。

片状测试的科学

不稳定的测试是每次运行相同的分析时都无法产生相同结果的测试。 构建只会偶尔失败:一次通过,另一次失败,下一次再次通过,没有对构建进行任何更改。

当我回想起我的测试噩梦时,我特别想到了一个案例。 它在 UI 测试中。 我们构建了一个自定义样式的组合框(即带有输入字段的可选列表):

自定义选择器示例
我每天工作的项目中的自定义选择器。 (大预览)

使用此组合框,您可以搜索产品并选择一个或多个结果。 很多天,这个测试都很顺利,但在某些时候,情况发生了变化。 在我们的持续集成 (CI) 系统中大约十个构建中的一个中,在此组合框中搜索和选择产品的测试失败了。

失败的屏幕截图显示了未过滤的结果列表,尽管搜索已成功:

带有不稳定测试的 CI 执行的屏幕截图
片状测试在行动:为什么它只是有时而不是总是失败? (大预览)

像这样的不稳定测试可能会阻塞持续部署管道,使功能交付比需要的慢。 此外,一个不稳定的测试是有问题的,因为它不再是确定性的——使它毫无用处。 毕竟,你不会信任一个骗子,就像你不会信任一个骗子一样。

此外,易碎测试的修复成本很高,通常需要数小时甚至数天的时间来调试。 尽管端到端测试更容易出现问题,但我在各种测试中都经历过它们:单元测试、功能测试、端到端测试以及介于两者之间的所有测试。

易碎测试的另一个重要问题是它们灌输给我们开发人员的态度。 当我开始从事测试自动化工作时,我经常听到开发人员对失败的测试这样说:

“啊,那个建筑。 没关系,重新开始吧。 它最终会在某个时候过去。”

对我来说是一个巨大的危险信号。 它告诉我构建中的错误不会被认真对待。 假设一个不稳定的测试不是真正的错误,而是“只是”不稳定的,不需要照顾甚至调试。 反正以后考试会再通过的,对吧? 没有! 如果合并了这样的提交,在最坏的情况下,我们将在产品中进行新的不稳定测试。

跳跃后更多! 继续往下看↓

原因

因此,片状测试是有问题的。 我们应该怎么处理它们? 好吧,如果我们知道问题所在,我们可以设计一个应对策略。

我在日常生活中经常遇到原因。 它们可以在测试本身中找到。 测试可能写得不够理想、假设错误或包含不良做法。 然而,不仅如此。 片状测试可能表明情况更糟。

在以下部分中,我们将介绍我遇到的最常见的部分。

1. 测试端原因

在理想情况下,应用程序的初始状态应该是原始的并且 100% 可预测。 实际上,您永远不知道您在测试中使用的 ID 是否始终相同。

让我们来看看我个人失败的两个例子。 第一个错误是在我的测试装置中使用了一个 ID

 { "id": "f1d2554b0ce847cd82f3ac9bd1c0dfca", "name": "Variant product", }

第二个错误是在 UI 测试中寻找一个唯一的选择器,然后想,“好吧,这个 ID 似乎是唯一的。 我会用的。”

 <!-- This is a text field I took from a project I worked on --> <input type="text" />

但是,如果我在另一个安装上运行测试,或者稍后在 CI 中的多个构建上运行测试,那么这些测试可能会失败。 我们的应用程序将重新生成 ID,并在构建之间更改它们。 因此,第一个可能的原因是在硬编码的 ID中找到。

第二个原因可能来自随机(或以其他方式)生成的演示数据。 当然,你可能会认为这个“缺陷”是有道理的——毕竟,数据生成是随机的——但考虑调试这些数据。 很难看出错误是在测试本身还是在演示数据中。

接下来是我无数次挣扎的测试方面的原因:具有交叉依赖关系的测试。 有些测试可能无法独立运行或以随机顺序运行,这是有问题的。 此外,之前的测试可能会干扰后续测试。 这些场景可能会通过引入副作用而导致不稳定的测试。

但是,不要忘记测试是关于具有挑战性的假设。 如果您的假设一开始就有缺陷,会发生什么? 我经常经历这些,我最喜欢的是关于时间的有缺陷的假设。

一个例子是使用不准确的等待时间,尤其是在 UI 测试中——例如,通过使用固定等待时间。 以下行来自 Nightwatch.js 测试。

 // Please never do that unless you have a very good reason! // Waits for 1 second browser.pause(1000);

另一个错误的假设与时间本身有关。 我曾经发现一个不稳定的 PHPUnit 测试只在我们的夜间构建中失败。 经过一番调试,我发现昨天和今天之间的时间偏移是罪魁祸首。 另一个很好的例子是时区导致的失败。

错误的假设不止于此。 我们也可能对数据的顺序有错误的假设。 想象一个包含多个信息条目的网格或列表,例如货币列表:

我们项目中使用的自定义列表组件
我们项目中使用的自定义列表组件。 (大预览)

我们希望使用第一个条目的信息,即“捷克克朗”货币。 您能否确定每次执行测试时您的应用程序总是将这条数据作为第一个条目? 会不会是“欧元”或其他货币在某些情况下会成为第一个入口?

不要假设您的数据会按照您需要的顺序出现。 与硬编码的 ID 类似,构建之间的顺序可以更改,具体取决于应用程序的设计。

2. 环境方面的原因

下一类原因与测试之外的一切有关。 具体来说,我们谈论的是执行测试的环境,测试之外的 CI 和 docker 相关的依赖项——所有这些你几乎无法影响的事情,至少在你作为测试人员的角色中是这样。

一个常见的环境方面的原因是资源泄漏:通常这将是负载下的应用程序,导致不同的加载时间或意外行为。 大型测试很容易导致泄漏,占用大量内存。 另一个常见问题是缺乏清理

依赖项之间的不兼容尤其让我做噩梦。 当我使用 Nightwatch.js 进行 UI 测试时,发生了一场噩梦。 Nightwatch.js 使用 WebDriver,这当然依赖于 Chrome。 当 Chrome 进行更新时,兼容性出现了问题:Chrome、WebDriver 和 Nightwatch.js 本身不再协同工作,这导致我们的构建时常失败。

说到依赖关系值得一提的是任何 npm 问题,例如缺少权限或 npm 关闭。 我在观察 CI 时经历了所有这些。

当涉及到由于环境问题导致的 UI 测试错误时,请记住,您需要整个应用程序堆栈才能运行它们。 涉及的东西越多,出错的可能性就越大。 因此,JavaScript 测试是 Web 开发中最难稳定的测试,因为它们涵盖了大量代码。

3. 产品方面的原因

最后但并非最不重要的一点是,我们真的必须小心第三个区域——一个存在实际错误的区域。 我说的是产品方面的片状原因。 最著名的例子之一是应用程序中的竞争条件。 发生这种情况时,需要在产品中修复错误,而不是在测试中! 在这种情况下,尝试修复测试或环境将毫无用处。

对抗片状的方法

我们已经确定了三个导致片状的原因。 我们可以在此基础上制定我们的反制策略! 当然,当您遇到不稳定的测试时,牢记这三个原因,您已经获得了很多。 您已经知道要寻找什么以及如何改进测试。 但是,除此之外,还有一些策略可以帮助我们设计、编写和调试测试,我们将在下面的部分中一起研究它们。

专注于你的团队

你的团队可以说是最重要的因素。 作为第一步,承认你有片状测试的问题。 获得整个团队的承诺至关重要! 然后,作为一个团队,您需要决定如何处理不稳定的测试。

在我从事技术工作的这些年里,我遇到了团队用来应对脆弱性的四种策略:

  1. 什么都不做,接受不稳定的测试结果。
    当然,这种策略根本不是解决方案。 测试不会产生任何价值,因为你不再信任它——即使你接受它的脆弱性。 所以我们可以很快跳过这个。
  2. 重试测试,直到通过。
    这种策略在我职业生涯初期很常见,导致了我之前提到的反应。 重试测试直到他们通过之前有一些接受。 这种策略不需要调试,但它很懒惰。 除了隐藏问题的症状外,它还会进一步降低您的测试套件的速度,从而使解决方案不可行。 但是,这条规则可能有一些例外,我稍后会解释。
  3. 删除并忘记测试。
    这是不言自明的:只需删除不稳定的测试,这样它就不会再干扰您的测试套件了。 当然,它会为您省钱,因为您不再需要调试和修复测试。 但这是以失去一些测试覆盖率和失去潜在的错误修复为代价的。 测试的存在是有原因的! 不要通过删除测试来射击信使。
  4. 隔离和修复。
    我在这个策略上取得了最大的成功。 在这种情况下,我们会暂时跳过测试,并让测试套件不断提醒我们已经跳过了一个测试。 为了确保修复不会被忽视,我们会为下一个 sprint 安排一张票。 机器人提醒也很有效。 一旦导致片状的问题得到解决,我们将再次集成(即取消跳过)测试。 不幸的是,我们将暂时失去覆盖范围,但它会回来修复,所以这不会花很长时间。
跳过的测试,取自我们 CI 的报告
跳过的测试,取自我们 CI 的报告。 (大预览)

这些策略帮助我们处理工作流级别的测试问题,而且我不是唯一遇到这些问题的人。 Sam Saffron 在他的文章中得出了类似的结论。 但在我们的日常工作中,它们对我们的帮助有限。 那么,当这样的任务出现时,我们该如何进行呢?

保持测试隔离

在规划您的测试用例和结构时,请始终将您的测试与其他测试隔离,以便它们能够以独立或随机的顺序运行。 最重要的一步是在测试之间恢复干净的安装。 此外,仅测试您要测试的工作流,并仅为测试本身创建模拟数据。 这个快捷方式的另一个优点是它可以提高测试性能。 如果您遵循这些要点,其他测试或剩余数据的副作用将不会妨碍您。

下面的例子取自一个电商平台的UI测试,处理的是客户在店铺店面的登录。 (测试是用 JavaScript 编写的,使用 Cypress 框架。)

 // File: customer-login.spec.js let customer = {}; beforeEach(() => { // Set application to clean state cy.setInitialState() .then(() => { // Create test data for the test specifically return cy.setFixture('customer'); }) }):

第一步是将应用程序重置为全新安装。 这是beforeEach生命周期钩子中的第一步,以确保每次都执行重置。 之后,专门为测试创建测试数据——对于这个测试用例,将通过自定义命令创建一个客户。 随后,我们可以从我们要测试的一个工作流程开始:客户的登录。

进一步优化测试结构

我们可以做一些其他的小调整,使我们的测试结构更加稳定。 第一个很简单:从较小的测试开始。 如前所述,你在测试中做的越多,出错的可能性就越大。 保持测试尽可能简单,并避免在每个测试中使用大量逻辑。

当涉及到不假设数据的顺序时(例如,在 UI 测试中处理列表中条目的顺序时),我们可以设计一个独立于任何顺序的测试。 为了带回包含信息的网格示例,我们不会使用伪选择器或其他对顺序有很强依赖性的 CSS。 代替nth-child(3)选择器,我们可以使用文本或其他顺序无关紧要的东西。 例如,我们可以使用一个断言,如“在此表中查找包含此文本字符串的元素”。

等待! 测试重试有时可以吗?

重试测试是一个有争议的话题,这是理所当然的。 如果盲目地重试测试直到成功,我仍然认为它是一种反模式。 但是,有一个重要的例外:当您无法控制错误时,重试可能是最后的手段(例如,从外部依赖项中排除错误)。 在这种情况下,我们无法影响错误的来源。 但是,在执行此操作时要格外小心:重试测试时不要对脆弱视而不见,并在跳过测试时使用通知来提醒您。

以下示例是我在我们的 CI 中与 GitLab 一起使用的示例。 其他环境可能有不同的语法来实现重试,但这应该让您体验一下:

 test: script: rspec retry: max: 2 when: runner_system_failure

在这个例子中,我们正在配置如果作业失败应该重试多少次。 有趣的是,如果 runner 系统出现错误(例如,作业设置失败),重试的可能性。 仅当 docker 设置中的某些内容失败时,我们才选择重试我们的工作。

请注意,这将在触发时重试整个作业。 如果您只想重试错误的测试,那么您需要在测试框架中寻找一个功能来支持这一点。 下面是 Cypress 的一个示例,它从版本 5 开始支持重试单个测试:

 { "retries": { // Configure retry attempts for 'cypress run` "runMode": 2, // Configure retry attempts for 'cypress open` "openMode": 2, } }

您可以在赛普拉斯的配置文件cypress.json中激活测试重试。 在那里,您可以在测试运行器和无头模式下定义重试尝试。

使用动态等待时间

这一点对于各种测试都很重要,尤其是 UI 测试。 我怎么强调都不为过:永远不要使用固定的等待时间——至少不要没有很好的理由。 如果您这样做,请考虑可能的结果。 在最好的情况下,您会选择太长的等待时间,从而使测试套件比需要的慢。 在最坏的情况下,您不会等待足够长的时间,因此测试将不会继续,因为应用程序还没有准备好,导致测试以不稳定的方式失败。 根据我的经验,这是导致片状测试的最常见原因。

相反,使用动态等待时间。 有很多方法可以做到这一点,但赛普拉斯处理得特别好。

所有赛普拉斯命令都有一个隐式等待方法:它们已经检查了应用命令的元素是否在指定时间内存在于 DOM 中——指向赛普拉斯的重试能力。 但是,它只检查存在,仅此而已。 所以我建议更进一步——等待真实用户也会看到的网站或应用程序 UI 中的任何更改,例如 UI 本身或动画中的更改。

一个固定的等待时间,在赛普拉斯的测试日志中找到
在赛普拉斯的测试日志中找到一个固定的等待时间。 (大预览)

此示例使用选择器.offcanvas对元素使用显式等待时间。 仅当元素在指定的超时之前可见时测试才会继续,您可以配置:

 // Wait for changes in UI (until element is visible) cy.get(#element).should('be.visible');

赛普拉斯动态等待的另一个巧妙可能性是它的网络功能。 是的,我们可以等待请求发生并等待其响应的结果。 我特别经常使用这种等待。 在下面的示例中,我们定义了要等待的请求,使用wait命令等待响应,并断言其状态码:

 // File: checkout-info.spec.js // Define request to wait for cy.intercept({ url: '/widgets/customer/info', method: 'GET' }).as('checkoutAvailable'); // Imagine other test steps here... // Assert the response's status code of the request cy.wait('@checkoutAvailable').its('response.statusCode') .should('equal', 200);

通过这种方式,我们可以完全按照应用程序的需要等待,从而使测试更加稳定,并且由于资源泄漏或其他环境问题而不太容易出现片状问题。

调试易碎测试

我们现在知道如何通过设计来防止片状测试。 但是如果你已经在处理一个不稳定的测试怎么办? 你怎么能摆脱它?

当我调试时,将有缺陷的测试放在一个循环中帮助我发现了片状问题。 例如,如果您运行一个测试 50 次,并且每次都通过,那么您可以更加确定测试是稳定的——也许您的修复工作有效。 如果没有,您至少可以更深入地了解片状测试。

 // Use in build Lodash to repeat the test 100 times Cypress._.times(100, (k) => { it(`typing hello ${k + 1} / 100`, () => { // Write your test steps in here }) })

在 CI 中更深入地了解这种不稳定的测试尤其困难。 要获得帮助,请查看您的测试框架是否能够获取有关您的构建的更多信息。 当涉及到前端测试时,通常可以在测试中使用console.log

 it('should be a Vue.JS component', () => { // Mock component by a method defined before const wrapper = createWrapper(); // Print out the component's html console.log(wrapper.html()); expect(wrapper.isVueInstance()).toBe(true); })

此示例取自 Jest 单元测试,其中我使用console.log获取被测试组件的 HTML 输出。 如果您在赛普拉斯的测试运行程序中使用这种日志记录功能,您甚至可以在您选择的开发人员工具中检查输出。 此外,在 CI 中使用 Cypress 时,您可以使用插件在 CI 日志中检查此输出。

始终查看测试框架的功能以获得日志记录支持。 在 UI 测试中,大多数框架都提供了截图功能——至少在失败时,会自动截图。 一些框架甚至提供视频录制,这对于深入了解测试中发生的事情非常有帮助。

对抗脆弱的噩梦!

重要的是不断寻找不稳定的测试,无论是从一开始就阻止它们,还是在它们发生时立即调试和修复它们。 我们需要认真对待它们,因为它们可以暗示您的应用程序中的问题。

发现红旗

当然,首先防止片状测试是最好的。 快速回顾一下,这里有一些危险信号:

  • 测试很大,包含很多逻辑。
  • 该测试涵盖了很多代码(例如,在 UI 测试中)。
  • 该测试使用固定的等待时间。
  • 该测试取决于之前的测试。
  • 测试断言不是 100% 可预测的数据,例如 ID、时间或演示数据的使用,尤其是随机生成的数据。

如果您牢记本文中的指示和策略,您可以在不稳定的测试发生之前阻止它们。 如果他们真的来了,你就会知道如何调试和修复它们。

这些步骤确实帮助我重拾对我们的测试套件的信心。 我们的测试套件目前似乎很稳定。 未来可能会出现问题——没有什么是 100% 完美的。 这些知识和这些策略将帮助我处理它们。 因此,我会越来越有信心与那些片状的测试噩梦作斗争

我希望我能够至少减轻一些你对片状的痛苦和担忧!

延伸阅读

如果您想了解有关此主题的更多信息,这里有一些简洁的资源和文章,它们对我有很大帮助:

  • 关于“薄片”的文章,Cypress.io
  • “重试测试实际上是一件好事(如果你的方法是正确的),”Filip Hric,Cypress.io
  • “测试片状:识别和处理片状测试的方法,”Jason Palmer,Spotify 研发工程部
  • “Google 的不稳定测试以及我们如何缓解它们,”John Micco,Google 测试博客