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 ,不然,当我们断言一个页面元素展示的时候,需要等待一个不确定接口返回,或者接口本身的变动,会导致测试异常,这种情况下,测试基本上是不可用的。可以看看写的代码示例

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 都是不稳定的存在,截图本身也会因为后端接口变化而变化。

  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 mock 和登录管理的说明,通过这两个内置的功能,结合我们当前的环境,我简单封装了一个 playwright 的 fixtures 。最终,再加上覆盖率收集功能,基于 playwright 的 e2e 在 datagov 应用下顺利跑起来了。 下面讲下,作为开发者,是如何写测试用例的。

第一步,codegen

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

第二步,记录网络请求

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

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 即可。

#!/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 失效

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 的稳定性和易用性是超出我的想象的,原来还能这样写,太强大了。