引言
关于单元测试的内容很多,关于 React
单元测试的内容也不少,在官方文档中,配套测试库中,都存在大量的实例示范,但核心问题依然存在,仅仅告诉开发者工具如何使用,对应该测什么、不应该测什么却着墨不多。本文以个人视角,讨论 React
项目中单元测试的落地。
必要性
正式开始讨论之前,先行说明单元测试的必要性,单元测试属于自动化测试的重要组成部分,单元测试的必要性,与自动化测试的必要性雷同。当然,忽略项目类型、生命周期、人员配置,大谈特谈单元测试的好处与必要性,无疑属于耍流氓,私以为应该引入单元测试的场景如下:
- 基础库开发维护
- 长期项目中后期迭代
- 第三方依赖不可控
如果项目存在大量用户,对稳定性追求高,人力回归测试依然不足以保障,必须要引入单元测试。第三方依赖不可控时,一旦出现问题,必然出现旷日持久撕逼扯皮,花费大量时间自证清白,影响开发效率,因而建议引入单元测试,增强撕逼信心。其他场景下,可以根据条件决定是否引入。
工具链
jest
@tesing-library/react
@tesing-library/jest-dom
测试内容
一般而言,测试用例的主体是函数,尤其无副作用纯函数。传入参数、执行函数、匹配期望值,便是一个基本的 test case
。示例如下:
export function sum(a: number, b: number) {
return a + b;
}
测试代码如下:
it('should sum number parameters', () => {
expect(sum(1, 2)).toEqual(3);
});
单元测试的基本骨架都与此类似,总结三点基本原则:
- 快速稳定 -- 运行环境可控
- 安全重构 -- 关注输入输出,不关注内部实现
- 表达清晰 -- 明确反映测试目的
提及 React
,无法绕过组件,一般划分为 stateless component
和 stateful component
两种。先讨论无逻辑无状态组件,从形态上来说,与纯函数较为接近。
import React from 'react';
export function Alert() {
return (
<div className="ant-alert ant-alert-success">
<span className="ant-alert-message">Success Text</span>
<span class="ant-alert-description">Success Description With Honest</span>
</div>
);
}
组件不接受任何参数,输出内容固定,而且一目了然。实践中,通常承担分割渲染职责,完全没有必要浪费任何笔墨进行测试。
警告框内容需要自定义,且不止一种样式,进一步派生:
import React from 'react';
interface AlertProps {
type: 'success' | 'info' | 'warning' | 'error';
message: string;
description: string;
}
export function Alert(props: AlertProps) {
const containerClassName = `ant-alert ant-alert-${props.type}`;
return (
<div className={containerClassName}>
<span className="ant-alert-message">{props.message}</span>
<span className="ant-alert-description">{props.description}</span>
</div>
);
}
组件接受 props
参数,不依赖 react context
,不依赖 global variables
,组件职责包括:
- 计算容器类名
- 绑定数据到
DOM
节点
组件功能依然以渲染为主,内含轻量逻辑,是否进行单元测试覆盖,视组件内部逻辑复杂度确定。如果存在基于入参的多分支渲染,或者存在复杂的入参数据派生,建议进行单元测试覆盖。数据派生建议抽取独立函数,独立覆盖,渲染分支测试的方式考虑以 snapshot
为主。
// package
import React from 'react';
import { render } from '@testing-library/react';
// internal
import { Alert } from './Alert';
describe('Alert', () => {
it('should bind properties', () => {
const { container } = render(
<Alert
type="success"
message="Unit Test"
description="Unit Test Description"
/>
);
expect(container.firstChild).toMatchSnapshot();
});
});
snapshot
数量不宜过多,且必须进行交叉 code review
,否则很容易流于形式,导致效果大打折扣,不如不要。
此处不针对 type
参数做其他 snapshot
测试,主要原因在于,不同的 type
入参,处理逻辑完全相同,不需要重复、多余的尝试。
接着讨论状态组件,一般称之为为 smart component
。状态组件,顾名思义,其在内部维护可变状态,同时混杂着用户交互、网络请求、本地存储等副作用,示例如下:
import React, { useState, useCallback, Fragment } from 'react';
import { Tag, Button } from 'antd';
export function Counter() {
const [count, setCount] = useState(0);
const handleAddClick = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
const handleMinusClick = useCallback(() => {
setCount((prev) => prev - 1);
}, []);
return (
<Fragment>
<Tag color="magenta" data-testid="amount">
{count}
</Tag>
<Button type="primary" data-testid="add" onClick={handleAddClick}>
ADD
</Button>
<Button type="danger" data-testid="minus" onClick={handleMinusClick}>
MINUS
</Button>
</Fragment>
);
}
组件实现简单计数,用户操作触发状态变更。函数式组件无法直接访问内部状态,因而编写测试用例时,以关键渲染节点为目标:
// package
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
// internal
import { Counter } from './Counter';
describe('Counter', () => {
it('should implement add/minus operation', async () => {
const { findByTestId } = render(<Counter />);
const $amount = await findByTestId('amount');
const $add = await findByTestId('add');
const $minus = await findByTestId('minus');
expect($amount).toHaveTextContent('0');
fireEvent.click($add);
expect($amount).toHaveTextContent('1');
fireEvent.click($minus);
fireEvent.click($minus);
expect($amount).toHaveTextContent('-1');
});
});
如果使用 class component
配合 enzyme
渲染,可以直接访问实例内部状态,此处不多做说明,不做评价,取决于你的选择。
状态组件存在变种,维护内部状态之外,也存在跨组件通信需求,一般表现为回调函数,函数调用可以纳入单元测试覆盖内容。
组件通信频繁,耦合严重之时,使用 redux
,mobx
等全局状态管理方案顺理成章。引入 redux
后,组件基本只负担 render
、dispatch action
职责,单元测试覆盖的重点便从组件渲染演变为状态管理。
以 redux
举例说明,一般包括 action
、action creator
、reducer
、selector
部分。action
、action creator
可以看做标量,除非逻辑特别复杂,且无法拆分,否则不建议进行任何测试。
最核心的环节为 reducer = (previousState, action) => nextState
,形态为纯数据处理,天然适合进行单元测试覆盖,依然采用上述案例:
export enum ActionTypes {
Add = 'ADD',
Minus = 'MINUS',
}
export interface AddAction {
type: ActionTypes.Add;
}
export interface MinusAction {
type: ActionTypes.Minus;
}
export interface State {
count: number;
}
export type Actions = AddAction | MinusAction;
export function reducer(state: State = { count: 0 }, action: Actions): State {
switch (action.type) {
case ActionTypes.Add:
return {
count: state.count + 1,
};
case ActionTypes.Minus:
return {
count: state.count - 1,
};
default:
return state;
}
}
纯函数的单元测试非常简单,控制入参即可:
import { ActionTypes, reducer, State } from './UT.reducer';
describe('count reducer', () => {
it('should implement add/minus operation', () => {
const state: State = {
count: 0,
};
expect(reducer(state, { type: ActionTypes.Add })).toEqual({ count: 1 });
expect(reducer(state, { type: ActionTypes.Minus })).toEqual({ count: -1 });
});
});
实践中,业务逻辑不会如此简单,明确每一个 action
的作用,明确每一个 action
对全局状态的影响。selector
用于节选部分数据,用于组件绑定,一般除非逻辑复杂,否则不推荐做单元测试覆盖。
使用全局状态管理,绕不过去 side effects
的处理。side effects
通过 redux-thunk
、redux-promise
等中间件实现,起始于 dispatch compound action
,终于 dispatch pure action
,关注的重点在于触发的 action
,副作用流程的异常逻辑、业务逻辑、数据更新都应通过 action
表达。
依然实现简单计数功能,触发计时之后,持续按秒迭代直到触发终止。
export enum CountdownActionTypes {
RequestCountdown = 'RequestCountdown',
IterateCountdown = 'IterateCountdown',
TerminateCountdown = 'TerminateCountdown',
}
export interface RequestCountdownAction {
type: CountdownActionTypes.RequestCountdown;
}
export interface IterateCountdownAction {
type: CountdownActionTypes.IterateCountdown;
payload: number;
}
export interface TerminateCountdownAction {
type: CountdownActionTypes.TerminateCountdown;
}
/**
* @description - countdown epic
*/
// package
import { Epic, ofType } from 'redux-observable';
import { interval, Observable } from 'rxjs';
import { exhaustMap, takeUntil, scan, map } from 'rxjs/operators';
// redux
import {
RequestCountdownAction,
IterateCountdownAction,
TerminateCountdownAction,
CountdownActionTypes,
} from './countdown.constant';
export const countdownEpic: Epic = (
actions$: Observable<RequestCountdownAction | TerminateCountdownAction>
): Observable<IterateCountdownAction> => {
const terminate$ = actions$.pipe(
ofType(CountdownActionTypes.TerminateCountdown)
);
return actions$.pipe(
ofType(CountdownActionTypes.RequestCountdown),
exhaustMap(() =>
interval(1000).pipe(
takeUntil(terminate$),
scan((acc) => acc + 1, 0),
map((count) => {
const action: IterateCountdownAction = {
type: CountdownActionTypes.IterateCountdown,
payload: count,
};
return action;
})
)
)
);
};
此处使用 redux-observable
实现,处理业务逻辑功能非常强大,基本无需引入其他副作用中间件。形态接近纯函数,输入输出都为 action stream
,比较蛋疼的地方在于单元测试与 rxjs
如出一辙,编写测试用例存在难度,甚至高于业务功能实现本身。此处使用 Marble Diagrams
测试作为案例,实践中推荐使用更加简单粗暴的方式。
/**
* @description - countdown epic unit test
*/
// package
import { TestScheduler } from 'rxjs/testing';
// internal
import { CountdownActionTypes } from './countdown.constant';
import { countdownEpic } from './countdown.epic';
describe('countdown epic', () => {
const scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
it('should generate countdown action stream correctly', () => {
scheduler.run((tools) => {
const { hot, expectObservable } = tools;
const actions$ = hot('a 3100ms b', {
a: {
type: CountdownActionTypes.RequestCountdown,
},
b: {
type: CountdownActionTypes.TerminateCountdown,
},
});
// @ts-ignore
const epic$ = countdownEpic(actions$, {}, {});
expectObservable(epic$).toBe('1000ms 0 999ms 1 999ms 2', [
{ type: CountdownActionTypes.IterateCountdown, payload: 1 },
{ type: CountdownActionTypes.IterateCountdown, payload: 2 },
{ type: CountdownActionTypes.IterateCountdown, payload: 3 },
]);
});
});
});
应用状态全局管理之后,组件业务逻辑轻量化,仅负责数据渲染、dispatch action
。渲染部分,与前文所示轻量化逻辑组件同样考量,dispatch
调用正确的 action
可测可不测。
/**
* @description - Countdown component test cases
*/
// package
import React from 'react';
import * as redux from 'react-redux';
import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
// internal
import { Countdown } from './Countdown';
describe('Countdown', () => {
it('should dispatch proper actions', async () => {
// manual mock dispatch
const dispatch = jest.fn();
jest.spyOn(redux, 'useSelector').mockReturnValue({
count: 0,
});
jest.spyOn(redux, 'useDispatch').mockReturnValue(dispatch);
const { findByTestId } = render(<Countdown />);
const $start = await findByTestId('start');
const $terminate = await findByTestId('terminate');
fireEvent.click($start);
expect(dispatch.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"type": "RequestCountdown",
}
`);
fireEvent.click($terminate);
expect(dispatch.mock.calls[1][0]).toMatchInlineSnapshot(`
Object {
"type": "TerminateCountdown",
}
`);
});
});
编写测试用例时,选择直接模拟 useSelector
、useDispatch
函数,没有传入 mock store
,主要考量在于组件测试关注数据,不关注数据来源,且 selector function
已经独立覆盖,没必要从 mock state
选择数据。如果没有使用 react hooks
,或者重度依赖传统 connect
高阶组件,可视具体情况作出选择。
总结
关于 React 的单元测试,上述内容为个人的一点想法,总结如下:
- 不要测试无逻辑纯渲染组件。
- 不要重复测试相同逻辑。
- 谨慎使用 snapshot。
- 组件测试以渲染结果为主,避开直接操纵实例。
- 重点关注数据管理。
如果看到这儿还没有睡着,欢迎留下你的想法和指点。
