为什么需要自动化测试?
一个多人合作,开发周期长的前端项目可能会出现以下问题:
- 代码风格各不相同
- 代码过度耦合,阅读和维护极其困难
- 新同学上手慢等等
为了解决这些问题,需要通过对核心组件进行自动化测试,保证组件职责单一,通过测试用例可以快速了解这个组件打算做什么。
通常自动化测试可分为单元测试和端到端测试,单元测试我们选择 vitest 来进行讲解,端到端测试则是playwright
学习准备
项目搭建:
npm i -g pnpm
pnpm create vite@latest vitest-demo
选择 react + ts 项目
cd vitest-demo
pnpm i
pnpm i vitest happy-dom @testing-library/react -D
// package.json
"scripts": {
// ...
"test": "vitest"
},
单元测试
通过断言告诉程序你的预期
expect 用于创建断言
toBe: 判断基础类型是否相等(相等判断符合Object.is)toEqual: 一般用于判断引用类型是否相等,断言实际值是否等于接收到的值,或者如果它是一个对象,则是否具有相同的结构。toThrowError: 断言函数在被调用时是否会抛出错误,如果我们在方法中,已经有了错误的捕获,那么断言本身是无法生效的not: 对断言取否,相当于不等于。toContain(value):判定某个值是否存在在数组中。arrayContaining(value):匹配接收到的数组,与 toEqual 结合使用可以用于判定某个数组是否是另一个数组的子集。toContainEqual(value):用于判定某个对象元素是否在数组中。toHaveLength(value):断言数组的长度 。toHaveProperty(value):断言对象中是否包含某个属性,针对多层级的对象可以通过 xx.yy 的方式进行传参断言。
// 示例函数
function sum(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Invalid arguments: both arguments should be numbers');
}
return a + b;
}
// 测试代码
describe('sum function', () => {
test('adds two numbers correctly', () => {
expect(sum(2, 3)).toBe(5); // 判断基础类型是否相等
});
test('throws an error for invalid arguments', () => {
expect(() => sum('2', 3)).toThrowError('Invalid arguments: both arguments should be numbers'); // 判断是否抛出错误
});
});
describe('array tests', () => {
const arr = [1, 2, 3, 4, 5];
test('array contains a specific value', () => {
expect(arr).toContain(3); // 判断数组是否包含特定值
});
test('array length is correct', () => {
expect(arr).toHaveLength(5); // 判断数组长度
});
test('array is a subset of another array', () => {
expect(arr).toEqual(expect.arrayContaining([2, 4])); // 判断数组是否是另一个数组的子集
});
});
describe('object tests', () => {
const obj = { name: 'John', age: 30 };
test('object has a specific property', () => {
expect(obj).toHaveProperty('name'); // 判断对象是否具有特定属性
expect(obj).toHaveProperty('age', 30); // 判断对象属性的值
});
});
除此之外还有自定义断言,我们可以自定义匹配器:
// ./src/__test__/expect.test.ts
import { test, expect } from "vitest"
test("同步自定义匹配器", () => {
const toBeBetweenZeroAndTen = (num: number) => {
if (num >= 0 && num <= 10) {
return {
message: () => "",
pass: true,
};
} else {
return {
message: () => "expected num to be a number between zero and ten",
pass: false,
};
}
};
// 挂载自定义匹配器
expect.extend({
toBeBetweenZeroAndTen,
});
expect(8).toBeBetweenZeroAndTen();
expect(11).not.toBeBetweenZeroAndTen();
});
异步方法如何进行单测?
- 通过
async/await拿到异步函数值后再进行测试 - 借助 Vitest 提供的
resolves和rejects匹配器来进行异步逻辑的断言
通过 Fake Timers API 快进定时任务
Vitest 提供了一组 Fake Timers API 来跳过定时的等待时长。
- useFakeTimers 启用假定时器
- useRealTimers 启用真定时器
- runAllTimers 运行所有定时器
- runOnlyPendingTimers 只运行等待中的定时器
- advanceTimersByTime 提前具体毫秒执行
// 示例函数
function delayedFunction(callback) {
setTimeout(() => {
callback();
}, 1000);
}
// 测试代码
describe('delayedFunction', () => {
beforeEach(() => {
jest.useFakeTimers(); // 启用假定时器
});
afterEach(() => {
jest.useRealTimers(); // 启用真定时器
});
test('calls the callback after 1 second', () => {
const callback = jest.fn();
delayedFunction(callback);
expect(callback).not.toBeCalled(); // 断言回调函数尚未被调用
jest.runAllTimers(); // 运行所有定时器
expect(callback).toBeCalled(); // 断言回调函数已被调用
expect(callback).toHaveBeenCalledTimes(1); // 断言回调函数只被调用一次
});
test('does not call the callback before 1 second', () => {
const callback = jest.fn();
delayedFunction(callback);
jest.advanceTimersByTime(500); // 提前500毫秒执行
expect(callback).not.toBeCalled(); // 断言回调函数尚未被调用
});
test('calls the callback only for pending timers', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
delayedFunction(callback1);
delayedFunction(callback2);
jest.runOnlyPendingTimers(); // 只运行等待中的定时器
expect(callback1).toBeCalled(); // 断言callback1已被调用
expect(callback2).not.toBeCalled(); // 断言callback2尚未被调用
});
});
通过 Mock 替代不需要关注的逻辑
一个文件可能会包含一些测试环境没有的 API 或者全局变量,或者不在我们测试范围内的外部文件,我们需要使用 mock 来模拟它们进行测试。
全局 mock
vitest.mock(path, moduleFactory)
- path:需要 mock 的文件路径
- moduleFactory: 这个模块的工厂函数
单次 mock
vitest.doMock(moduleName, factory, options)
mock 函数
vitest.fn 用于 mock 一个空函数,它会默认返回 undefined,当然我们也可以传入两个类型来控制它的入参和回参内容,例如jest.fn<string, string>()。
vitest.spyon可以创建一个和jest.fn类似的 mock 函数,不同的是它可以追踪目标函数的调用,使得它的入参和回参与需要 mock 的函数是自动匹配的。
通过 React Testing Library 进行 DOM 查询
Vitest 的基础断言通常用于纯 JavaScript 逻辑的断言,对 DOM 元素进行单元测试使用React Testing Library。
两者的关系为:
- Vitest 提供测试方法:断言、Mock 、SpyOn 等方法。
- RTL 主要提供 React 组件渲染, DOM 解析,DOM 的事件模拟。
happy-dom , 它的作用是在测试的运行环境 node 下提供对 web 标准的模拟实现。我们在开发 React 的运行环境时浏览器与测试的运行环境不一致,比如 window,document, web存储的API 在 node 运行时是不存在的,这影响了测试。 happy-dom 完成了对这些标准的补充。
在进行页面元素的查询之前需要进行页面元素的渲染:
import {render} from '@testing-library/react'
test("test", () => {
// 通过 render 方法渲染
render(<App />);
// screen api 进行查询
screen[...]
// 需要啥用啥
}
常见的页面查询 api 及其分类
一个查询方法一般由两部分组成:行为分类 + 参照物分类
行为分类
行为角度上,查询 API 可以包含三种类别(getBy, queryBy, findBy),它们各自又包含单查和多查(getAllBy, queryAllBy, findAllBy)。从字面意思就可以看出单查是只能查一个,多查则会查全部。
- Get:返回查询的匹配节点,如果没元素匹配,则会报错(针对单查如果查到多个也会报错);
- Query:返回查询的匹配节点,如果没有元素匹配会返回 null,但是不会报错(同样针对单查,如果查到多个匹配元素也会报错)
- Find:返回一个 Promise,默认超时时间为 1000 ms, 如果没有元素匹配或者查找超时,Promise 状态切为 reject(同样针对单查,如果查到多个元素,也会返回 reject)。
通过 waitfor API 测试异步函数
React testing library 提供有一个 waitfor 的 API。
waitfor接收两个参数,第一个是需要重复执行的回调函数,我们可以在其中查询元素并且断言,waitfor 会根据设定(或者默认)的超时时间和执行间隔来重复执行回调。第二个参数是可以配置的数据,比如说超时时间(timeout)、执行间隔(interval),通过这个参数我们就可以自定义我们需要的超时场景。
参照物分类
我们详细了解一下 role 分类,剩下的比较常用的分类大伙可以去官网查看
role - 角色
要理解角色的含义,首先我们需要来了解一个 W3C 语义 ---- ARIA。
ARIA (Accessible Rich Internet Applications) 是一组属性,用于定义使残障人士更容易访问 Web 内容和 Web 应用程序(尤其是使用 JavaScript 开发的应用程序)的方法。
我们使用的 div、button 等标签,即使没有加任何属性,也有一个隐性的 ARIA role 属性来表示它的语义,就拿 button 为例,<button>按钮</button> 其实可以看作是 <button role="button">按钮</button>,这个就是 role 查询。
除了基础的角色 role 外,W3C 在 ARIA 语义的提案中还包含了 aria 属性,这个语义表明 role 语义的状态和属性,比如 “按压” 的 button, "隐藏" 的 button 等
aria-hidden: 不在 DOM 树上访问的元素;aria-selected: 元素是否被选中;aria-checked: 元素是否被勾选;aria-current: 当前选中的元素;aria-pressed: 被按压的元素;aria-expanded:元素是否被展开;aria-level: 区域的等级,值得一提的是,h1 - h6 会有默认的aria-level属性,值对应1-6;aria-describedby: 可以通过描述来定位额外的元素。
其它参照物
- 标签文本:针对label标签的text查询
const label = screen.getByLabelText("testLabel")
- 占位符文本(placeholdertext):通过placeholder来查询
const placeholderInput = screen.getByPlaceholderText( "a query by placeholder" );
- 表单value(displayValue):根据表单元素的值来查询
const valueInput = screen.getByDisPlayValue("a query by value")
如何进行 DOM 断言?
页面可见断言
toBeEmptyDOMElement:标签之间是否有可见内容, 即使是空格也会失败;toBeVisible:是否可见,从用户直接观察的角度看能否可见;toBeInTheDocument:是否存在在文档中,document.body 是否存在这个元素。
// 示例函数
function createEmptyElement() {
return document.createElement('div');
}
function createElementWithContent() {
const div = document.createElement('div');
div.textContent = 'Hello, Jest!';
return div;
}
function createHiddenElement() {
const div = document.createElement('div');
div.style.display = 'none';
return div;
}
// 测试代码
describe('DOM element tests', () => {
test('element is empty', () => {
const element = createEmptyElement();
expect(element).toBeEmptyDOMElement(); // 断言元素为空
});
test('element has visible content', () => {
const element = createElementWithContent();
expect(element).toBeVisible(); // 断言元素可见
});
test('element is not visible', () => {
const element = createHiddenElement();
expect(element).not.toBeVisible(); // 断言元素不可见
});
test('element is in the document', () => {
const element = createEmptyElement();
document.body.appendChild(element);
expect(element).toBeInTheDocument(); // 断言元素存在于文档中
});
});
表单验证断言
toBeDisabled:检查元素是否通过 disable 属性判断,而不是 aria-disabled;toBeEnabled: 是否未被禁用,等同于.not.toBeDisabled;toBeRequired: 元素是否必填;toHaveFocus: 元素是否聚焦;toBeChecked: checkbox 或者是 radio 是否被选中;toHaveFormValues:验证整体表单的值是否和预期值匹配;toHaveValue:与toHaveFormValues类似,不过不同的是toHaveValue验证某个单独的表单元素,而不是全部。
代码层面验证
toHaveAttribute: 匹配元素是否具备某个值的属性;toHaveClass: 匹配元素在类属性中是否包含某个类;toHaveStyle: 匹配元素是否具有对应样式,需要注意的是,这个是精准非模糊匹配,例如display: none无法匹配display:none;color:#fff;。
通过 fireEvent 和 userEvent 模拟事件绑定触发
React Testing Library 提供了两种手段来模拟,fireEvent 和 userEvent
我们应该尽量避免使用
fireEvent,而是使用userEvent
fireEvent[eventName](node: HTMLElement, eventProperties: Object)
- eventName: 事件名
- node: 查询出来的对象
- eventProperties: 描述这个具体事件的属性
通常对于事件的用例,我们会使用到 Vitest 提供的 mock 事件,以及 toBeCalled 和 toBeCalledTimes 两个断言,toBeCalled 用来判断 mock 事件是否被调用,而 toBeCalledTimes 用来判断 mock 事件被调用的次数。
userEvent 的实现,除了模拟传入实例直接需要的 click 外,它还触发了这个元素聚焦和失焦,就不像 fireEvent ,只是简单返回模拟的事件
怎么测试 React Hook?
-
通过直接测试调用 hook 组件的方式来完成这部分用例
-
testing-library 提供了一个 renderHook 的方法来帮我们实现。
第一种方式对于一些公共 hook 可能会需要专门建一个组件进行测试,这会导致写很多与业务无关的代码,但测试的健壮性更强。
通过快照保证组件 UI 完整
快照测试和它的字面意思一样,通过快速(简单)拍出的照片来测试,它是将我们需要判定的元素的内容存储下来,生成一个 snapshots 目录用来存放快照文件,在下一次匹配时,会判断两次的结果能否匹配,从而达到从整体维度保证组件功能完成的能力。
快照测试更适合使用在不轻易改变,甚至不会去改变的公共逻辑中。
React Testing library 中提供了快照测试的能力,我们只需要使用它提供给我们的 toMatchSnapshot 断言就好
如何更新快照?
直接在控制台中输入 u 来更新快照
端对端测试
什么是端对端测试?
E2E(End-to-End)测试是一种软件测试方法,用于测试整个应用程序的工作流程,以确保整个系统按预期工作。它不需要基于项目,有页面就可以测试,通常由测试人员编写。
这通常涉及从用户界面(UI)开始,覆盖系统各个层,比如业务逻辑层、API/服务层、数据层。
E2E 测试是从用户的角度出发,模拟真实世界场景来进行的,它有以下特点:
- 全面性: 覆盖整个应用程序的所有交互和数据流。
- 实用性: 能够捕获整体系统行为,而不仅仅是单个组件。
- 复杂性: 可能会涉及多个系统或子系统。
安装
pnpm create playwright
运行:
开两个终端分别运行
pnpm run dev
npx playwright test --ui
然后就可以看见 playwright 的可视化界面了。
E2E 测试用例编写原则
在决定好如何组织 E2E 测试代码后,我们在写具体测试用例时,尽可能遵从以下原则:
- 测试用户可见行为:测试应该验证页面中对于用户可见的部分,避免依赖实现细节(比如对于用户不可见的函数名称,css 类名等)
// 👎
test('should submit when button is clicked', async ({ page }) => {
await page.goto('http://example.com');
await page.locator('button.submit-icon').click(); // 依赖于 CSS 选择器,如果后续更改了类名,测试用例会失败
});
// 👍
test('should submit when button is clicked', async ({ page }) => {
await page.goto('http://example.com');
await page.getByRole('button', {name: '提交'}).click(); // 依赖于用户可见的文本,不依赖实现细节
});
-
尽可能将测试隔离:每个测试都应该完全与其他测试隔离,并且应该独立运行,具有自己的 local storage、session storage、数据、Cookie 等。测试隔离提高了可重复性,使调试更容易,并防止级联测试失败。
-
避免测试第三方依赖:只测试项目中我们可控的部分,不测试我们无法控制的外部网站和 API,避免耗费时间,还可能导致测试失败
假如项目中依赖了外部 API,可以使用 Playwright Network API 对响应进行 mock:
await page.route('**/api/fetch_data_third_party_dependency', route => route.fulfill({
status: 200,
body: mockData,
}));
await page.goto('https://example.com');
关于其他 playwright 的 api,大家可以到官网去学习。
可深究的方向
- 自动化测试覆盖率的统计。
- 项目自动化测试的持续集成。
- 怎么覆盖滚动等复杂交互场景?
- 怎样才算一个好的测试代码?