引言
关于单元测试的内容很多,关于
必要性
正式开始讨论之前,先行说明单元测试的必要性,单元测试属于自动化测试的重要组成部分,单元测试的必要性,与自动化测试的必要性雷同。当然,忽略项目类型、生命周期、人员配置,大谈特谈单元测试的好处与必要性,无疑属于耍流氓,私以为应该引入单元测试的场景如下:
- 基础库开发维护
- 长期项目中后期迭代
- 第三方依赖不可控
如果项目存在大量用户,对稳定性追求高,人力回归测试依然不足以保障,必须要引入单元测试。第三方依赖不可控时,一旦出现问题,必然出现旷日持久撕逼扯皮,花费大量时间自证清白,影响开发效率,因而建议引入单元测试,增强撕逼信心。其他场景下,可以根据条件决定是否引入。
工具链
jest @tesing-library/react @tesing-library/jest-dom
测试内容
一般而言,测试用例的主体是函数,尤其无副作用纯函数。传入参数、执行函数、匹配期望值,便是一个基本的
1 2 3 | export function sum(a: number, b: number) { return a + b; } |
测试代码如下:
1 2 3 | it('should sum number parameters', () => { expect(sum(1, 2)).toEqual(3); }); |
单元测试的基本骨架都与此类似,总结三点基本原则:
- 快速稳定 -- 运行环境可控
- 安全重构 -- 关注输入输出,不关注内部实现
- 表达清晰 -- 明确反映测试目的
提及
1 2 3 4 5 6 7 8 9 10 | 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> ); } |
组件不接受任何参数,输出内容固定,而且一目了然。实践中,通常承担分割渲染职责,完全没有必要浪费任何笔墨进行测试。
警告框内容需要自定义,且不止一种样式,进一步派生:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | 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> ); } |
组件接受
- 计算容器类名
- 绑定数据到
DOM 节点
组件功能依然以渲染为主,内含轻量逻辑,是否进行单元测试覆盖,视组件内部逻辑复杂度确定。如果存在基于入参的多分支渲染,或者存在复杂的入参数据派生,建议进行单元测试覆盖。数据派生建议抽取独立函数,独立覆盖,渲染分支测试的方式考虑以
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // 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(); }); }); |
此处不针对
接着讨论状态组件,一般称之为为
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 | 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> ); } |
组件实现简单计数,用户操作触发状态变更。函数式组件无法直接访问内部状态,因而编写测试用例时,以关键渲染节点为目标:
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 | // 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'); }); }); |
如果使用
状态组件存在变种,维护内部状态之外,也存在跨组件通信需求,一般表现为回调函数,函数调用可以纳入单元测试覆盖内容。
组件通信频繁,耦合严重之时,使用
以
最核心的环节为
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 | 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; } } |
纯函数的单元测试非常简单,控制入参即可:
1 2 3 4 5 6 7 8 9 10 11 12 | 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 }); }); }); |
实践中,业务逻辑不会如此简单,明确每一个
使用全局状态管理,绕不过去
依然实现简单计数功能,触发计时之后,持续按秒迭代直到触发终止。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | 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; } |
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 | /** * @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; }) ) ) ); }; |
此处使用
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 | /** * @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 }, ]); }); }); }); |
应用状态全局管理之后,组件业务逻辑轻量化,仅负责数据渲染、
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 | /** * @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", } `); }); }); |
编写测试用例时,选择直接模拟
总结
关于 React 的单元测试,上述内容为个人的一点想法,总结如下:
- 不要测试无逻辑纯渲染组件。
- 不要重复测试相同逻辑。
- 谨慎使用 snapshot。
- 组件测试以渲染结果为主,避开直接操纵实例。
- 重点关注数据管理。
如果看到这儿还没有睡着,欢迎留下你的想法和指点。