之前记录过如何通过 Vitest 作为框架搭建单元测试的环境,但是 Vitest 仅作为运行环境提供了断言等测试API,如果要进行 DOM 测试,则还需要jsdom或者happy-dom等工具来进行dom解析。
Testing Library 就是基于jsdom的一款 DOM 测试工具,它提供了几乎所有主流前端框架的DOM测试工具,像React、Vue等。
环境配置
在vitest+React环境基础上安装DOM测试依赖,vitest的配置就不多说了
$ pnpm install -D @testing-library/react @testing-library/jest-dom jsdom
在根目录创建vitest-setup.js
文件
import '@testing-library/jest-dom/vitest'
这个文件是为了引入Testing Library提供的对vitest断言的拓展,如果TS的项目类型报错需要额外配置下类型,新建一个vitest.d.ts
(记得在tsconfig.json中添加include)。
这个能力Testing Library应该提供,但是没生效,好像是声明
declare module '@vitest/expect'
匹配不上,需要自己拓展一下
import type { TestingLibraryMatchers } from '@testing-library/jest-dom'
declare module 'vitest' {
interface Assertion<T = any> extends TestingLibraryMatchers<T> { }
interface AsymmetricMatchersContaining extends TestingLibraryMatchers { }
}
然后继续配置vite.config.ts
,添加测试相关的配置
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@/': new URL('./src/', import.meta.url).pathname,
}
},
test: {
environment: 'jsdom',
setupFiles: ['./vitest-setup.js'],
},
})
下面来进行下测试
function App() {
return (
<>
<h1>Vite + React</h1>
</>
)
}
import { it, describe, expect } from "vitest";
import { render, screen } from "@testing-library/react";
describe('index', () => {
it('test', () => {
expect(1 + 1).toBe(2)
})
it('dom', () => {
render(<App />)
expect(screen.getAllByText('Vite + React')[0].tagName).toBe('H1')
})
})
可以正常通过测试
API
在开始进行DOM单元测试学习之前,了解一下一些一些常用的API是很有必要的。Testing Libaray中最核心的API包括元素查询和事件控制,这也是前端交互中的主要内容。
查询
查询有三种类型,其作用都是查找指定的元素,区别如下:
- get:查询不到元素时会抛出异常;
- find:查询结果是一个Promise,如果查找不到会重试(默认超时时间1000ms),适用于一些异步渲染的场景;
- query:查找不到元素不会抛出异常,而是返回null。
这三种元素还有查询多个元素的方法getAll
、findAll
、queryAll
,返回结果时元素组成的数组。
从查询的纬度来看,有以下几种类型(都可以和上面的6种前缀组合):
- ByLableText(label, options):通过label标签查询元素,可以是for绑定的元素也可以是嵌套的元素;
- ByPlaceholderText(placeholder, options):通过占位提示文本查询表单元素;
- ByText(text, options):通过文本节点的内容查询;可以通过options.ignore设置忽略的标签,默认是’script, style’。
- ByDisplayValue(value, options):通过显示的值来选择表单元素,通常是
input
,textarea
和select
; - ByAltText(alt, options):通过alt属性查询元素,通常是img标签;
- ByTitle(title, options):通过title属性来查询元素,svg中的title也可以查到;
- ByTestId(testId, options):通过testid来查询元素,例如
<div data-testid="custom-element" />
这样的元素; - ByRole:这是最复杂也是功能最灵活的一种查询方式,通过WAI-ARIA 规范中定义的 roles 来进行查询。直接看文档吧,内容太多,这里只言片语也讲不清楚。
除了ByRole之外的每种查询都可以通过options.exact设置绝对匹配,默认为true。
事件
在选择玩元素之后,可以通过原生的 Events API
或者 @testing-library/user-event(推荐)
提供的API来触发事件。
通过fireEvent(dom, event)
可以模拟用户触发操作,通过手动创建事件对象在指定的dom元素上触发。
例如我们有一个计数器组件,每次点击时count都会加一,此时我们可以通过fireEvent来进行测试验证
describe("event", () => {
beforeAll(() => {
render(<App />, { wrapper: BrowserRouter })
})
it("fireEvent click", () => {
expect(screen.getByTitle('displayCount').innerHTML).toBe('count: 0')
const button = screen.getByTestId('test-button')
fireEvent(button, new MouseEvent('click', { bubbles: true, cancelable: true }))
expect(screen.getByTitle('displayCount').innerHTML).toBe('count: 1')
});
})
fireEvent可以通过fireEvent[eventName](dom, eventProperties)
的形式来快速触发事件,我们可以在刚才的基础上补充一个这样的用例
describe("event", () => {
// ......
it('quickly event', () => {
expect(screen.getByTitle('displayCount').innerHTML).toBe('count: 1')
const button = screen.getByTestId('test-button')
fireEvent.click(button)
expect(screen.getByTitle('displayCount').innerHTML).toBe('count: 2')
})
})
这种方式比第一种更加灵活,不需要构建事件对象,只需要传入关键的事件属性即可。
另外,可以通过createEvent[eventName](dom, eventProperties)
方法快速创建事件作为fireEvent(dom, event)
中的event参数。当需要访问event事件上的属性,但是想通过事件属性对象快速创建时,这种方式就很有用了。
上面就是fireEvent的用法,虽然并不算复杂,但是官方更推荐使用user-event。
常见场景测试
React测试主要是对以下场景进行验证,例如:
- 方法测试:主要是对一些通用工具方法的测试,一般不涉及DOM,是纯JS逻辑的验证;
- Hooks 测试:Hooks 是React通用能力的封装,Hooks的正确与否影响了众多引用者的正确运行;
- 路由测试:前端路由是现代前端应用的核心能力了,正确的路由跳转是非常关键的;
- 组件测试:组件测试是最复杂的,需要对DOM进行全方位测试,有以下场景
- 测试页面加载:确保页面是否成功加载,并且所有必要的 DOM 元素是否存在。
- 属性操作:验证针对特定属性(如 class、style、innerText 等)进行读写操作时是否正确。
- 事件绑定和触发:检查事件绑定功能是否正常工作,以及触发事件后相关行为是否符合预期。数据更新和展示:确认数据或状态变化时页面上相应 DOM 的更新情况。
- 表单交互:测试表单元素(如 input、textarea、select)输入与提交的情况,包括内容校验和提交数据验证等方面。
- 异步加载内容处理: 当涉及到异步请求获取内容渲染,则需包含同样异步回调函数
如果在测试组件遇到了TypeError: window.matchMedia is not a function
错误,可以在vitest的setup文件中添加以下内容,这个时jest-dom官方的建议。
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
下面就来对一些常见的场景进行代码示例
Router
使用React Router作为路由工具时,我们可以很方便的对路由层面进行测试。我们以最基础的项目进行测试,现在的App内容如下
export default function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
);
}
其中对应的三个页面组件都只是简单的文本渲染,就不贴了,Layout中定义了声明式的跳转按钮,渲染效果如下
针对这个页面我们可以进行以下内容的测试
import App from "@/App";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { BrowserRouter } from "react-router-dom";
import userEvent from '@testing-library/user-event';
import { createBrowserHistory } from "history";
describe('routes test', () => {
beforeAll(() => {
render(<App />, { wrapper: BrowserRouter })
})
it('home', () => {
expect(location.pathname).toBe('/')
expect(document).toContain(screen.queryByText(/home page/i))
})
it('route jump', async () => {
const user = userEvent.setup()
await user.click(screen.getByText(/about/i, { selector: 'a' }))
expect(location.pathname).toBe('/about')
expect(document).toContain(screen.queryByText(/about page/i))
})
it('not found', () => {
const history = createBrowserHistory()
// 命令式路由跳转
history.push('/abc')
expect(location.pathname).toBe('/abc')
// 重新渲染
render(<App />, { wrapper: BrowserRouter })
expect(document).toContain(screen.queryByText(/404/i, { selector: 'h1' }))
})
})
第一个用例中我们对首页内容进行了验证,第二个用例我们验证了路由跳转的交互正常,第三个用例我们用命令式的路由跳转验证了NotFound时的渲染是否正确。
🌟render(ui[, options])
对Core API的render进行了React层面的适配,用于渲染第一个参数ui(React组件)的内容到document.body中,第二个参数是一个options,配置了渲染函数的一些参数:
- container:默认情况下会创建一个div渲染到document.body中,在某些时候例如测试
<tr>
、<td>
等标签时,可以将container声明为一个<table>
元素。- baseElement:声明渲染的根元素,默认是document.body。
- hydrate:在服务端渲染时设为true,可以使用ReactDOM.hydrate进行渲染。
- wrapper:传递一个React组件作为包装器选项,一般是用于Provider的包裹。
- queries:用于自定义一些查询函数或者覆盖默认的查询函数,如果不设置则使用默认,在某些场景下我们可能会通过以下自定义dom属性来进行查询,这时使用自定义queries将会非常有用。
🌟screen
当前“屏幕”渲染的内容,可以通过内置的一些查询函数来获取渲染的内容。例如
getByText(container, text)
,在screen下调用可以自动设置container为screen从而忽略container参数,screen.getByText(text)
。如果自定义函数也想通过screen这种形式调用,可以通过within
函数进行包装,就像bind那样。
事件调用
有些组件需要传递函数来作为某些时机的绑定函数,比如Button组件,我们需要验证这些函数的执行状态,例如我们有这样一个简单的Button组件:
import { PropsWithChildren } from "react";
import styles from './index.module.less'
import classnames from 'classnames'
type ButtonType = 'primary' | 'default' | 'danger';
interface IProps {
onClick: () => void;
type?: ButtonType;
size?: 'small' | 'middle' | 'large';
}
export default function Button(props: PropsWithChildren<IProps & Record<string, unknown>>) {
const { onClick, type = 'default', size = 'middle', children, ...rest } = props;
return (
<button className={
classnames(
styles.button,
styles['button-' + type],
styles['button-' + size])
}
onClick={onClick}
{...rest}
>
{children}
</button>
)
}
在进行单元测试时,我们只需要关注逻辑部分,也就是onClick的执行状态(样式问题我们应该交给E2E测试来完成),我们可以通过Vitest Mock一个函数,将这个函数传递给组件,函数内部会记录调用状态,如下
describe("Button", () => {
it("should render", async () => {
const handler = vi.fn() // mock 函数
const user = userEvent.setup()
render(<Button onClick={handler} type="primary" size="large">click</Button>)
const button = screen.getByRole('button', { name: /click/i })
expect(button).toBeInTheDocument() // 是否渲染
await user.click(button) // 点击
expect(handler).toBeCalled() // 被调用
await user.click(button) // 再次点击
expect(handler).toBeCalledTimes(2) // 调用两次
});
})
Hooks
Hooks 的测试依赖于Testing Library的renderHook方法,通过返回的对象来获取实时的值。例如我们有一个倒计时的hook如下
import { useState, useEffect } from 'react';
const Countdown = (initialSeconds: number) => {
const [seconds, setSeconds] = useState(initialSeconds);
useEffect(() => {
if (seconds > 0) {
const timer = setTimeout(() => setSeconds(seconds - 1), 1000);
return () => clearTimeout(timer);
}
}, [seconds]);
return { seconds }
};
export default Countdown;
renderHook(render[, options]):{rerender, result, unmount}
用于模拟Hooks的渲染,接收两个参数,第一个是执行hook的函数,第二个是一个配置对象,返回一个处理后的对象,因为不会像React真实的执行过程中刷新变量,所以通过对象属性的形式返回结果,就像Ref一样。返回值有三个属性,分别对应重新渲染、执行结果和解绑(解绑可用于验证useEffect的解绑逻辑)。
options有两个属性,initialProps和wrapper,其中wrapper和redner函数中是一样的,指定hook执行的组件容器,initialProps用于指定Hook首次执行时传入的参数,如果rerender时不穿入参数则会按照没有穿入参数更新返回值。
针对于这个Hook,我们要验证Hook在每个时间段的seconds是否正确。
import { renderHook } from '@testing-library/react';
import useTimeout from '@/hooks/useTimeout';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
describe('useTimeout Hook', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should display the correct countdown text', async () => {
const { result } = renderHook(() => useTimeout(5));
expect(result.current.seconds).toBe(5);
await vi.advanceTimersByTimeAsync(2000); // 模拟时间过去2秒
expect(result.current.seconds).toBe(3);
await vi.advanceTimersByTimeAsync(3000); // 模拟时间过去3秒
expect(result.current.seconds).toBe(0);
});
});
Table
Table 不论在B端还是C端,都是一种常见的数据展示形式,表格是一种很直观的数据展示形式,可以把数据明确地告知用户。
但是这些Table页面往往夹杂着网络请求,且数据不确定,使用真实请求的话非常不好确定单测用例。但是别担心,vitest提供了可以拦截请求的方法。
例如我们有这样一个简单的Table页面
export default function User() {
const [data, setData] = useState([])
function load(pageNumber = 1, pageSize = 10) {
fetch(`https://example.com/api/?page=${pageNumber}&results=${pageSize}`)
.then(res => res.json())
.then(res => {
setData(res.data)
})
}
useEffect(() => {
load(1, 10)
}, [])
return (
<Table columns={[
{
title: '姓名',
dataIndex: 'name',
key: 'name',
},
{
title: '年龄',
dataIndex: 'age',
key: 'age',
},
{
title: '住址',
dataIndex: 'address',
key: 'address',
},
]}
dataSource={data}
pagination={{
pageSize: 10,
}}
onChange={({ pageSize, current }) => load(current, pageSize)}
/>
)
}
表格中的数据源自fetch接口响应,我们可以通过vi.spyOn
来拦截fetch请求
describe('User Page', () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
beforeAll(() => {
const res = new Response(JSON.stringify({
ok: true,
data: [
{ name: 'John', age: 13, address: 'China/Beijing' },
{ name: 'Amy', age: 20, address: 'Japan/Tokyo' },
],
}), {
headers: {
"Content-Type": "application/json"
}
})
fetchSpy.mockReturnValue(Promise.resolve(res));
});
afterAll(() => {
fetchSpy.mockRestore();
});
it('request data', async () => {
render(<User />)
await waitFor(() => {
const table = screen.getByRole('table');
// 检查表头内容
const headerRow = table.querySelector('thead tr');
expect(headerRow).toHaveTextContent('姓名');
expect(headerRow).toHaveTextContent('年龄');
expect(headerRow).toHaveTextContent('住址');
// 检查第一行数据内容
const firstDataRow = table.querySelector('tbody tr:first-child');
expect(firstDataRow?.querySelector('td:nth-child(1)')).toHaveTextContent('John');
expect(firstDataRow?.querySelector('td:nth-child(2)')).toHaveTextContent('13');
expect(firstDataRow?.querySelector('td:nth-child(3)')).toHaveTextContent('China/Beijing');
})
})
})
我们在测试开始之前手动构造了一个Response实例,并Mock了fetch方法的返回值,当组件渲染时触发了fetch,接收到了我们预设的数据,此时就可以根据我们预设的数据进行验证了。
Form
Form 是数据输入的载体,这里以Antd Form组件为例,要注意的是Antd的Select组件实现并不是基于select元素实现的,所以不能使用传统的select事件来触发交互,要通过模拟鼠标点击来进行测试。
例如我们有以下一个简单的表单组件
export default function CreateUser(props: IProps) {
const { onFinish } = props;
return (
<Form onFinish={onFinish}>
<Form.Item name="name" label="姓名">
<Input />
</Form.Item>
<Form.Item name="age" label="年龄">
<Input />
</Form.Item>
<Form.Item name="sex" label="性别">
<Select
options={[
{ label: '男', value: 'man' },
{ label: '女', value: 'woman' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
创建
</Button>
</Form.Item>
</Form>
)
}
这里我们用了外部传进来的请求函数,更多的场景下接口一般是和表单绑定的,那样的话可以通过上一小节Table那种拦截请求的方式来进行测试
表单的内容非常简单,有两个Input输入框和一个Select选择框,我们的思路是模拟输入两串文本,然后通过点击行为选择到一个值,最后我们将收集到的值于预期的值进行比较。
describe('form', () => {
it('form submit', async () => {
const handler = vi.fn()
render(<CreateUser onFinish={handler} />)
const user = userEvent.setup()
// 输入
await user.type(screen.getByLabelText('姓名'), 'feng')
await user.type(screen.getByLabelText('年龄'), '16')
// 选择
await user.click(screen.getByRole('combobox', { name: '性别' }))
await user.click(screen.getByText('女'))
// 提交
await user.click(screen.getByRole('button', { name: '创 建' }))
await waitFor(() => {
expect(handler).toBeCalledWith({
name: 'feng',
age: '16',
sex: 'woman'
})
})
})
})
通过以上的场景,你应该已经了解到了DOM Test应该怎么做了。Testing Library 是一个强大且灵活的测试工具库,它通过鼓励开发者编写更贴近用户交互的测试用例,提高了测试代码的可读性和稳定性。使用 Testing Library 可以帮助我们构建更健壮、可靠且易于维护的前端应用程序,在保证功能正确性的同时促进代码质量和开发效率。因此,掌握好 Testing Library 将成为每个前端开发者值得投入精力学习和实践的重要技能之一。