cypress-cover

关于前端测试的一些理论与基于 Cypress 的 E2E 测试具体实践。

关于前端自动化测试的一些碎碎念

日常业务项目开发的痛点之一便是前端的回归测试,免不了各种手动点点点,但凡改动了某个公用组件,函数,都要漫山遍野地把项目的主要页面都点进去看一遍有没有问题。项目用了 GraphQL 的话,Schema 一个更新不及时,某个没注意到的页面就挂了,然后就等着开 issue 或者报线上 Bug 吧 😐

通过人工手动点点点不仅是累,也并不靠谱,没法保证每一次都测到了需要回归测试的功能。想解决这一痛点,就不得不提前端的自动化测试。通过命令行跑测试,集成 CI 自动测试岂不美滋滋。

然而,国内各厂对于前端自动化测试尚未形成很好的实践,说起自动化测试,大家想到的也还是后端测试。几次技术大会(JSConf、GMTC 等等)里关于前端测试的话题也是寥寥无几,有的话也是国外前端工程师的分享,或是 QA 关于搭建测试平台的分享。

在我的经验里,对于业务项目而言的前端测试都很“尴尬”。如果是开发工具库,那可以通过单元测试来保证质量,如果是开发 UI 组件库,可以通过 Storybook 来进行视觉与快照测试。所以之前每每想到业务项目如何集成自动化测试都感觉无从下手(还有几次是被比业务代码还多的测试代码吓跑了)。

但是业务项目里并没有太多工具函数需要单元测试(大部分通过 lodash 或其他第三方库来解决复杂逻辑处理),UI 组件也基本是直接采用了业界比较成熟的 ant-mobile, ant-design 等方案,需要在业务项目中开发的 UI 组件并不多,大多数是在第三方的 UI 组件基础上结合业务逻辑进行二次封装(事实上 ant-design 也把自己的组件单独放到 https://github.com/react-component 里维护了)。

业务项目里需要自动化测试的场景主要是想覆盖用户的主要使用路径,例如登录注册,加购到购物车,查看操作订单,修改个人信息等等,都是与 UI 界面的渲染逻辑强相关的,需要测试这些页面的表单提交,自动跳转,数据渲染是否有异常。

所以在此可以梳理一下我们的需求是:

  1. 可以模拟用户的点击输入操作,事件驱动来验证页面渲染是否符合预期
  2. 可以使用命令行跑测试,可以集成到 CI
  3. 轻量高效,环境易搭建,测试代码易编写(毕竟是作为对敏捷开发,持续集成的环节补充,并不是 QA 环节的测试,不应舍本逐末)

想到这里不难发现,前端业务项目里最需要的是 E2E 测试,但是在如题图的测试金字塔所示,E2E 测试在金字塔顶端,执行 E2E 测试成本高又速度慢。因此 Cypress 应运而生,Cypress 提供了完备的解决方案,从测试金字塔顶端的 E2E 到集成测试再到单元测试都实现。

Cypress

Cypress 是在 Mocha API 的基础上开发的一套开箱即用的 E2E 测试框架,并不依赖前端框架,也无需其他测试工具库,配置简单,并且提供了强大的 GUI 图形工具,可以自动截图录屏,实现时空旅行并在测试流程中 Debug 等等。

总结一下,Cypress 的优点有:

  1. 配置简单,可快速集成到现有项目中
  2. 支持所有等级的测试(即前面所提到的 e2e 测试,集成测试,单元测试等)
  3. 可以给每一步测试都生成快照,易于 Debug
  4. 可以获取、操作 Web 页面里的所有 DOM 节点
  5. 自动重试功能,Cypress 会在当前节点重试几次再断定测试失败
  6. 易于集成到 CI 系统中

与其它类似测试工具如 Selenium、Puppeteer、Nightwatch 相比,Cypress 的测试代码语法更简单,并且在保证了框架的轻量高效的前提下,对前端工程师更友好。

cypress-gui

简单介绍一下使用方法(具体可以参照官网引导):

安装:

1
yarn add cypress --dev

添加到项目的 npm 脚本中:

1
2
3
4
5
{
"scripts": {
"cypress:open": "cypress open"
}
}

根目录里配置 cypress.json

1
2
3
4
5
{
"baseUrl": "http://localhost:8080", // 本地启动的 webpack-dev-server 地址
"viewportHeight": 800, // 测试环境的页面视口高度
"viewportWidth": 1280 // 测试环境的页面视口宽度
}
1
npm run cypress:open

这就已经在本地打开了测试 GUI,可以进行测试了。

用官方文档的一个例子说明一下测试代码怎么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
describe('My First Test', function() {
it('Gets, types and asserts', function() {
cy.visit('https://example.cypress.io')
cy.contains('type').click()
// Should be on a new URL which includes '/commands/actions'
cy.url().should('include', '/commands/actions')
// Get an input, type into it and verify that the value has been updated
cy.get('.action-email')
.should('have.value', '[email protected]')
})
})

这其实已经测试了:

  1. 打开目标页面,这里是示例的 https://example.cypress.io, 实际上在项目里应是本地启动的 server 页面如 http://localhost:8080
  2. 找到页面的 dom 里文字内容为 ‘type’ 的按钮,点击(如果页面里并没有渲染这个按钮即测试没跑通)
  3. 按钮点击后,页面应跳转到了路由中包含 ‘/commands/actions’ 的页面
  4. 在此页面的 dom 里可以找到 class 类名为 ‘.action-email’ 的 input 框,在里面输入 ‘[email protected]’ 后,输入框的 value 值应该为 ‘[email protected]

测试代码语义化比较好,代码量不多,也不需要写很多 async 逻辑。

到这里为止体验了一下安装配置,本地测试,感觉还可以,功能丰富,上手比较简单,集成到项目里也不麻烦。

测试覆盖率与持续集成(Gitlab 为例)

凡是没有集成到 CI 里的测试都只是玩具,并不能算数。所以我们来看看 Cypress 这块的表现吧。

我们希望 Cypress 可以通过配置,在开发的不同阶段执行不同的测试命令。比如在发起 PR 到 feature 分支时可以在当前分支执行集成测试,到 master 主分支时还需计算测试覆盖率并将数据上报到 Sonar 等质检平台(还可以设置测试覆盖率不满足xx%的话则测试失败等等)。

因此我们先看看测试覆盖率要怎么计算。Cypress 的测试覆盖率计算貌似是后来才添加上的功能,配置稍有点复杂。

依然还是具体说明可以参照文档,博客中只是简单介绍一下:

首先安装依赖:

1
npm install -D @cypress/code-coverage nyc istanbul-lib-coverage

再配置一下 Cypress 中的配置:

1
2
3
4
5
6
7
// cypress/support/index.js
import '@cypress/code-coverage/support'
// cypress/plugins/index.js
module.exports = (on, config) => {
on('task', require('@cypress/code-coverage/task'))
}

文档只介绍到这里,如果项目用了 TypeScript 的话这就还远远不够,翻了一下官方的 github 示例才发现还需要几个步骤:

1
npm i -D babel-plugin-istanbul

设置一下 .babelrc

1
2
3
{
"plugins": ["istanbul"]
}

再修改一下 cypress/plugins/index.js

1
2
3
4
5
// cypress/plugins/index.js
module.exports = (on, config) => {
on('task', require('@cypress/code-coverage/task'))
on('file:preprocessor', require('@cypress/code-coverage/use-babelrc'))
}
1
2
3
"cy:run": "cypress run && npm run test:report",
"instrument": "nyc instrument --compact=false client instrumented",
"test:report": "npm run instrument && npx nyc report --reporter=text-summary",

通过 cypress run 可以直接在命令行跑测试,不启动 GUI,在 CI 里使用的话就该用这个命令。

看看结果,真是快快乐乐。

coverage-result

Cypress 的 E2E 测试的覆盖率也可以和单元测试,或是通过其它框架 Jest 等的测试覆盖率进行合并,具体方法可以去官网查找。

下面我们来以 Gitlab CI runner 为例来看一下 Cypress 怎么集成到 CI:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// .gitlab-ci.yml
variables:
npm_config_cache: "$CI_PROJECT_DIR/.npm"
CYPRESS_CACHE_FOLDER: "$CI_PROJECT_DIR/cache/Cypress"
stages:
- test
- sonar
cache:
paths:
- .npm
- node_modules/
- cache/Cypress
build:
stage: sonar
tags:
- docker
script:
- yarn
- sh ci/sonar.sh
- yarn build
artifacts:
expire_in: 7 day
paths:
- codeclimate.json
- build
cypress-e2e-local:
image: cypress/base:10
tags:
- docker
stage: test
script:
- unset NODE_OPTIONS
- yarn
- $(npm bin)/cypress cache path
# show all installed versions of Cypress binary
- $(npm bin)/cypress install
- $(npm bin)/cypress cache list
- $(npm bin)/cypress verify
- npm run test
artifacts:
expire_in: 1 week
when: always
paths:
- coverage/lcov.info
- cypress/screenshots
- cypress/videos

在这里我们设置了两个 CI 阶段,test 与 build(与 Sonar 扫描,数据上报等),在 test 阶段中使用了 Cypress 的官方镜像 cypress/base:10。(其它环境变量设置和依赖如 Sonar 扫描,yarn 等都在我们自己的 Docker 镜像中)

其中 CI 所执行的命令 npm run test 是:

1
"test": "start-server-and-test start http://localhost:5000 cy:run"

在这里为了简化命令,使用了 npm 包 start-server-and-test 来实现待本地 Server 启动之后再执行测试这一逻辑。

我们也在 .gitlab-ci.yml 中设置了 artifacts:

1
2
3
4
5
6
7
artifacts:
expire_in: 1 week
when: always
paths:
- coverage/lcov.info
- cypress/screenshots
- cypress/videos

这是 Gitlab 的 job artifacts 功能,可以设置在某一步骤完成之后将特定文件夹的内容上传到服务器,在有效时间内,我们可以在网页端查看或下载这些文件内容。这样如果在 CI 测试失败的话我们就可以在 artifacts 中查看其测试失败视频和快照,避免盲猜式 Debug。

artifacts

在 CI 设置和测试用例管理中可以深挖的点还有很多。比如将测试用例分为冒烟测试,全量测试,或者 Client 端测试,Node 层测试等等。

Cypress 可应用的测试场景也更多,比如通过设置 Cookie 实现不同权限用户的测试,引入 Chance.js 实现随机点击 Tab 进行不同选项卡的测试,Mock 接口返回值等等。

https://glebbahmutov.com/blog/ 是 Cypress 的主要维护者的博客,其中也记录了很多骚操作(比如检查网页对比度是否满足条件等等),如有兴趣,可以继续进行挖掘。

一个坑点

在我的实践中发现的一个坑点是 Cypress 缺少对于 fetch 请求的支持,它无法捕捉或者 mock 请求,只能通过一个有点脏的方法来 hack 解决。

在项目中引入whatwg-fetch,再修改 cypress/support/command.js:

1
2
3
4
5
6
7
8
9
10
11
// cypress/support/command.js
Cypress.Commands.add('visitWithDelWinFetch', (path, opts = {}) => {
cy.visit(
path,
Object.assign(opts, {
onBeforeLoad(win) {
delete win.fetch;
},
})
);
});

这样我们就可以测试我们项目的登录重定向判断了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
describe('Node server', function() {
it('no cookie get 401', function() {
cy.server()
cy.clearCookies()
cy.route('POST', '**/graphql').as('login')
cy.visitWithDelWinFetch('/');
cy.wait('@login').then((xhr) => {
expect(xhr.status).to.eq(401)
})
})
it('with cookie get 200', function() {
cy.server()
cy.route('POST', '**/graphql').as('loginWithCookie')
cy.visitWithCookie('/');
cy.wait('@loginWithCookie').then((xhr) => {
expect(xhr.status).to.eq(200)
})
// login successfully, so display the content
cy.get('.ant-layout-sider')
cy.get('.ant-layout-content')
})
})

aha~

sonar