關於 React 單元測試的一點思索

引言

關於單元測試的內容不少,關於 React 單元測試的內容也很多,在官方文檔中,配套測試庫中,都存在大量的實例示範,但核心問題依然存在,僅僅告訴開發者工具如何使用,對應該測什麼、不該該測什麼卻着墨很少。本文以我的視角,討論 React 項目中單元測試的落地。react

必要性

正式開始討論以前,先行說明單元測試的必要性,單元測試屬於自動化測試的重要組成部分,單元測試的必要性,與自動化測試的必要性雷同。固然,忽略項目類型、生命週期、人員配置,大談特談單元測試的好處與必要性,無疑屬於耍流氓,私覺得應該引入單元測試的場景以下:typescript

  1. 基礎庫開發維護
  2. 長期項目中後期迭代
  3. 第三方依賴不可控

若是項目存在大量用戶,對穩定性追求高,人力迴歸測試依然不足以保障,必需要引入單元測試。第三方依賴不可控時,一旦出現問題,必然出現曠日持久撕逼扯皮,花費大量時間自證清白,影響開發效率,於是建議引入單元測試,加強撕逼信心。其餘場景下,能夠根據條件決定是否引入。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 componentstateful 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 渲染,能夠直接訪問實例內部狀態,此處很少作說明,不作評價,取決於你的選擇。

狀態組件存在變種,維護內部狀態以外,也存在跨組件通訊需求,通常表現爲回調函數,函數調用能夠歸入單元測試覆蓋內容。

組件通訊頻繁,耦合嚴重之時,使用 reduxmobx 等全局狀態管理方案瓜熟蒂落。引入 redux 後,組件基本只負擔 renderdispatch action 職責,單元測試覆蓋的重點便從組件渲染演變爲狀態管理。

redux 舉例說明,通常包括 actionaction creatorreducerselector 部分。actionaction 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-thunkredux-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", } `);
  });
});
複製代碼

編寫測試用例時,選擇直接模擬 useSelectoruseDispatch 函數,沒有傳入 mock store,主要考量在於組件測試關注數據,不關注數據來源,且 selector function 已經獨立覆蓋,不必從 mock state 選擇數據。若是沒有使用 react hooks,或者重度依賴傳統 connect 高階組件,可視具體狀況做出選擇。

總結

關於 React 的單元測試,上述內容爲我的的一點想法,總結以下:

  • 不要測試無邏輯純渲染組件。
  • 不要重複測試相同邏輯。
  • 謹慎使用 snapshot。
  • 組件測試以渲染結果爲主,避開直接操縱實例。
  • 重點關注數據管理。

若是看到這兒尚未睡着,歡迎留下你的想法和指點。

相關文章
相關標籤/搜索