關於單元測試的內容不少,關於 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 的單元測試,上述內容爲我的的一點想法,總結以下:
若是看到這兒尚未睡着,歡迎留下你的想法和指點。