关于单元测试的内容不少,关于 React
单元测试的内容也很多,在官方文档中,配套测试库中,都存在大量的实例示范,但核心问题依然存在,仅仅告诉开发者工具如何使用,对应该测什么、不该该测什么却着墨很少。本文以我的视角,讨论 React
项目中单元测试的落地。react
正式开始讨论以前,先行说明单元测试的必要性,单元测试属于自动化测试的重要组成部分,单元测试的必要性,与自动化测试的必要性雷同。固然,忽略项目类型、生命周期、人员配置,大谈特谈单元测试的好处与必要性,无疑属于耍流氓,私觉得应该引入单元测试的场景以下:typescript
若是项目存在大量用户,对稳定性追求高,人力回归测试依然不足以保障,必需要引入单元测试。第三方依赖不可控时,一旦出现问题,必然出现旷日持久撕逼扯皮,花费大量时间自证清白,影响开发效率,于是建议引入单元测试,加强撕逼信心。其余场景下,能够根据条件决定是否引入。redux
jest
@tesing-library/react
@tesing-library/jest-dom
通常而言,测试用例的主体是函数,尤为无反作用纯函数。传入参数、执行函数、匹配指望值,即是一个基本的 test case
。示例以下:promise
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
两种。先讨论无逻辑无状态组件,从形态上来讲,与纯函数较为接近。antd
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>
);
}
复制代码
组件不接受任何参数,输出内容固定,并且一目了然。实践中,一般承担分割渲染职责,彻底没有必要浪费任何笔墨进行测试。less
警告框内容须要自定义,且不止一种样式,进一步派生:dom
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
,组件职责包括:async
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 的单元测试,上述内容为我的的一点想法,总结以下:
若是看到这儿尚未睡着,欢迎留下你的想法和指点。