【万字长文】🍉大型 ToB 项目的前端自动化测试实践

2,848 阅读33分钟

前言

测试是工程化的研发体系中不可或缺的一环。

本文将从单元测试和 E2E 测试出发,谈谈笔者所在团队最近所做的大型 ToB 项目的前端自动化测试实践

前端需要自动化测试吗

前端领域的自动化测试往往是最容易被忽略的。

很多人会觉得编写测试用例的成本很高,或者觉得自动化测试的意义不大

无论哪一种原因,本质来说,是因为大家觉得 ROI(投资回报率)很低。毕竟,对于商业公司,只有 ROI 高的事情,才会被采纳。

既然要 ROI 高,那么我们先来看看自动化测试的收益支出

收益和支出

收益支出
手动测试的成本减少。显而易见,自动化测试用例必然可以取代大部分的手动测试。初始成本。引入自动化测试框架,首次适配项目的开发成本。
整体的测试速度更快。自动化测试比手动测试可以更快地测试整个应用程序,缩短需求的迭代的周期,提升效率。新用例的编写成本。每次增加一个新的测试用例,都需要编写的成本。
测试的覆盖范围更大。每次新功能上线,自动化测试用例可以保证所有存量功能都被覆盖到。脆弱性。如果项目处在初创期,迭代频率高,或者用例编写不合理,自动化测试用例可能存在脆弱性(flaky),反而降低测试的效率。
误差和遗漏更少。如同机器流水线,不会发生人工的失误和错误。
用例可重复使用。自动化测试用例,可以在项目多个迭代的多个生命周期里重复使用,且保证测试逻辑的高度一致性。

如何选择

收益 > 支出的时候,引入前端自动化测试是有必要的。

下面我们看个具体的🌰。假设我们正在开发一个表单。

Puppeteer

如上图,最初的版本,这个表单只有5个字段,不同字段之间的联动关系比较简单。因此所有的手动测试可以在半小时内完成。

随着功能的迭代,增加到了20个字段,字段之间的联动也变得十分复杂。在这时候完成手动测试验证的时间相比之前,可能是指数级的增长,并且极可能无法完成所有字段的测试。

而如果这时候有自动化测试,对于存量的功能,我们直接运行测试用例即可;而新增的功能,手动验证并编写新的测试用例即可。

这种情况下,数个用例编写的成本必然是远远低于大量功能的手动验证,在这时候引入自动化测试便顺理成章。

因此,如果我们能在恰当的时间点,引入自动化测试,这不仅可以提升代码质量和信心,也可以节省大量人力成本

前端测试的类型

众所周知,软件工程里的测试主要分为:单元测试集成测试端到端测试(E2E 测试)。

这里主要讲一下前端领域里的这三种测试。

  • 单元测试:主要是对项目里的最小组件或模块进行测试,一般是在非浏览器环境下的测试。

  • 集成测试:一般是对单元测试下的多个最小单元组成的较大组件或模块进行小型的组合测试,一般是在非浏览器环境下的测试。

  • E2E 测试:从用户的视角出发,基于整个页面或者应用,模拟用户操作测试,一般是在浏览器环境下的测试。

对于前端而言,单元测试和集成测试之间的区别可能很难区分。正如React官网所言:

对组件来说,“单元测试”和“集成测试”之间的差别可能会很模糊。如果你在测试一个表单,用例是否应该也测试表单里的按钮呢?一个按钮组件又需不需要有他自己的测试套件?重构按钮组件是否应该影响表单的测试用例?不同的团队或产品可能会得出不同的答案。

因此对前端而论,如果一定要分为三种类型的测试,会过于繁琐,同时也没要必要性。因此在我们团队的实践里,主要做两个类型的测试:单元测试,E2E 测试。这里的单元测试,可以认为包括集成测试。

单元测试和 E2E 测试的实现原理

正所谓,知其然还要知其所以然。 因此,在使用前,我们先初步来了解下前端的单元测试和 E2E 测试的实现原理。

单元测试

普通函数

单元测试的框架,一般可以由两部分组成:

  • 测试执行器(test runner)
  • 断言库(assertion)

以我们常用的 Jest 为例,Jest 本身就是个大而全的测试框架,除了它自研的 test runner,默认引入了 expect 作为断言库。

Jest 的 test runner 的原理,简单来说,它基于 Node.js 语言为测试用例的函数提供了一个运行环境,然后通过 expect 断言库对测试用例的运行结果和预期进行判断。

Node.js 环境下,对于不涉及 DOM 的测试用例,相当于就是执行普通的 JS 函数。对于涉及 DOM 的测试用例,就比较复杂。

JSDOM

Node.js 环境和浏览器环境最大区别就是,前者不支持 DOM 及相关 Web 的 API。因此诞生了 JSDOM, 它通过纯 JS 在 Node.js 环境,实现了一系列 web 标准,其中就包括最重要的 DOM。

我们通过 JSDOM 官网的一个例子来看下 JSDOM 如何在 Node.js 环境下支持 DOM:

const jsdom = require("jsdom");
const { JSDOM } = jsdom;
​
const dom = new JSDOM("<!DOCTYPE html><p>jsdom test</p>");
console.log(dom.window.document.querySelector("p").textContent); // "jsdom test"

JSDOM 会像浏览器一样解析传入的 HTML 字符串,创建 JSDOM 实例。该实例挂着许多有用的属性,特别是window。如上面例子,我们从 dom 这个 JSDOM 实例,查询到了声明的 p 标签的 textContent

除此之外,JSDOM 生成的 window 对象下还实现了如 localStoragesessionStorage,等其他 API。

Jest 使用 JSDOM 模拟支持了浏览器环境,使得在 Node.js 环境下,能执行包含 DOM 的单元测试的测试用例。

E2E 测试

我们先来看一些基础的概念。

Chrome DevTool Protocol

Chrome DevTool ProtocolCDP)允许使用工具来检测、检查、调试和分析 ChromiumChrome 和其他基于 Blink 的浏览器。CDP 基于 WebSocket,使用 WebSocket 协议实现与浏览器内核的快速数据通信

Chrome 开发者工具就是基于 CDP 实现的。

Headless Browser

Headless Browser无头浏览器)通过命令行或使用网络通信,实现在无界面的环境下对浏览器网页的自动控制。Headless Browser 不需要人为的干预,相比有界面的浏览器,运行更加稳定。

E2E 测试框架

目前当前主流的前端 E2E 测试框架主要有:PlaywrightPuppeteerCypressSelenium

从实现原理角度看,可以分为两大类:PlaywrightPuppeteerSelenium(>=2) 和 Cypress

Playwright,Puppeteer,Selenium(>=2)

从本质上看,这三个框架都是基于浏览器的 Devtool Protocol 来实现的。

我们这里先以 Puppeteer 为例。

Puppeteer

如上图,Puppeteer 通过 CDP 协议来控制 Chromium/Chrome 浏览器CDP 分为多个域(DOMConsoleNetworkProfiler等),CDP 的各个域中都有对应的事件和命令。

Puppeteer 基于 CDP,每次启动都会创建一个浏览器(支持 Headless Browser 或者有界面的)实例。而这个浏览器实例的内部,它支持了我们平时常用的浏览器的操作,包括:创建新 tab,操作 DOM ,通过 devtool 来监听网络请求和响应等等。

这些操作可以支持我们进行 E2E 测试的多种行为:

  1. 模拟用户来进行操作 DOM

  2. 监听 API 的请求和响应

  3. 模拟用户点击链接跳转到新的 Tab

    ...

Playwright 是微软新开发的一个更强大的类似 PuppeteerE2E 测试框架。

它和 Puppeteer 的最大区别在于,Playwright 支持了多种编程语言的同时,兼容了多种浏览器,它实现的原理和 Puppeteer 基本类似,都是基于不同浏览器的 Devtool Protocol 完成数据通信,从而实现 E2E 测试用例的运行。

Selenium

如上图,我们可以看到 Selenium(>=2) 的原理和 Puppeteer 略有区别。Selenium 这里新出现了一个 WebDriverWebDriver 是基于 Devtool Protocol,实现了一系列的接口用于操作和控制浏览器中的 DOM 元素,几乎可以操作浏览器做任何事情。而 Selenium 是基于 WebDriver ProtocolWebDriver 进行数据通信,从而实现和浏览器的数据通信,完成 E2E 测试用例的运行。

Cypress

Cypress 的实现原理如图:

Cypress

CypressWebDriverCDP 的实现完全相反,它在和被测试的应用程序的相同的生命周期里来执行测试用例。

Cypress 的实现和 iframe 的实现很相似。当我们运行 Cypress 的测试用例后,Cypress 使用 Webpack 将测试代码中的所有模块 bundle 到一个 js 文件中。然后运行浏览器,将测试代码注入到一个新的空白页中,bundle的js将在浏览器中运行。

每次测试首次加载 Cypress 时,内部的 Cypress Web 应用程序先把自己托管在本地的一个随机端口上。

当测试用例执行 cy.visit() 命令后,Cypress 会更改本地 URL 以匹配所需要测试的 Origin ,使其符合同源策略,这使得测试代码和应用程序可以在相同的生命周期中运行。

技术选型

笔者使用的技术栈是基于 React 的,因此下述的实践是以 React 为例的,但其他的框架亦可参考。

单元测试

对于 React 技术栈的单元测试来说,一般必须要使用的是测试工具和 React 的测试类库。前者是单元测试用例运行的基础;后者是辅助库,可以辅助我们更高效简洁地写测试代码。

测试工具

经过初步筛选,我们最后在 MochaJest 当中选择。

框架定位上手成本是否需要额外配置React 友好程度
Mochatest runner需要额外配置断言库,代码覆盖率一般
Jesttest runner+assertion默认配置断言库,代码覆盖率等功能

通过比较发现,JestMocha 相比,是个大而全的单元测试框架,默认支持断言、支持代码覆盖率等,无需任何额外配置。

同时 Mocha 最初是为 Node.js 的单元测试而设计,而 Jest 最初就为 React 而诞生的,因此 JestReact 更友好,上手成本更低。

因此选择了我们团队选择了 Jest 作为单元测试的框架。

React 测试类库

目前社区主流的 React 测试类库主要有:Enzyme 和 React Testing Library(简称 RTL)。

我们来看看这两者的核心区别。

Enzyme 主流测试理念是使用 propsstate 来测试 React 组件。当组件功能没有发生变化时,仅仅某个 propsstate 的变量名发生了改变,那么之前通过的单元测试就将失败。这种单元测试是脆弱(flaky)的。

当然,Enzyme 也提供使用 DOM 来测试,但这并不是 Enzyme 的主流,支持性一般。

RTL 中,我们从用户的角度出发来编写测试用例。

比如测试一个文本输入组件,不会来测试组件的 propsstate ,而是通过与用户交互的 DOM 元素来编写单元测试。RTL 的单元测试并不关心组件内部发生的事情,只关心与组件发生的交互(用户维度的输入和输出)。此时,我们的单元测试将是具有韧性(not flaky)的。

显而易见,RTL是更好的选择。

E2E 测试

要点

E2E 测试框架,测试的整体流程都大同小异。因此我们主要关注如下要点:

  • 强大的 CSS 选择器。CSS 选择器是 E2E 测试的痛点之一,如何编写可维护和稳定的 CSS 选择器是个重要的课题。

  • 智能的等待机制。E2E 测试里,很多时候需要有等待:a.CSS 选择器的等待(比如某些组件的渲染需要时间);b.网络请求的等待...用最短的时间实现最准确的等待,可以保证测试用例的韧性(not flaky)和测试用例的高效运行。

  • 多种断言类型的支持。除了普通的断言类型,E2E 测试里的截图断言的支持可以大大提升 E2E 测试用例编写的效率。

  • 多种浏览器事件的支持。E2E 测试是模拟用户真实行为,因此 click,input,hover,focus 等等的浏览器事件支持是必不可少的。

  • 并发和重试机制。E2E 测试用例运行时间相对比较久,支持并发运行可以提升效率;同时 E2E 测试相对单元测试比较脆弱,重试机制可以让其相对稳定

  • 支持浏览器多标签。E2E测试的主要作用之一就是覆盖单测无法覆盖的功能,而支持浏览器多 Tab 的功能这是在单测无法覆盖的,这可以支持我们来测试跳转到目标页面,同时测试链接跳转是否准确等。

  • 支持网络请求抓取。抓取网络请求可以满足以下功能:判断测试用例运行时,是否有异常的请求;获取网络请求创建的资源和页面上展示的资源是否相符。

  • 多浏览器支持

Playwright vs Puppeteer

PlaywrightPuppeteer 的进化版(Playwright 的初创开发团队是来自 Puppeteer 的前开发团队),除了支持更多的编程语言和兼容更多的浏览器之外,还提供了其他更强大的功能(比如自定义css选择器,更智能的等待等)。显而易见,我们这里先淘汰puppeteer。

Playwright vs Cypress

Cypress 这里有致命的缺陷,就是不支持浏览器多标签,因此我们直接淘汰了 Cypress

Playwright vs Selenium

PlaywrightSelenium 对于我们上面提出的要求都有基本的支持。但是在某些方面,Playwright 表现得更优秀。

选择器方面,Playwright 支持自定义 Css 选择器,更智能的选择器等待机制,这可以让我们写出更稳定的测试用例。

我们来看个 Playwright 官方文档的click事件智能等待说明:

对于page.click(selector[, options]),Playwright 将确保 selector 对应的元素:

元素是在挂载 DOM 上的

元素是可见的

元素是稳定的,比如不在动画加载状态

元素能响应浏览器事件,没有被其他元素遮挡

元素是enabled的

Playwright 在执行浏览器事件操作之前对 selector 对应的元素执行一系列如上的可操作性检查,以确保这些操作按预期运行。它会自动等待所有相关检查通过,然后才执行相关的操作。

截图断言方面,Playwright 支持天然的截图断言,而 Selenium 还需要自己额外支持。

综上,我们选择了 Playwright 作为我们的 E2E 测试框架。

单元测试最佳实践

我们使用的前端框架是 React,测试框架是JestReact-testing-library

AAA理论

无论我们使用哪种测试框架,软件工程的测试一般都遵循 AAA 的理论,了解 AAA 理论基本就可以写出一个正确的单元测试。

AAA即为:Arrange(准备),Act(操作),Assert(断言)。

准备:这一步是进行必要的单元测试的提前设置。例如,进行某个 UI 组件的单元测试,我们可能需要 mock 某些涉及的 API 请求。

操作:这一步是执行测试的操作。例如,在渲染后的 UI 组件中,进行选择框的选择操作等。

断言:这一步,我们将检查并验证返回的结果与预期结果是否相符,输出测试结果。

实战🌰

创建测试组件

我们通过实战例子,来看一下如何使用单元测试的 AAA 理论。

我们先创建一个 React 组件。

import React, { useState } from 'react';
import {TestComponent} from './Component'export function Example() {
  const [count, setCount] = useState(0);
​
  return (
    <div>
      <p data-testid="test-display">{count}</p>
      <button data-testid="test-button" onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <TestComponent/>
    </div>
  );
}
​

接下来,我们就将按照 AAA 理论来编写 Example 组件的单元测试。

Example 组件的逻辑比较简单。我们需要测试它的渲染是否正常,以及测试 button(按钮)的操作是否正常。

准备

这个阶段的行为包括:

初始化渲染要测试的 UI 组件,实例化对象,mock API 请求,mock 部分组件或模块。

我们对 Example 的单元测试来进行准备

  1. 假设 TestComponent 组件此处进行了异步请求,因此我们要将这个组件进行 mock。

    jest.mock('./Component', () => {TestComponent:'test component'});
    

    一般来说,非必要不进行 mock。只有影响了当前单测,或者不进行 mock 的开发成本很高,我们才进行 mock。

  2. 使用 RTLrender 方法执行 UI 组件的渲染。

    import {render} from '@testing-library/react';
    ​
    render(<Example />); 
    

至此,完成了 Example 组件测试的准备工作。

操作

这个阶段的行为包括:

执行对象实例的方法,或者进行某些 DOM 事件的操作等。

我们对 Example 的单元测试来进行操作

使用 RTLfireEvent.click 方法执行 button 的点击操作。

import {fireEvent, screen} from '@testing-library/react';
​
fireEvent.click(screen.getByTestId('test-button'))

断言

这个阶段,是比较测试用例的返回结果和预期的结果。

这里我们一共写两个测试用例。第一个是组件的初始化渲染正常,第二个是组件的按钮的 点击操作正常以及操作的后展示正常。

用例1断言

判断组件内部渲染正常,初始 count 变量的展示渲染是否正确。

expect(screen.getByTestId('test-display')).toBeTruthy();
expect(screen.getByTestId('test-button')).toBeTruthy();
expect(screen.getByTestId('test-display')).toHaveTextContent('0');
用例2断言

判断点击按钮之后,count 的展示渲染是否正常。

expect(screen.getByTestId('test-display')).toHaveTextContent('1');

完整的单元测试用例

import {fireEvent, render, screen, cleanup} from '@testing-library/react';
import React from 'react';
import {Example} from './example';
​
describe('Example component test', () => {
  const setUp = () => render(<Example />);
​
  beforeEach(() => setUp());
  afterEach(() => cleanup());
​
  it('should render component without error', () => {
    expect(screen.getByTestId('test-display')).toBeTruthy();
    expect(screen.getByTestId('test-button')).toBeTruthy();
    expect(screen.getByTestId('test-display')).toHaveTextContent('0');
  });
​
  it('should correctly display count after clicking the button', () => {
    fireEvent.click(screen.getByTestId('test-button'));
​
    expect(screen.getByTestId('test-display')).toHaveTextContent('1');
  });
});

在完整的例子里,我们对准备部分的代码进行了复用,统一抽象为 setUp 函数,同时使用 JestbeforeEach 函数确保每个用例执行前执行 SetUp 函数。

至此,对于在 React 组件里,如何根据 AAA 理论完成一个完整的单测,大家应该有了基本的概念。

单元测试原则

在初步了解了如何编写一个完整的单元测试之后,接下来我们讲重点讲述单元测试里的一些重要原则。

测试行为还是测试实现

我们首先来看一个 Enzyme 的例子,我们的测试的组件还是上面的 Example 组件。

const wrapper = shallow(<Example />);
expect(wrapper.state().count).to.equal(0);

正如我们上面提到的,Enzyme 测的是 React 组件的 state 或者 props。这里当组件内部的 state.count 命名由于某种原因发生变化改为 state.counter 时,上面的这个测试用例就无法执行通过了。但如果我们使用 React-testing-library 来测试的话,内部 state 重命名不会影响我们存量的测试用例,因此用例依旧通过。

我们回到问题:测试行为还是测试实现

如果单元测试是测试实现,会有下面的问题:

1.当代码发生迭代或者重构的时候,原有的单元测试用例可能受影响,但实际上我们的组件在使用时上依旧是符合我们预期的。举个🌰:React 组件中去测试 state,当 state 的命名发生变化的场景。

2.当代码的行为和我们预期不相符的时候,但原有的测试用例依旧通过。举个🌰:某个 React 组件只测试 stateprops 的变化,没有测试到 UI 组件和用户交互的行为,当交互行为发生改变时,原有的测试用例依旧通过。

因此,前端的单元测试对于使用者来说,我们只需关注它的使用的行为。内部无论怎么实现,无论它用了什么魔法,都和单测用例没有直接的关系。

声明式的代码

我们来看个🌰(伪代码)。doSomthing 函数是我们的被测函数。

it('doSomething test', () => {
  const testInput = {
    a: 13,
    b: 21,
    c: 56,
  };
  const result = doSomething(testInput.a, testInput.b, testInput.c);
  expect(result).toEqual(testInput.a + testInput.b - testInput.c);
});

如上述代码,当我们的测试用例没通过时,你要怎么区分是 doSomthing 函数有问题,还是 testInput.a + testInput.b - testInput.c 这部分测试用例的代码的计算有问题呢 ?

我们把它改成一个的测试用例。用声明式的代码来编写测试用例。

it('doSomething test', () => {
  const testInput = {
    a: 13,
    b: 21,
    c: 56,
  };
  const result = doSomething(testInput.a, testInput.b, testInput.c);
  expect(result).toEqual(51);
});

当我们去掉了测试代码内部的复杂计算之后,使用声明式的代码。如果测试用例没通过,我们显然可以确认 doSomthing 的问题,而不用再去分析我们的测试代码是否有问题。

因此,单测代码减少不必要复杂的计算,尽量使用声明式的代码,这可以帮助我们快速定位问题。

坚韧不脆弱

当业务代码对应的逻辑未曾变化时,对应的单元测试应该每次都尽量能通过。

脆弱的单元测试会增加成本,反而让测试用例影响我们项目的迭代,成为累赘。

快速

单元测试是一种白盒测试,一般是由开发人员在编码阶段完成,从而尽早尽量小的范围内暴露代码的问题。

因此很多时候单元测试需要即时执行,我们选择的测试框架的执行速度一定要快。

E2E 测试最佳实践

测试理念

前端的 E2E 测试侧重于模拟用户行为的测试,应该包含用户使用某个网站或者某个应用时,进行某次操作的整体流程。E2E 测试的主要目的是模拟真实的用户场景,验证系统的集成和数据的完整性。

E2E 测试的测试环境应该接近或者等于生产环境,包括使用的 API 和数据库等。

但是,在某些情况下某些 API 服务可能不可用,或者某些真实的 API 服务无法覆盖所有测试场景。在这种情况下,可以进行 mock API 操作。这种尽量应该避免,因为模拟过多的测试会增加维护成本和降低测试的信心。

基本流程

通过一个🌰,我们来看下如何编写一个创建资源的E2E测试用例。(Playwright 为例)

test('e2e test', async ({page}) => {
  // 预设的准备,创建非当前页面关联的资源
  await CreateOtherResources();
​
  // 跳转到被测试的页面
  await page.goto('https://example.com');
​
  // 打开创建的弹窗
  await page.click('#create-btn');
​
  // 断言:进行创建的弹窗的截图断言
  expect(await page.locator('#create-dialog')?.screenshot()).toMatchSnapshot('test.png');
​
  // 输入创建资源的名称
  await page.fill('.form__item:has(:has-text("名称")) input', 'e2e-test-123');
​
  // 点击创建资源的类型的下拉框
  await page.click('.form__item:has(:has-text("类型")) dropdown');
​
  // 选择创建资源的类型
  await page.click('#create-dialog >> text="类型1"');
​
  // 点击【提交】,发起创建的请求
  await page.click('#submit-btn');
​
  // 等待创建资源的请求完成
  await page.waitForResponse((response) => response.url().includes('create-test') && response.status() === 200);
​
  // 断言:判断当前的页面上是否有该资源
  expect(await page.locator('.list-item:nth-child(1) .name').textContent()).toEqual('e2e-test-123');
​
  // 清理所有资源
  await ClearAllResources();
});

本质上,E2E 测试也是遵循 AAA 理论的。

准备

E2E 测试里的准备,主要有:

1.非当前被测试的页面有关的一些预设请求。比如 ToB 业务里,创建当前页面需要关联用到的资源,示例代码:

// 预设的准备,创建非当前页面关联的资源
await CreateOtherResources();

2.跳转到被测试的页面,示例代码

// 跳转到被测试的页面
await page.goto('https://example.com');

3.请求异常/ js 异常的监听。示例代码

// 请求出错,断言不通过
page.on('response', async (response) => {
  const body = await response?.json();
  expect(body.error).toBeFalsy();
});

操作

E2E 测试里面的操作,一般都是一个完整的行为

上面的例子,该用例的操作包括:

  1. 打开创建弹窗

  2. 创建资源的名称

  3. 点击类型下拉框,选择创建资源的类型

  4. 点击【提交】,完成创建资源

可以看出,E2E 测试的操作,比起单元测试,远离代码本身,更加接近用户行为。

断言

E2E 测试的断言,除了和单元测试一样的普通逻辑断言之外,还包括截图断言

  1. E2E 测试的普通断言和单元测试的大同小异,只是 API 的区别。
// 断言:判断页面上是否有该资源
expect(await page.locator('.list-item:nth-child(1) .name').textContent()).toEqual('e2e-test-123');
  1. 截图断言相比普通断言,它具有独特的优势:
  • 截图断言里包含测试项的范围更大,能覆盖更广的测试场景;
  • 一图胜千言,截图断言的判断形式更加直观和简洁
  • 可以判断页面的 CSS 样式是否符合预期;
// 断言:进行弹窗截图断言
expect(await page.locator('#create-dialog')?.screenshot()).toMatchSnapshot('test1.png');

特别注意,截图断言需要初始化生成一次基线图片。基线图片后续将作为标准,测试用例的运行都将以基线图片为基准。

E2E 测试的痛点

稳定性和可维护性

E2E 测试用例,很多时候都是不稳定和不可维护的。比如,某些页面的 DOM 结构出现变化,就要大规模的修改,甚至重写;某些后台 API 不稳定,导致用例失败,因此提升可稳定性和可维护性,是 E2E 测试的重点课题。我们从多个方面出发,提升 E2E 用例的稳定性和可维护性。这里由于篇幅有限,我们选取几个重点的来讲。

可维护的CSS选择器
  1. 业务代码要尽量避免使用手写HTML标签,使用公共组件,保证 DOM 结构的稳定,从而保证 CSS选择器 的稳定。

  2. 在某些场景减少使用 nth-child(n) 类似的结构不稳定的 CSS 选择器。我们来看个🌰:

    <div class='form'>
      <div class='form-item'>
        名称
        <div class='form-control'>
          <input />
        </div>
      </div>
      <div class='form-item'>
        备注
        <div class='form-control'>
          <input />
        </div>
      </div>
    </div>
    

    此时,我们要获取名称对应的 input 组件的 CSS 选择器。常规的写法即为: 

    .form > .form-item:nth-child(1) > .form-control > input
    

    这种本身不能说有错误,但是这个 CSS 选择器的可维护性就很差。比如,后续我们在名称前插入了一个新的 formItem,那当前这个名称 input 对应的选择器就出错了,需要修改原来的测试用例。

    而如果改成:

     
    // 基于playwright语法
    .form >> .form__item:has(:has-text("名称")) >> .form-control > input
    

    这种写法是基于 formItem 的 label text 来获取当前选择器,这种方式获取的 CSS 选择器就很稳定。一般的,一个表单里的 label text 都是独一无二的,因此即便我们在名称的 formItem 前面插入新的 formItem,也不用修改此处的 CSS 选择器。

处理测试中的噪音

噪音处理是 E2E 测试的重点之一。何为 E2E 测试的噪音?

E2E 测试的噪音就是测试的不稳定因素。噪音会导致测试用例运行不稳定,时而通过,时而失败。

下面我们来看两种常见的噪音:

  1. 不稳定的页面 UI。比如类似常见的轮播图组件,每隔几秒页面的内容都会发生变换。

    如果这个轮播图组件包含在测试用例的截图断言里,那么这个轮播图就是噪音元素,我们需要去除这个噪音。 去除 UI 噪音的方法则多种多样:

    a. 隐藏该轮播图组件

    await page.$eval('.carousel', (r) => { r.style.visibility = 'hidden'; });
    

    b.进行截图断言时,使用局部截图的形式,不把这个噪音元素包含进去。

  2. 不稳定的 API。比如某个查询资源的 API 每过一段时间返回的结果都不稳定,导致断言经常失败。

    同样的,这个 API 也是噪音。这时,我们可以采取 mock 该 API 的方法,来排除噪音。

运行速度

E2E 测试由于访问的是真实的浏览器环境,用例运行的速度相比单元测试会偏慢。但很多时候,我们需要尽快知道E2E 测试的结果。比如现网发布后,回归测试用例的运行,发布人员需要尽快知道是否所有回归的用例都通过了。

这里我们从如下几个方面来提升 E2E 测试的运行速度。

智能的等待机制

智能的等待机制,可以最大程度地压缩用例运行时间。

选择 Palywright 的一大原因,就是 Playwright 智能的等待机制:

  1. CSS 选择器的智能等待机制。

    比如我们渲染某个元素需要一定的时间,那么测试用例使用这个元素的选择器就需要等待。

    之前一些框架的方案是用 setTimeout/setInterval,这些方法既不优雅,又可能需要消耗额外不必要的时间。

    Playwright 则提供了智能的等待机制。上面我们已经提到,Playwright 在执行操作之前对元素执行一系列可操作性的检查(visible,enabled 等),以确保这些操作按预期运行。它会自动等待所有相关检查通过,然后才执行请求的操作。

  2. API 请求的智能等待机制。

    同样的,很多时候需要等待某个 API 请求完成,才会继续进行后续的测试。

    Playwright 则提供了waitForResponse 等 API 可以用来智能地等待 API 请求完成,而不需要我们自己去轮询 API 请求,减少了轮询等待时间。

不要过度地拆分用例

E2E 测试和单元测试不一样。E2E 测试强调的是行为的完整性,而不是单元测试用例的独立性。

所以,在编写 E2E 测试用例的时候,我们不需要过度地拆解测试用例。

适中大小的测试用例,可以减少重复 API 请求的时间和页面的渲染时间,从而减少 E2E 测试的整体运行时间。

举个🌰,我们可以将几个简单的页面操作放在一个测试用例里,比如某种资源修改属性A和修改属性B可以放在一个用例里。

并发运行

选取支持并发运行的E2E测试框架。

当然,并发运行对编写的测试用例会有要求。要考虑测试用例并发运行时,一个用例是否会影响另外一个同时运行的用例。要避免用例之间的相互影响。

简单举个🌰,在 A 用例里,有对 a 资源的操作;如果在 B 用例,有删除a资源的操作。那如果 A,B 用例同时运行,B 用例很可能导致A 用例无法运行通过。

用例编写速度

E2E 测试用例的编写速度,因为是从用户操作的角度来操作。相对单元测试来说,编写速度比较慢,需要花的时间相对多很多。

因此,目前社区里有很多用来自动录制 E2E 测试用例的解决方案。

自动化录制解决方案

目前主流的一些自动化 E2E 测试用例的录制方案主要有:Chrome 插件 DeploySentinel RecorderChrome 插件 Headless RecorderPlaywrighg 官方的 CLI Playwright CLI Codegen

下面是参考 DeploySentinel Recorder 的三种方案的对比图,经过笔者仔细比较,这图还算靠谱(不是DeploySentinel Recorder 的自吹自擂哈哈)!

| | DeploySentinel Recorder | Headless Recorder | Playwright CLI Codegen | | ------------------------------ | ----------------------- | ----------------- ---------------------- | | 自动捕获 click 事件 | ✅ | ✅ | ✅ | | 自动捕获 input 事件 | ✅ | ⚠ | ✅
| 自动生成 CSS 选择器 | ✅ | ❌ | ✅ | | 自动生成 text CSS 选择器 | ⚠ | ❌ | ✅ | | 支持截图断言插入 | ✅ | ✅ | ❌ | | 支持插入 hover 事件 | ✅ | ❌ | ❌ | |stable 版本浏览器录制 | ✅ | ✅ | ❌ |

如上图,自动化录制脚本的核心能力主要为:浏览器事件的完善支持,CSS 选择器的支持,插入测试用例断言的支持。

DeploySentinel Recorder 在上述三个方案的比较中,对这几方面的支持都比较完善。

基于 DeploySentinel Recorder 的改造

当然,我们团队在使用 DeploySentinel Recorder 的时候,还是会发现它有一些不足之处。

我们基于它进行了一些改造,主要新增以下功能:

  1. 由于 DeploySentinel Recorder 插件本身的 CSS 选择器是通用版本的,因此它生成的 CSS 选择器就是上面我们提到的不可维护的选择器。我们根据自己的项目的代码特点,支持了稳定和可维护的 CSS 选择器,比如在表单去除了包含 nth-child(n) 的低稳定性的选择器,转而使用 label text定位的选择器;

  2. DeploySentinel Recorder 插件不支持局部元素的截图断言,我们进行了支持;

  3. DeploySentinel Recorder 插件不支持普通的逻辑断言,我们进行了支持;

  4. 同时也新增支持了直接在浏览器的页面上,可视化地去除噪音元素

通过一系列的改造之后,我们项目的一个 E2E 测试用例中 90% 以上的代码都可以通过我们的自动化录制插件来生成。

我们只需要对录制后的代码进行了简单改造,即可使其成为完善的 E2E 测试用例。通过使用自动化录制插件后,我们整体的 E2E 测试用例代码的编写速度大大提升。

如何平衡单元测试和 E2E 测试

看到这里,大家可能有个疑问:如果项目里既写单元测试,又写 E2E 测试,是不是很多用例都会重复,浪费时间?要怎么平衡两者使单元测试和 E2E 测试发挥最大作用?

单元测试的擅长点

  • 单元测试专注于独立测试某个组件或者模块。如果测试用例不通过,我们可以快速地识别导致用例失败的对应组件的代码模块。

  • 单元测试一般是模拟渲染,因此运行速度比较快。开发者可以边编码边编写单元测试用例,单元测试可以即时反馈当前的代码质量。

  • 单元测试由于测试范围比较小,基本可以测试到被测试组件的所有边界情况。

E2E 测试的擅长点

  • E2E 测试是以页面或者局部页面的维度来测试的,主要来测试不同模块之间的交互。如果测试用例不通过,我们可以快速识别模块之间的交互问题。

  • E2E 测试使用了真实的浏览器环境,接近用户的运行环境,可以保证用户的主要的 happy path极个别的 error path 正常,确保整个页面的主要流程按预期正常运行。

  • E2E 测试可以覆盖单元测试无法覆盖的点,比如链接跳转等。

实战例子

接下来我们来看一个具体的🌰,来真正了解开发过程中,如何平衡单元测试和 E2E 测试。

我们以某电商网站购买下单某个商品为例。

E2E 测试,主要用来测试用户的一些主要操作流程:

  1. 用户成功下单购买某商品;
  2. 用户购买某商品失败,并显示错误。

但是这些测试并不足以保证覆盖所有可能的场景。因为各个子模块或者组件里有更多的业务逻辑分支,这些可以使用单元测试进行测试:

  1. 用户输入的收获的手机号格式有问题的测试;
  2. 用户未在填写地址的表单中输入必填字段的测试;
  3. 用户使用多种方式支付的测试;
  4. 用户的支付余额不足的测试;

持续测试

持续测试是一个过程,它将自动化测试作为软件交付通道中内嵌的一部分,以尽快获得软件发布后业务风险的反馈。

那我们是怎么做前端自动化测试这部分的持续测试的呢?

我们来看一个比较简单版本的持续测试。

ci

一图胜千言。从图中可以看出,首先是单元测试的持续测试:

  1. 开发者在本地开发的时候,只有在本地运行的单元测试100% 通过后,才会提交到远端仓库;

  2. 当远端的分支代码合入 master 的时候,合入的流水线要求100% 的单元测试通过率,才能合入 master。

其次是 E2E 测试的持续测试:

  1. 变更代码部署到测试环境或者预发布环境后,我们会运行 E2E 测试,100% 通过后才会允许发布到现网;

  2. 发布现网后,会进行E2E测试的回归测试,及时发现发布潜在的问题。

小结

本文主要从是否需要自动化测试(单元测试,集成测试,E2E 测试),单元测试和 E2E 测试的原理,单元测试和 E2E 测试的技术选型,单元测试和 E2E 测试的最佳实践如何平衡单元测试和E2E测试等方面出发,讲述了如何在大型ToB前端项目上做自动化测试。

本文相对侧重理论,实战的代码部分比较少。后续大家如果对此话题有兴趣的话,可以结合实际的代码分享下实战经验。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿