使用 Cypress 进行视觉回归测试:一种务实的方法

已发表: 2022-03-11

每次发布我们的组件库 Picasso 的新版本时,我们都会更新我们所有的前端应用程序,以充分利用新功能并在我们网站的所有部分调整我们的设计。

上个月,我们推出了 Toptal 人才门户网站的毕加索更新,这是我们的人才用来寻找工作和与客户互动的平台。 知道发布会带来重大的设计更改,并且为了尽量减少意外问题,使用视觉回归测试技术帮助我们在发布之前发现问题是有意义的。

视觉回归测试并不是一个新概念; Toptal 的许多其他项目已经在使用它,包括毕加索本身。

Percy、Happo 和 Chromatic 等工具可用于帮助团队构建健康的视觉回归管道,我们最初确实考虑过添加它们。 我们最终决定设置过程太耗时,并且可能会破坏我们的日程安排。 我们已经设定了代码冻结开始迁移的日期,距离截止日期仅剩几天,我们别无选择,只能发挥创造力。

通过 UI 测试进行视觉回归测试

虽然我们在项目中没有进行视觉回归测试,但我们确实对使用 Cypress 的 UI 集成测试进行了很好的覆盖。 尽管这不是该工具的主要用途,但赛普拉斯在其文档中有一页专门用于可视化测试,另一页列出了所有可用的插件,以帮助配置赛普拉斯进行可视化测试。

从赛普拉斯到截图

在浏览了可用的文档之后,我们决定尝试一下 cypress-snapshot-plugin。 设置只需要几分钟,一旦完成,我们很快意识到我们并不是在追求传统的视觉回归输出。

大多数视觉回归工具通过比较快照和检测已知、可接受的基线与页面或组件的修改版本之间的像素差异来帮助识别不需要的更改。 如果像素差异大于设置的容差阈值,则将页面或组件标记为手动检查。 不过,在这个版本中,我们知道我们将对大多数 UI 组件进行一些小的更改,因此设置阈值并不适用。 即使给定的组件碰巧有 100% 的不同,它在新版本的上下文中可能仍然是正确的。 同样,小到几个像素的偏差可能意味着组件当前不适合生产。

描述测试运行的预期结果和实际结果的屏幕截图。
图 1. 导致假阴性的微小像素差异示例

在那一点上,两个对比的事情变得清晰起来:注意像素差异并不能帮助识别问题,并且对组件进行并排比较正是我们所需要的。 我们将快照插件放在一边,并着手在应用毕加索更新之前和之后使用我们的组件创建一组图像。 这样,我们可以快速浏览所有更改,以确定新版本是否仍然符合站点的需求和图书馆的标准。

新计划是对一个组件进行截图,将其存储在本地,使用更新的 Picasso 版本对分支中的同一组件进行新的截图,然后将它们合并为一个图像。 最终,这种新方法与我们一开始的方法并没有太大区别,但它在实施阶段为我们提供了更大的灵活性,因为我们不再需要导入插件并使用它的新命令。

显示视觉比较流程的图表,在视觉测试运行后如何合并新旧版本的图像。
图 2. 视觉比较流程

利用 API 进行比较图像

有了明确的目标,是时候看看赛普拉斯如何帮助我们获得所需的屏幕截图了。 如前所述,我们进行了大量的 UI 测试,涵盖了人才门户的大部分内容,因此为了收集尽可能多的关键组件,我们决定在每次交互后对各个元素进行截图。

另一种方法是在测试期间的关键时刻截取整个页面的屏幕截图,但我们认为这些图像太难以比较。 此外,此类比较可能更容易出现人为错误,例如错过页脚已更改的信息。

第三种选择是通过每一个测试用例来决定要捕获什么,但这会花费更多时间,因此坚持页面上使用的所有元素似乎是一种实际的折衷方案。

我们求助于赛普拉斯的 API 来生成图像。 cy.screenshot()命令可以开箱即用地创建组件的单个图像,After Screenshot API 允许我们重命名文件、更改目录以及区分视觉回归运行与标准运行。 通过将两者结合起来,我们创建的运行不会影响我们的功能测试,并使我们能够将图像存储在相应的文件夹中。

首先,我们扩展了插件目录中的index.js文件以支持两种新的运行类型(基线和比较)。 然后,我们根据运行类型设置图像的路径:

 // plugins/index.js const fs = require('fs') const path = require('path') module.exports = (on, config) => { // Adding these values to your config object allows you to access them in your tests. config.env.baseline = process.env.BASELINE || false config.env.comparison = process.env.COMPARISON || false on('after:screenshot', details => { // We only want to modify the behavior of baseline and comparison runs. if (config.env.baseline || config.env.comparison) { // We keep track of the file name and number to make sure they are saved in the proper order and in their relevant folders. // An alternative would have been to look up the folder for the latest image, but this was the simpler approach. let lastScreenshotFile = '' let lastScreenshotNumber = 0 // We append the proper suffix number to the image, create the folder, and move the file. const createDirAndRename = filePath => { if (lastScreenshotFile === filePath) { lastScreenshotNumber++ } else { lastScreenshotNumber = 0 } lastScreenshotFile = filePath const newPath = filePath.replace( '.png', ` #${lastScreenshotNumber}.png` ) return new Promise((resolve, reject) => { fs.mkdir(path.dirname(newPath), { recursive: true }, mkdirErr => { if (mkdirErr) { return reject(mkdirErr) } fs.rename(details.path, newPath, renameErr => { if (renameErr) { return reject(renameErr) } resolve({ path: newPath }) }) }) }) } const screenshotPath = `visualComparison/${config.env.baseline ? 'baseline' : 'comparison'}` return createDirAndRename(details.path .replace('cypress/integration', screenshotPath) .replace('All Specs', screenshotPath) ) } }) return config }

然后我们通过将相应的环境变量添加到项目的package.json中的 Cypress 调用来调用每个运行:

 "scripts": { "cypress:baseline": "BASELINE=true yarn cypress:open", "cypress:comparison": "COMPARISON=true yarn cypress:open" }

运行新命令后,我们可以看到运行期间拍摄的所有屏幕截图都已移动到相应的文件夹中。

显示在运行期间拍摄并移动到文件夹的图像的屏幕截图。
图 3. 可视化运行结果

接下来,我们尝试覆盖 Cypress 用于返回 DOM 元素的主要命令cy.get() ,并对调用的任何元素及其默认实现进行截图。 不幸的是, cy.get()是一个很难更改的命令,因为在其自己的定义中调用原始命令会导致无限循环。 解决此限制的建议方法是创建一个单独的自定义命令,然后让该新命令在找到元素后截取屏幕截图:

 Cypress.Commands.add("getAndScreenshot", (selector, options) => { // Note: You might need to tweak the command when getting multiple elements. return cy.get(selector).screenshot() }); it("get overwrite", () => { cy.visit("https://example.cypress.io/commands/actions"); cy.getAndScreenshot(".action-email") })

但是,我们与页面上的元素交互的调用已经包含在内部getElement()函数中。 所以我们所要做的就是确保在调用包装器时截取屏幕截图。

通过视觉回归测试获得的结果

一旦我们有了屏幕截图,剩下要做的就是合并它们。 为此,我们使用 Canvas 创建了一个简单的节点脚本。 最终,脚本让我们生成了 618 张对比图! 打开人才门户很容易发现其中一些差异,但有些问题并不那么明显。

不正确使用毕加索的前后示例,在元素中显示红色和黑色。
图 4. 不遵循毕加索新指南的示例; 预计会有所不同,但新版本应该有红色背景和白色文字

之前和之后的组件布局略有损坏的示例,在“之后”图像中的复选框旁边显示未对齐的文本。
图 5. 轻微损坏的组件布局示例

为 UI 测试增加价值

首先,添加的视觉回归测试被证明是有用的,并且发现了一些如果没有它们我们可能会错过的问题。 尽管我们预计组件会有所不同,但了解实际更改的内容有助于缩小问题案例的范围。 所以,如果你的项目有一个接口,但你还没有执行这些测试,那就去做吧!

这里的第二个教训,也许是更重要的一个教训,是我们再次被提醒完美是善的敌人。 如果我们因为没有事先设置而排除了为此版本运行视觉回归测试的可能性,那么我们可能在迁移过程中错过了一些错误。 相反,我们同意了一个虽然并不理想,但执行速度很快的计划,我们朝着它努力,并且得到了回报。

有关在您的项目中实施强大的视觉回归管道的更多详细信息,请参阅赛普拉斯的视觉测试页面,选择最适合您需求的工具,并观看教程视频。