End-to-End Testing Web Apps: The Painless Way

Michael Lynch

端到端测试 Web 应用:轻松无痛的方法

好吧,你对此持怀疑态度。其他指南也曾向你承诺可以轻松无痛地测试 Web 应用,结果却发现它们的方案要求使用某种极其特定的技术栈,或购买第三方服务。我不会这样对你。

本指南提供了一个简单直接且灵活的端到端测试(end-to-end testing)模板,几乎可以应用于任何 Web 应用。唯一的要求是:你的应用能够在 Docker 中运行。

真的就只有这一个要求!你可以测试 Ruby 应用、React 应用、Enterprise Java Beans 应用,甚至是你自己发明的某种古怪 Web 技术栈。而且,无论你是在 Windows、Linux 还是 Mac 上开发,都没有关系。最棒的是,你不需要进行复杂的配置,也不必安装 Docker 之外的任何软件。

本教程使用免费、开源的工具,你无需在任何地方注册账号即可运行它们。到了需要在 Circle 或 Travis 这样的持续集成(continuous integration)环境中运行测试的时候,你也不需要做任何特殊处理——使用与你在开发机器上一样的一行命令运行测试即可。

Cypress,主角登场

更新(2022-10-25):我不再推荐使用 Cypress 对 Web 应用进行端到端测试。对于新项目,我建议改用 Playwright

让这一切测试成为可能的工具是 Cypress,它是浏览器自动化领域的新近成员。它是一个开源端到端测试框架,并由一支全职团队积极开发。它们的商业模式与 Docker 类似:两家公司都发布免费的开源工具,并通过销售这些工具的托管服务来资助开发。

Cypress 标志

Cypress 是用于 Web 应用自动化测试的开源工具。

去年,我在一次地区性软件会议上看到 Gleb Bahmutov(格列布·巴赫穆托夫)演示 Cypress 后,第一次发现了它。当他提到 Cypress 不依赖 Selenium 时,我产生了兴趣。我此前所有端到端测试的经历都糟糕透顶,而 Selenium 始终是痛苦的根源。

Selenium 标志

Selenium 是历史最悠久、使用最广泛的浏览器自动化工具,但它笨拙且过时。

Selenium 无疑是最流行的浏览器自动化框架,但它也具备一个 15 年前设计的 Java 工具所能带来的一切问题。安装它很痛苦,语法也很别扭,而且测试失败时几乎提供不了什么有用信息。在 Gleb 流畅漂亮的 Cypress 演示中,它似乎可以解决所有这些麻烦。

Cypress 标志

Cypress 的一个出色功能是,它会记录测试每一步中的浏览器画面,帮助你诊断失败原因。

我兴致勃勃地阅读了 Cypress 文档,却失望地发现,Cypress 几乎所有文档都假定用户使用 Node.js 技术栈,并且在图形环境中而不是无头控制台中开发。

不过,Cypress 似乎仍有光明的前景。一年后,我再次了解它们的进展,发现了一个新的示例应用,将 Cypress 与 Docker Compose 结合了起来。突然之间,一切都清晰了。看到 Cypress 在 Docker Compose 下运行后,我很容易就明白了如何将这一模式应用到任何 Web 应用。今天,我就来向你展示这种模式,以及如何在自己的应用中使用它。

可复用的端到端测试模式

将 Cypress 与 Docker Compose 结合起来,可以得到一种足够灵活、几乎适用于任何 Web 应用的测试模式。与那些会对应用实现方式做出假设的其他测试工具不同,这一方案将测试框架与被测应用完全解耦。

Docker 容器架构示意图

Docker Compose、Cypress 与 Web 应用如何协同工作

Docker Compose 允许你将 Cypress 和应用分别运行在两个容器中。你的应用无需了解任何 Cypress 的信息,而 Cypress 唯一需要知道的应用信息,就是用于发送 HTTP 请求的网络端口。

一个用于测试的简单 Web 应用

作为待测试的示例 Web 应用,我介绍 Sentimentalyzer:世界上最蠢的文本情感分析器。它试图根据用户的一段文字来猜测用户的心情。

如果你输入文本 It's a nice day today,Sentimentalyzer 会推断你很开心:

在 Sentimentalyzer 中输入文本Sentimentalyzer 生成结果

Sentimentalyzer 分析表达开心情绪的文本

如果你输入文本 Who ate ALL MY WAFFLES?,Sentimentalyzer 会认定你很生气:

在 Sentimentalyzer 中输入文本Sentimentalyzer 生成结果

Sentimentalyzer 分析表达愤怒情绪的文本

算法很简单:如果超过 50% 的字符是大写字母,就说明用户在大喊大叫,因此一定很生气。否则,Sentimentalyzer 就假定用户感觉还不错。

项目布局

下面是我的示例项目的文件布局:

main.go               <- source for my web app, Sentimentalyzer
Dockerfile            <- defines how to run Sentimentalyzer in a Docker container
e2e/                  <- folder that contains all the files for my end-to-end tests
  cypress.json        <- Cypress configuration
  docker-compose.yml  <- glue that binds together my app container with the Cypress container
  integration/
    spec.js           <- defines the end-to-end test for Sentimentalyzer

所有生产逻辑都位于根目录,而所有端到端测试代码都位于 e2e 文件夹中。

在本地运行 Sentimentalyzer

我特意没有在这里展示应用源代码,以强调这样一个事实:你完全可以在不看应用自身实现的情况下编写 Cypress 测试。Sentimentalyzer 恰好是一个 Go 应用,但如果我用 Python 或 Angular 实现它,测试也会完全相同。如果你感兴趣,可以在 GitHub 上查看源代码。

要在自己的机器上试用 Sentimentalyzer,请运行以下命令:

git clone https://github.com/mtlynch/hello-world-cypress.git
cd hello-world-cypress
docker build --tag sentimentalyzer .
docker run \
  --interactive \
  --tty \
  --env PORT=8123 \
  --publish 8123:8123 \
  sentimentalyzer

上述命令会在本地机器的 http://localhost:8123 启动 Sentimentalyzer 服务器。

现在,我已经可以在 Docker 容器中运行应用,接下来就可以使用 Cypress 为它创建端到端测试了。

创建端到端测试

要编写第一个 Cypress 端到端测试,你只需要三个文件:

  • cypress.json
  • docker-compose.yml
  • integration/spec.js

cypress.json

该文件用于指定 Cypress 的配置选项

{
  "pluginsFile": false,
  "supportFile": false
}

下载 cypress.json

这些设置并没有什么特别之处,但我将它们设为 false,以防止 Cypress 自动生成不必要的辅助文件。

docker-compose.yml

该文件定义了一个用于 Sentimentalyzer 的 Docker 容器和一个用于 Cypress 的 Docker 容器,并允许二者相互通信:

version: "3.2"
services:
  sentimentalyzer:
    build: ../
    environment:
      - PORT=8123
  cypress:
    image: "cypress/included:4.4.0"
    depends_on:
      - sentimentalyzer
    environment:
      - CYPRESS_baseUrl=http://sentimentalyzer:8123
    working_dir: /e2e
    volumes:
      - ./:/e2e

下载 docker-compose.yml

下面几行值得特别说明:

image: "cypress/included:4.4.0"

cypress/includedCypress Docker 镜像系列,其中的镜像本身已经预装了 Cypress。其他系列(例如 cypress/basecypress/browsers)则假定客户端会在运行时安装 Cypress。通过使用 cypress/included 镜像,我可以确保 Cypress 在容器启动后立即执行测试。

depends_on:
  - sentimentalyzer

depends_on 配置段确保 Sentimentalyzer 已经启动并运行后,Cypress 才开始执行测试。

environment:
  - CYPRESS_baseUrl=http://sentimentalyzer:8123

CYPRESS_baseUrl 环境变量向 Cypress 提供了访问 Sentimentalyzer 的 URL。由于 Cypress 和 Sentimentalyzer 运行在同一个 Docker Compose 配置中,Cypress 可以使用其容器名称(sentimentalyzer)作为主机名,向 Sentimentalyzer 发送网络请求。

working_dir: /e2e
volumes:
  - ./:/e2e

最后,我使用 Docker 的卷挂载功能,让 Cypress Docker 容器共享主机文件系统中的一部分内容。

主机上的 ./e2e 目录中的所有内容,都会出现在 Docker 容器的 /e2e 路径下。这样,当 Cypress 执行期间写入日志、屏幕截图或视频时,这些文件会立即出现在主机上,无需手动从容器复制到主机。以这种方式绑定主机卷,也便于编辑和重新运行测试,而不必重新构建整个 Docker 镜像。

working_dir 行确保 Cypress 将 /e2e 目录视为文件系统中的当前文件夹。

integration/spec.js

配置处理完毕,现在到了有趣的部分:编写测试。

it("detects angry sentiment", () => {
  cy.visit("/analyze");

  cy.get("#feelings").type("I REALLY need some COFFEE");
  cy.get("form").submit();

  cy.get(".results p").should("contain", "You are feeling: Angry");
});

it("detects content sentiment", () => {
  cy.visit("/analyze");

  cy.get("#feelings").type("I think coffee in the morning is just swell!");
  cy.get("form").submit();

  cy.get(".results p").should("contain", "You are feeling: Content");
});

下载 spec.js

即使你不熟悉 Cypress API,它的语义也足够易读,你大概可以凭直觉理解这些测试。用日常语言来说,两个测试遵循相同的流程:

  1. 在浏览器中,访问 Sentimentalyzer Web 应用的 /analyze 路径。
  2. 找到文本字段。
  3. 输入一些文本。
  4. 提交表单。
  5. 读取结果。

下面我会逐行讲解第一个测试:

cy.visit("/analyze");

这行代码告诉 Cypress 在浏览器中加载 Sentimentalyzer 的 /analyze 路径。Cypress 会将它与我在上面的 docker-compose.yml 中定义的 CYPRESS_baseUrl 环境变量结合起来,因此完整 URL 是 http://sentimentalyzer:8123/analyze。你无法从开发机器访问这个 URL,但它在 Cypress 容器内是一个有效地址。

cy.get("#feelings").type("I REALLY need some COFFEE");

接下来,我告诉 Cypress 找到文本字段。由于该文本字段具有唯一 ID feelings,因此很容易找到;我使用 CSS 选择器语法指定这个元素:#feelings

查找 feelings 元素的 HTML id

type() 函数会告诉 Cypress 将一些文本输入我指定的字段。

接下来,Cypress 必须提交表单。Cypress 为这个常见任务提供了 submit() 函数。页面上只有一个 <form> 元素,因此使用 CSS 选择器 form 获取它,然后提交表单非常简单:

cy.get("form").submit();

提交表单后,Cypress 应该会进入 Sentimentalyzer 的结果页面。Cypress 需要检查文本 "You are feeling: Angry",但这稍微麻烦一些,因为包含该文本的 <p> 标签没有 ID 属性:

查找结果 <p> 标签的 CSS 选择器

我再次使用 CSS 选择器语法,通过指定位于 class 为 "results" 的 DOM 节点下的 <p> 元素,来定位相关文本:

cy.get(".results p").should("contain", "You are feeling: Angry");

contain 断言会验证 <p> 标签包含我所期望的文本。

运行测试

现在一切就绪,是时候看看 Cypress 如何运作了。我用一条简单的命令运行测试:

cd e2e
docker-compose up --exit-code-from cypress

--exit-code-from cypress 标志告诉 Docker Compose 使用 Cypress 容器的退出代码作为 docker-compose 命令的退出代码。这意味着测试通过时该命令的退出代码为零,测试失败时则为非零。对于那些通过命令退出代码判断命令是否成功的构建脚本或持续集成配置来说,这一行为非常方便。

下面是整个过程在控制台中的样子:

Cypress 会为每次测试运行创建视频记录。这是我最喜欢的功能,因为它在诊断测试失败时帮助极大:

Cypress 对端到端测试的录制(以 1/4 倍速放慢)

测试失败时的屏幕截图

上面展示的是一个通过的测试。如果 Cypress 测试失败,会发生什么?它仍然会生成测试运行视频,但还会输出一张屏幕截图,显示失败的断言:

Cypress 在失败时输出的屏幕截图

我的测试失败时 Cypress 生成的屏幕截图(Cypress 期待的是单词 “Furious”,但实际找到的是 “Angry”)

这解决了我使用其他工具时遇到的一个主要痛点。Selenium 支持屏幕截图,但只能在断言之前或之后截图。这一限制会导致令人沮丧的情况:Selenium 声称测试失败了,但屏幕截图却显示行为正确,因为测试失败后浏览器状态发生了变化。

Cypress 避免了这个问题,因为它会与断言同时进行屏幕截图。如果测试失败,屏幕截图会准确显示 Cypress 在失败时看到的内容。

将这一方案应用到你的 Web 应用

要开始对 Web 应用进行端到端测试,你所需的全部内容就是这三个文件。步骤如下:

  1. e2e 文件夹复制到你的项目中。
  2. docker-compose.yml 中的 sentimentalyzer 部分替换为用于运行你应用的 Docker 容器。
  3. 根据应用的 UI 流程重写 integration/spec.js

源代码和其他示例

这个演示的完整源代码可在 GitHub 上找到:

我还创建了几个分支,用于演示其他常见的 Cypress 场景:

延伸阅读

本指南对 Cypress 作了基础介绍。若要了解更高级的功能,请参阅官方 Cypress 文档:

更新(2019-05-02):针对本文,Cypress 团队发布了一个官方 Docker 镜像,其中已预装 Cypress。我已经修订本教程,以整合他们的新镜像。更多关于这些镜像以及将 Cypress 与 Docker 结合使用的技巧,请查看 Cypress 博客文章


插图由 Loraine Yow(洛林·尤)绘制。感谢 Cypress 团队的 格列布·巴赫穆托夫为本文提供早期反馈。

原文由 Michael Lynch 发布

本文章由 openai/gpt-5.6-luna 进行翻译