从 Cypress 迁移到 Playwright
Cypress 是一款用于对 Web 应用进行端到端测试的开源工具。我第一次见到它,是 Gleb Bahmutov(格莱布·巴胡托夫)在 2018 年纽约的一次 Web 开发者聚会上演示 Cypress,当时我大为震撼。

自从 2018 年在开发者聚会上看到它的演示以来,我一直在使用 Cypress。
在发现 Cypress 之前,我一直不情愿地使用着 Selenium。Cypress 是一次令人耳目一新的飞跃,它优雅地解决了大量让 Selenium 难以实际使用的痛点。
我最近试用了 Playwright——微软对 Cypress 的回应。体验了一天之后,我已经准备从 Cypress 彻底切换到 Playwright。
说这话让我有些难过,因为我对 Cypress 那支小巧而顽强的团队怀有好感。我当然不热衷于依赖微软这样的巨型公司,但 Playwright 实在好太多了,我没法说服自己继续留在 Cypress 上。
以下是我趁记忆犹新时记录的、从 Cypress 切换到 Playwright 的一些心得。
我此前使用 Cypress 和 Playwright 的经验
在过去四年里构建的几乎所有 Web 应用中,我都编写了 Cypress 端到端测试。我算是一名中等水平的 Cypress 用户。我的 Cypress 需求大多很简单,只用到基础 API。我从未写过自定义插件,但用过几个第三方插件。
而 Playwright 我只用了一天。为了上手实践,我尝试把我的一个应用的测试套件从 Cypress 移植到 Playwright。我选的是 PicoShare,我的一款极简文件共享工具,它只有 10 个端到端测试。我花了大约五个开发小时就把它们全部从 Cypress 移植到了 Playwright,其中还包括学习 Playwright API 的时间。
我从未为 Cypress 或 Playwright 付过钱,所以我对这两个工具都不享有任何权利。Cypress 有付费的 SaaS 组件,但我从未购买过,因为它不适合我的工作流程。我本很乐意像赞助其他我使用的开源项目那样赞助 Cypress,但 Cypress 不提供任何赞助选项。
我喜欢 Playwright 的地方
Playwright 比 Cypress 快得多
在 CircleCI 上,我的 Playwright 测试套件比等效的 Cypress 测试快 34%。在我的本地开发机上,Playwright 相比 Cypress 有 5 倍的提速。这并不是严格的测量,但两者之间存在显著的速度差异是显而易见的。
| 任务 | Cypress | Playwright | 差异 |
|---|---|---|---|
| 在 CircleCI 上运行测试 | 127 秒 | 84 秒 | -34% |
| 从开发机运行测试 | 40 秒 | 7 秒 | -83% |
CI 上性能差异的一部分原因在于 Playwright 的 Docker 镜像比 Cypress 的镜像小得多。对于本地开发来说这不算大事,因为只需下载一次就完事了。但当我在 CI 中运行 Cypress 时,每次都得等 CircleCI 下载并解压一个约 1 GB 的镜像。
| cypress/included:10.9.0 | playwright:v1.26.0-focal-amd64 | |
|---|---|---|
| 大小 | 940 MB | 651 MB |
Playwright 提供一套一致的断言
Cypress 在其工具中捆绑了九个不同的第三方库,导致 API 混杂且不一致。有 should、expect 和 assert,你需要根据所处的上下文使用不同的关键字。
例如,以下两个代码片段执行完全相同的断言:
cy.get("#error-message").should("be.visible");cy.get("#error-message").should(($el) => expect($el).to.be.visible);而在 Playwright 中,只有单一、一致的 API。断言 ID 为 error-message 的元素在屏幕上可见,只需一个简单的函数调用:
expect(page.locator("#error-message")).toBeVisible();Playwright 不依赖 GUI 环境
Cypress 最引以为傲的功能之一是它的桌面 GUI 应用:

Cypress 使用桌面应用来展示测试执行过程
Cypress 桌面应用让你可以在测试中“时间旅行”,从而看到测试中每个时间点浏览器窗口的样子。
但如果你在没有 GUI 的环境下开发呢?我所有的开发工作都在无头服务器虚拟机上进行。使用 Cypress 的四年里,我从未用过它的桌面应用。相反,我在 Docker 容器中运行 Cypress,而对于一个期望你在其桌面 GUI 中工作的工具来说,这有时会是个障碍。
当你试图在 CI 环境中运行 Cypress 测试时,GUI 问题又会冒出来。CI 环境通常也没有桌面 GUI。Cypress 给出的答案是使用他们的付费 CI 服务,这也是他们为公司盈利的主要方式。
我支持公司以任何他们想要的方式将其开源产品商业化,但 Cypress 的 CI 产品从未吸引过我。我希望能够在本地用 Docker 容器复现我的 CI 环境。Playwright 能做到这一点,而 Cypress 的 CI 服务做不到。
为了在 CircleCI 上运行 Cypress,我不得不用 Docker Compose 稍作周旋。这算不上多大的额外负担,但它让测试技术栈比我想要的更复杂了一些。
当我尝试 Playwright 时,使用一个天生为无头运行而设计的工具真是让人耳目一新。在 CI 中运行 Playwright 我不需要做任何花哨的事情,因为它在无头环境中开箱即用。
Playwright 拥有与 Cypress 相同的时间旅行功能,但他们用 Web UI 而非桌面 GUI 来实现,因此能在更多环境中使用。
时间旅行真的很棒!Playwright 的快照甚至不只是应用的静态截图。你可以在测试的每个阶段与浏览器交互,感觉有点像魔法。
Playwright 的 Web UI 让你可以时间旅行到应用执行的不同状态,并与页面上的任何元素交互。
Playwright 的功能缺口更少
Cypress 让你轻松上手基础的端到端测试,但我发现随着应用规模增长,我经常撞上测试工具的功能缺口。
比如,我加了一个文件上传功能,然后发现 Cypress 无法测试文件上传。我只好放下手头的事,去找一个第三方 Cypress 插件来填补缺口。
在写这篇文章时,我发现 Cypress 在今年早些时候增加了对文件上传的原生支持,但一个极其常见的场景竟然花了他们七年才支持,实在让人费解。
同样,如果你想模拟鼠标悬停——几乎所有 Web UI 框架都有的功能——Cypress 做不到。这个 bug 已经开了将近八年。
我相信 Playwright 也有自己的功能缺口,但在一天从 Cypress 移植测试的过程中,我一个都没遇到。我测试套件中为绕过 Cypress 缺口所做的所有变通,在 Playwright 中都有原生解决方案。
Playwright 需要的领域特定知识更少
当年发现 Cypress 时,吸引我的原因之一是它专为 JavaScript 设计,而 Selenium 是 Java 优先的。
对于基础测试来说,Cypress 的语义对理解 JavaScript 的人来说感觉自然而熟悉。但一旦你偏离常规路径,Cypress 突然就不太像 JavaScript 了,而更像一个自己的领域专用框架。
举个例子,我的应用 PicoShare 有一个功能,可以为想要分享给未认证用户的文件生成 URL。为了测试这个功能,我需要进入 PicoShare 的分享功能、退出用户会话,然后验证浏览器仍能访问几步之前生成的那个 URL。
以下是我最初在 Cypress 中实现这个测试的方式:
// Save the route to the guest link URL so that we can return to it later.
cy.get('.table td[test-data-id="guest-link-label"] a')
.invoke("attr", "href")
.then(($href) => {
// Log out.
cy.get("#navbar-log-out").click();
cy.location("pathname").should("eq", "/");
// Make sure we can still access the guest link after logging out.
cy.visit($href);
// Continue with the test
});你看到了 then,所以可能会以为 invoke 返回了一个 Promise。但如果你尝试 await 这个 promise,它会返回 undefined,因为 Cypress 实际返回的是一个只是假装成 Promise 的东西。
这看起来可能不是什么大事,但一旦你需要在应用中动态引用某个值,Cypress 就会强迫你为每个需要的值增加一层新的嵌套闭包。有一个支持 await 的功能请求获得了广泛支持,但四年过去毫无进展,而且 Cypress 最近表示他们目前没有实现它的计划。
以下是同样的测试在 Playwright 中的样子:
// Save the route to the guest link URL so that we can return to it later.
const guestLinkRouteValue = await page
.locator('.table td[test-data-id="guest-link-label"] a')
.getAttribute("href");
expect(guestLinkRouteValue).not.toBeNull();
const guestLinkRoute = String(guestLinkRouteValue);
// Log out.
await page.locator("#navbar-log-out").click();
await expect(page).toHaveURL("/");
// Make sure we can still access the guest link after logging out.
await page.goto(guestLinkRoute);
// Continue with the test.在 Playwright 中,当我们拿到一个 DOM 元素的引用后,可以对它调用正常的 API,比如 getAttribute,然后直接得到我们期望的简单值,无需纠缠闭包的复杂性。而且 Playwright 返回的那些看起来像 promise 的值,真的是可以 await 的 Promise,所以代码更整洁。
在 Playwright 中文本比较更容易
Cypress 一直让我感到沮丧的一点,是断言某个元素包含特定文本值实在太难了。
下面是 PicoShare 中的一个 <p> 元素示例:
<p data-test-id="github-instructions">
Visit our
<a href="https://github.com/mtlynch/picoshare">GitHub repo</a> to create your
own PicoShare server.
</p>Cypress 中出乎意料的文本比较结果
以下是在 Cypress 中断言文本值的天真做法:
cy.get("[data-test-id='github-instructions']").should(
"have.text",
"Visit our GitHub repo to create your own PicoShare server.",
);不幸的是,这个测试会失败:
Timed out retrying after 10000ms
+ expected - actual
-'
Visit our
GitHub repo to create
your own PicoShare server.
'
+'Visit our GitHub repo to create your own PicoShare server.'Cypress 获取的是 textContent 属性,其中包含文本在原始 HTML 中呈现的所有空白,而不是文本在浏览器中呈现的样子。
你可以改用获取元素的 innerText 来绕过这个问题,但语法绕口且难以记忆,因为它使用了一套完全不同的断言 API:
cy.get("[data-test-id='github-instructions']").should(($el) => {
expect($el.get(0).innerText).to.eq(
"Visit our GitHub repo to create your own PicoShare server.",
);
});Playwright 中不出意料的文本比较
在 Playwright 中,天真的断言就能得到正确的行为:
await expect(page.locator("data-test-id=github-instructions")).toHaveText(
"Visit our GitHub repo to create your own PicoShare server.",
);Playwright 同样查看元素的 textContent,但它会像浏览器一样自动修剪和合并空白。
你也可以强制 Playwright 改为查看 innerText,其语法比 Cypress 中可用的简单得多:
await expect(page.locator("data-test-id=github-instructions")).toHaveText(
"Visit our GitHub repo to create your own PicoShare server.",
{ useInnerText: true },
);Playwright 也有失分之处:它有两个名称相似、看似相同的 API:
toHaveText:“确保Locator指向一个具有给定文本的元素。值也可以使用正则表达式。”toContainText:“确保Locator指向一个包含给定文本的元素。值也可以使用正则表达式。”
一个 API 用于断言元素存在“且具有给定文本”,而另一个则断言元素存在“且包含给定文本”。“具有”文本和“包含”文本有什么区别?
多读了一些文档之后,区别似乎归结为对元素子元素的期望存在细微差异,但文档确实还有改进空间。
Playwright 让遍历 shadow DOM 更容易
我写了很多使用 HTML 自定义元素的 Web 应用,所以我的代码经常包含嵌套的 shadow DOM。
在 Cypress 中,指定 shadow DOM 内的页面元素有点别扭,因为每当你遇到 shadow DOM 边界时,都得打断 CSS 选择器:
cy.get("#upload-result upload-links")
.shadow()
.find("#verbose-link-box")
.shadow()
.find("#link")
.should("be.visible");Playwright 默认会穿透 shadow DOM,因此可以使用简洁的 CSS 选择器:
await expect(
page.locator("#upload-result upload-links #verbose-link-box #link"),
).toBeVisible();更新(2022-10-26):reddit 用户 /u/Daffodils2 指出,Cypress 提供了一个 includeShadowDom 选项,可以让它在通过 shadow DOM 选择元素时表现得像 Playwright 一样。
Playwright 会替你启动你的应用
Cypress 一个奇怪的设计决策是,他们拒绝替你启动你的应用。你得自己想办法启动应用,然后编排你的 Cypress 测试,让它们在应用开始提供服务后再启动。
Playwright 消除了这种编排上的麻烦,提供了一个简单的配置选项来启动你的应用。以下是 PicoShare 中的配置:
webServer: {
command: "PS_SHARED_SECRET=dummypass PORT=6001 ./bin/picoshare",
port: 6001,
},Playwright 的日志功能真的能用
Cypress 的一大痛点是,你必须学会忍受无法在终端输出调试日志。Cypress 没有任何官方途径打印到 stdout 或 stderr。
如果我加一句 console.log,什么都不会发生:
console.log("hello from Cypress"); // this does nothingCypress 有自己的 cy.log API,那我试试它?
cy.log("hello from Cypress"); // this prints nothing to the terminal不行,这也不管用。它只在 Cypress 桌面 GUI 或 Cypress 自家的 SaaS 仪表板中输出。
Cypress 开发者 Zach Bloomquist 发布过一个非官方插件,用于将浏览器控制台输出打印到终端,但那是第三方插件,不是 Cypress 官方支持的。
而在 Playwright 中,console.log 直接就能用,毫不费事:
console.log("hello from Playwright");运行测试时,我能在终端输出中看到日志消息:
[chromium] › auth.spec.ts:3:1 › logs in and logs out
hello from PlaywrightPlaywright 团队没有资源受限的感觉
Cypress 的核心仓库有 2,782 个未关闭的 bug,其中一些涉及被忽视多年的重要功能请求。有时人们用插件来填补缺口,但常常让人感觉 Cypress 核心团队就是没有资源跟上现代 Web 开发的步伐。
一年前我向 Cypress 提交了一个毫无争议的 PR,至今仍未得到回应。我怀疑他们只是没有资源审查外部拉取请求。
相比之下,尽管收到的 bug 报告数量大致相同,Playwright 只有 603 个未关闭的 bug。当我向 Playwright 提交一个 bug 时,他们在一个工作日内就完成了分类并给了我一个有实质内容的回复。
Playwright 与 VS Code 集成得更好
Playwright 提供一个官方的 VS Code 插件,可以给你带来上下文感知的自动补全。在 Playwright 中见到它之前,我从未意识到自己一直在 Cypress 中缺失这项功能:

Playwright 的 VS Code 插件提供上下文感知的自动补全。
在 Cypress 中,函数数量很少,你需要通过传递特殊的字符串值来调用不同的功能。IDE 很难帮助理解这种语义,而 Playwright 明确的 TypeScript 函数列表让 IDE 更容易为你提供帮助。Cypress 也有第三方 VS Code 插件,但没有 Cypress 团队官方支持的。
在 Playwright 中并行测试是免费的
理论上,你可以在 Cypress 中免费运行并行测试,但他们刻意把它做得不方便。这我倒不能怪他们,因为并行测试是 Cypress 付费 SaaS 工具的招牌功能之一,让免费版更有用会让他们赔钱。
微软的财力远比 Cypress 雄厚,所以他们负担得起把 Playwright 的所有功能免费送出。因此,Playwright 开箱即支持并行测试。
我会想念 Cypress 的地方
Cypress 的语法在流畅性上更一致
Cypress 和 Playwright 都提供流畅风格(fluent-style)的 API,即把一系列操作链式连接成一条语句。
Cypress 更严格地遵循流畅风格,让开发者可以从左到右阅读测试逻辑。
cy.get(".navbar-item [data-test-id='log-in']").should("be.visible");使用 Cypress 时,我写代码的顺序与我对测试的思考顺序一致。首先,我拿到一个元素的引用。然后,我考虑要做什么断言。
在 Playwright 中,顺序就有点混乱了。在我开始定位要测试的元素之前,我必须先把代码包在 expect 调用里:
await expect(
page.locator(".navbar-item [data-test-id='log-in']"),
).toBeVisible();Playwright 的语法打断了我习惯于从 Cypress 得到的从左到右的顺序。我希望 Playwright 的语法看起来更像这样:
// INVALID - not how Playwright actually behaves
await page
.locator(".navbar-item [data-test-id='log-in']")
.expect()
.toBeVisible();Cypress 拥有一支小而独立的团队
我对 Cypress 作为一家开源公司怀有个人好感,尤其是他们的工程副总裁 Gleb Bahmutov。Gleb 发布高质量的博客文章,也是一位出色的会议演讲者。
当我写一篇关于 Cypress 的博客文章时,Gleb 很大方地分享反馈来改进这篇文章。文章发布后,Cypress 还在他们的博客上推广了我的文章。
而微软在历史上一直对开源怀有敌意。他们目前正处于友好期,但如果风向转变,他们意识到通过打压开源能赚到更多钱,他们很可能会那么做。
如果这是一部电影,Cypress 会是那个让你不由自主为其加油的小而顽强的弱者,而微软则是那个很可能在第三幕背叛英雄的改邪归正的反派。
Cypress 的测试产物在 CI 中可用
当 Cypress 测试失败时,它会在失败点对你的应用截图,并将图像保存到磁盘。很容易配置你的 CI 平台保留这些图像作为测试产物以便于调试。类似地,Cypress 允许你保存每个测试的视频,这些视频也可以作为 CI 测试产物发布。

Cypress 生成的测试产物很容易作为 CI 产物查看
Playwright 生成的测试产物则更为复杂。Playwright 生成的是一个用于查看所有测试产物的静态 Web 应用,而不是简单的图像和视频。
不幸的是,Playwright 的报告查看器在 CircleCI 上无法工作,所以我不得不下载资源并在本地运行一个 Playwright 服务器,而不是直接从 CircleCI 仪表板查看。
Cypress 的 Docker 镜像真的包含软件本身
有一种我只在端到端测试工具中见过的模式:Cypress 和 Playwright 的官方 Docker 镜像实际上并不包含工具本身。也就是说,Cypress 的 Docker 镜像里没有 Cypress,Playwright 的 Docker 镜像里也没有 Playwright。
相反,这些 Docker 镜像包含的是你安装 Cypress 或 Playwright 所需的依赖项。所以当你运行 Playwright 的 Docker 镜像时,你仍需要在环境设置中安装 Playwright。
这背后肯定有什么充分的理由,但我一直没搞懂。当我向 Cypress 团队抱怨此事后,他们添加了一个包含 Cypress 工具本身的特殊 cypress/included 镜像。而 Playwright 似乎没有对应的 Docker 镜像。
总结
虽然我才使用 Playwright 几个小时,但我发现它的体验比 Cypress 好得多。凭借更清晰的 API、更简单的测试设置和更快的速度,我在 Playwright 中的效率很可能比在 Cypress 中高出 50% 到 100%。
今后,我将用 Playwright 测试我所有的新应用。对于测试时间已经超过五分钟的应用,我甚至可能会把一些旧的 Cypress 测试移植到 Playwright。
如果你是 Cypress 用户,我强烈建议你试试 Playwright。在我看来,从 Cypress 到 Playwright 的跨越,与从 Selenium 到 Cypress 的跨越一样重大。
随机一篇博客