# E2E test with Playwright

## Why e2e

首先谈谈为什么要写 e2e 测试。

### 可维护性

首先，e2e 测试和所有测试的主要目的在于**提高代码的可维护性**。 我们写代码的过程中，有一部分功能是新增的，但更多的是在已有基础上做一些改造，这个就属于维护过程，维护代码的过程中，我们都需要确认一下自己写的代码，是否会引入新的问题。一般我们手动点点看看，测试用例所做的事情，是自动化验证。 在没有测试的时候，当你提交 mr 的时候，你只能说，这个改动，应该不会有问题。有了测试用例以后，我们对于代码的维护过程会更加有信心，我们能说在已知的范围内都没问题。同时，其他人参与维护的时候也能更放心。

### 自动化

所谓自动化就是，当我们发现线上或者其他环境的 bug 的时候，我们都会去修复这个 bug ，然后我们回去验证一下这个 bug 是否修复了。但如果我们把这个验证的过程写成测试用例，测试用例在以后每次改动都自动化重现这个场景，保证这个 bug 不再出现。 在蚂蚁的时候，有一次我们组一个小伙在写一段代码，展示一个相互宝的分摊状态。这个卡有十几种状态分支，在一个函数处理，然后他写完了，测试提了几个 bug 。他继续修复，测试又测出其他几个 bug ，就这样反反复复，折腾了一天，测试受不了了，说你们想想怎么彻底解决吧，这样下去 bug 永远修不完的。 最终解决方法也很简单，写用例，十几种状态，每种状态写一个用例就好了。当需要处理的状态超过 10 个，就不是人脑能处理得来的。但通过测试，我们可以避免这种打地鼠式 bug 出现。

### e2e

除了 e2e 测试，还有的主要就是单元测试。但是对于前端业务代码来说，更多的是 ui 展示为主。主要模型就是读取后端接口，渲染页面，处理用户交互。这种单元测试也可以写一部分，比如一些纯功能函数。但更多的部分直接写 e2e 测试更简单一些。 所以这里主要讨论 e2e 测试，单元测试也需要，但如果要单元测试覆盖整个代码仓库，成本就有点太高了。我个人理解，那种基础的类库，比如 antd 这种非常适合单元测试为主。对于前端业务代码来说，e2e 是更容易覆盖整体的方式。

## Why Playwright

前端社区有非常多的 e2e 测试框架，最初在我们的代码仓库里面也集成了 cypress ，我选择 playwright 也是一个逐步探索的过程的。 最初，我看到一个介绍 chrome devtool 录屏工具，可以自动记录用户的行为，然后生成对应的执行脚步。我发现这个特别适合辅助写测试用例，e2e 测试的过程，通常就分为两个主要步骤：

* 打开页面，模拟用户行为操作
* 断言当前状态

如果能够记录用户操作过程，自动生成代码，这样就可以剩下很大工作量了。那里面提到了几个框架可以自动生成执行代码的测试框架，我随便选了一个 nightwatch ，然后尝试了一下。

### 问题

基于我过去的经验，写 e2e 最核心的问题是两个

* 维护和写测试成本
* 执行稳定性
  * 测试运行稳定性
  * 测试外部依赖稳定性

这两个问题，第一个是代码变动导致测试需要维护，同时如何写测试断言，本身也是一个很麻烦的事情，需要了解如何模拟用户执行 api ，如何使用框架断言。这个问题主要解决方式有两个方案

* 录屏记录用户操作，自动生成代码，这样可以减少手动写代码的过程
* 截屏对比，直接对比页面渲染最终的图片，这样断言也不需要手写了

当时选择 nightwatch 主要是看到他是有个 Visual Regression Testing 功能，然后还有录屏插件，感觉可能是不错的选择。

### 效果

折腾了一会儿，终于把两个页面测试写起来了，但是我发现效果并不好。虽然和录屏，但录屏的代码并不好用。可以截屏测试，但是第二个稳定性问题完全没有解决。如果测试不稳定，会耗费过多的精力在排查用户为什么挂了的过程中。 通常的 e2e 测试不稳定性问题，主要在于外部依赖的不稳定。也就是后端 api 接口，要保证测试绝对稳定，就要 mock 所有 api ，不然，当我们断言一个页面元素展示的时候，需要等待一个不确定接口返回，或者接口本身的变动，会导致测试异常，这种情况下，测试基本上是不可用的。可以看看写的代码示例

```typescript
export const start = () => {
  before((browser) => {
    const cookies = cookie.parse(process.env.COOKIE || "");
    browser.navigateTo(`\${BASE_URL}/datagov/api/swagger-ui/index.html`);

    Object.keys(cookies).forEach((key) => {
      browser.setCookie({
        name: key,
        value: cookies[key],
      });
    });
  });
};
```

这是页面初始化过程，需要在页面带上 cookie ，这个过程就非常 hack ，首先打开页面，这时候会报错，然后再写入 cookie ，这时候重新访问就有 cookie 了。但这种方式测试 staging 环境就直接挂了，因为打开第一个页面，会自动跳转登录，这时候写 cookie 就换了一个域名了。 写的测试过程也很折腾，这里有非常长的代码，做的时候就是执行一系列操作，最终到页面的某个状态，然后截图。但这个执行代码过程写起来是非常麻烦，这里有很多 pause ，就是为了等待接口加载，这个用例是非常不稳定的，每个 pause 都是不稳定的存在，截图本身也会因为后端接口变化而变化。

```typescript
  it("team cost: ram2", function (browser) {
    browser
      .navigateTo(`\${BASE_URL}/datagov/cost_center`)
      .execute(function() {
        // clear local storage, which is used to store the state of the cost center page
        localStorage.setItem('__COST_PAGE_STATE_KEY__', '');
      })
      .waitForElementVisible(".root-container")
      .pause(500)
      // open team cost tab
      .click(".cost-center-tab .ant-tabs-tab:nth-child(2)")
      .pause(500)
      // close L1 Team Cost
      .click('.team-cost-center .ant-collapse-header')
      .pause(500)
      // open RAM Team Cost
      .click('.team-cost-center .ant-collapse:nth-child(2) .ant-collapse-header')
      .pause(2000)
      .execute(function() {
        document.querySelector('.content-area')?.scrollTo(0, 700);
      })
      .click('.ram-team-cost__table .ant-table-row-expand-icon')
      .pause(1000)
      // take screenshot
      .assert.screenshotIdenticalToBaseline(
        ".root-container",
        "ram cost screen 2",
      );
  });
```

## Playwright

后来，看到我们隔壁一个大的前端组有一系列 playwright 的使用总结，这里面还特意提到他们从 cypress 迁移的经验，然后同时考虑到 playwright 是微软维护的，我感觉应该是靠谱的，与重头开始尝试 playwright 。

### 问题

首先，我们的测试在执行过程中，访问 api 是需要 mock 的，但是如果手动复制粘贴 mock 代码，是非常麻烦，而且繁琐的事情。所以，最好的方案是，首先通过一次访问，自动把后端 api 记录到本地，然后测试的时候直接使用这一份本地的数据。 这里第一次访问，是需要真实访问 staging 环境 api 的，这时候就需要解决登录问题，e2e 测试开启的都是一个新的浏览器环境。所以，首先我们需要解决的两个问题

* 如何自动记录网络接口数据
* 如何保持登录状态真实访问 staging 环境接口

大致看了下 playwright 的文档，有专门关于 [network](https://playwright.dev/docs/network) mock 和[登录管理](https://playwright.dev/docs/auth)的说明，通过这两个内置的功能，结合我们当前的环境，我简单封装了一个 playwright 的 [fixtures](https://playwright.dev/docs/test-fixtures) 。最终，再加上覆盖率收集功能，基于 playwright 的 e2e 在 datagov 应用下顺利跑起来了。 下面讲下，作为开发者，是如何写测试用例的。

### 第一步，codegen

[codegen](https://playwright.dev/docs/codegen-intro) 是 playwright 自带的一种模式，主要用于自动记录用户操作，然后生成相应的代码。这个具体的可以看一下官网的视频，就一目了然了。具体操作就是自动打开一个浏览器，还有个一个 record 窗口，在浏览器的所有操作，会被记录在 record 中，我们从 record 窗口可以得到我们需要的执行流程代码。 对于我们来说，稍微需要多走一步，就是在打开浏览器的时候，同时带上 cookie ，否则页面无法访问后端 api 的。这个我在我们的仓库封装了一个 npm run codegen 的 script ，自动把 cookie 转换为 playwright session 文件，然后在执行 `playwright codegen`的时候带上这个 session 配置就可以带上 cookie 了。

### 第二步，记录网络请求

通过第一步，我们可以得到我们需要执行的过程代码，把这一段代码复制到测试文件后，再通过配置打开 record 网络请求，重新执行一下测试，这样就可以把所有 api 请求记录到一个文件中了。比如下面的代码，把 isRecord 改成 true 就可以了。

```typescript
test('team', async ({ setup }) => {
  const { page, close } = await setup({ page: 'monitoring', isRecord: false });
  await page.goto('/');

  await page.getByLabel('Hide until next release').check();
  await page.getByRole('button', { name: 'OK' }).click();
})
```

这一步执行完成，会生成一个 monitoring.har 的文件，这个文件就是 chrome 网络请求记录 snapshot 了。

### 第三步，写断言

这也是最后一步了，这一步，得益于 playwright 的 vscode 插件，我们可以在 debug 模式下写断言，具体的操作直接看演示吧。 这里可以看到，playwright 的元素选中 api 是非常友好的，不是普通的比如前面 nightwatch 所使用的 css 选择器模式，css 选择器，写起来是非常繁琐和麻烦的事情，而且 dom 本身的变化会比较大，会导致选择器本身不稳定。但 getByLabel getByRole 这种基于页面可是元素文案的方式选择，稳定性更好，并且对于开发者也更容易理解，知道这个节点真实的含义。

### 最后

写断言这一步，如果不想写也可以直接截屏，直接对比图片方式来测试。这种情况需要生成 snapshot ，但由于 ci 机器和本地 mac 环境不一致，最终渲染结果会稍有差异。我们可以通过 docker 启动 ci 环境的容器，然后生成一份 Linux 环境的截屏。我整理了一份 bash 脚本，我们首先 docker pull 拉下容器镜像，然后执行 start 即可。

```bash
#!/bin/bash

if [ "\$1" = "start" ]; then
  docker run \
    -v \$(pwd):/work/ \
    harbor.shopeemobile.com/shopee-fe/web-frontend-playwright-runner:node18.14.2-pnpm7.33.1-playwright1.35.1 \
    /bin/bash -c 'cd /work/ && ./tests/scripts/test.sh test'
elif [ "\$1" = "test" ]; then
  echo `pwd`
  export NODE_OPTIONS=--openssl-legacy-provider
  echo '127.0.0.1 dev.datasuite.test.shopee.io' >> /etc/hosts
  npm run dev &
  sleep 10 && CI=1 npm run ee
fi
```

### CI and Coverage

最后写好用例了，可以提交代码了，这一步通过集成到 gitlab ci ，我们可以跟随 mr 自动执行测试任务。最终如果我们的测试代码越来也多，对于 mr 的信心也能更高了。 通过参考隔壁团队的文档，以及他们打包的镜像，我们可以直接使用相应的测试环境了。但最初折腾这个配置还是搞了一会儿的，这里最重要的就是 image 镜像了，自带了 playwright 依赖，否则每次需要安装 chrome 等工具，运行会非常慢。最后在执行前把本地的开发环境 cookie 写入到容器中，因为记录的 har 使用的域名就是这个，如果 URL 变化了，会导致 api mock 失效

```yaml
image: harbor.shopeemobile.com/shopee-fe/web-frontend-playwright-runner:node18.14.2-pnpm7.33.1-playwright1.35.1
e2e-test:
  stage: test
  before_script:
    - whoami
    - echo '127.0.0.1 dev.datasuite.test.shopee.io' >> /etc/hosts
    - cat /etc/hosts
  script: 
    - npm ci --registry=https://npm.shopee.io
    - npm run dev & 
    - sleep 10 && CI=1 npm run cov # sleep 60s for dev server start
```

Coverage 的收集，依赖了 chrome 自带的 coverage 统计，然后在每个用例执行结束，自动把统计信息转化为 istanbul 的 json 格式，最终跑完用例，通过 istanbul 工具生成 Coverage report ，这里也是参考了隔壁小组封装的工具。

## 总结

Playwright 基本上解决了我前面提到的两个核心问题

* 写测试成本
* 测试稳定性

通过 codegen 能自动生成测试代码，并且有可视化的定位器工具，加上友好的元素识别 api ，这一套组合下来，大大简化了测试代码的复杂度。 最最关键的是，通过网络的记录和重放功能，保证了 api 的稳定性，api 稳定了，所以外部依赖也就稳定了。再也不需要通过 wait 来等待 api 加载，也不怕后端数据变更了。并且这个过程都是自动化的，基本没有成本。 可以说，Playwright 的使用体验大大超出了我的预期。在我一开始想用 e2e 的时候，我还停留在 nightwatch 那种阶段（今天看 nightwatch 也发新的 3.0 了），我感觉稳定性是无法解决的，或者说是成本巨大的（mock 所有 api）。但 playwright 的稳定性和易用性是超出我的想象的，原来还能这样写，太强大了。

---

**Documents**

- [基于实践谈谈软件架构设计](https://iling.fun/s/blog/doc/5z65lqo5a6e6le16lci6lci6l2v5lu25p625p6e6k66k6h-tsGrQnNSNu)
- [rspack 使用体验过程](https://iling.fun/s/blog/doc/rspack-u8S4Mtjkfz)
- [E2E test with Playwright](https://iling.fun/s/blog/doc/e2e-test-with-playwright-JLFKAvWQ0U)
- [Vim everywhere](https://iling.fun/s/blog/doc/vim-everywhere-v6WZyLzGA1)
- [滴答 FaaS 研发平台小结](https://iling.fun/s/blog/doc/faas-XQxhEO3e5r)
- [Cod 服务编排之路](https://iling.fun/s/blog/doc/cod-0CFsOfiX20)
- [Cod 起源和用武之地](https://iling.fun/s/blog/doc/cod-1aAA2rVJZY)
- [2019 年度总结](https://iling.fun/s/blog/doc/2019-RhbTD2SiCn)
- [凤蝶区块历史及其问题排查记录](https://iling.fun/s/blog/doc/5yek6j225yy65z2x5y6g5yy5yk5yw26zeu6aky5o6s5pl6k6w5b2v-WoaaJ9QHpq)
- [如何用单元测试](https://iling.fun/s/blog/doc/5aac5l2v55so5y2v5ywd5rwl6kv-3zwG1cJvTn)
- [小钱袋 Node 应用开发经验总结](https://iling.fun/s/blog/doc/node-QpFfYCuoE0)
- [简单 JS 算数表达式求值算法的实现探索](https://iling.fun/s/blog/doc/js-FXcWkpW2vw)
- [凤蝶可视化编辑实现原理](https://iling.fun/s/blog/doc/5yek6j225yv6keg5yyw57yw6l6r5a6e546w5y6f55cg-ZsBj0CS1ev)
- [凤蝶服务端渲染经验总结二](https://iling.fun/s/blog/doc/5yek6j225pyn5yqh56uv5riy5pt57up6aqm5oc757ut5lqm-a4Kd59d7Td)
- [凤蝶服务端渲染经验总结](https://iling.fun/s/blog/doc/5yek6j225pyn5yqh56uv5riy5pt57up6aqm5oc757ut-GBhTESymbn)
- [Node.js 中 sourcemap 使用问题总结](https://iling.fun/s/blog/doc/nodejs-sourcemap-duex3wDhWt)
- [如何实现一个 mvvm 组件](https://iling.fun/s/blog/doc/mvvm-8TXAVSXe77)
- [webpack 热加载原理探索](https://iling.fun/s/blog/doc/webpack-7Wjn9z1fUB)
- [再谈 vim](https://iling.fun/s/blog/doc/vim-UBf5u57ko7)
- [How to realize velocity template interpreters](https://iling.fun/s/blog/doc/how-to-realize-velocity-template-interpreters-4s1KEznjYR)
- [joycss](https://iling.fun/s/blog/doc/joycss-bGvBvDGF7k)