关于 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。
  • 组件测试以渲染结果为主,避开直接操纵实例。
  • 重点关注数据管理。

若是看到这儿尚未睡着,欢迎留下你的想法和指点。

相关文章
相关标签/搜索