关于 React 单元测试的一点思索


引言

关于单元测试的内容很多,关于 React 单元测试的内容也不少,在官方文档中,配套测试库中,都存在大量的实例示范,但核心问题依然存在,仅仅告诉开发者工具如何使用,对应该测什么、不应该测什么却着墨不多。本文以个人视角,讨论 React 项目中单元测试的落地。

必要性

正式开始讨论之前,先行说明单元测试的必要性,单元测试属于自动化测试的重要组成部分,单元测试的必要性,与自动化测试的必要性雷同。当然,忽略项目类型、生命周期、人员配置,大谈特谈单元测试的好处与必要性,无疑属于耍流氓,私以为应该引入单元测试的场景如下:

  1. 基础库开发维护
  2. 长期项目中后期迭代
  3. 第三方依赖不可控

如果项目存在大量用户,对稳定性追求高,人力回归测试依然不足以保障,必须要引入单元测试。第三方依赖不可控时,一旦出现问题,必然出现旷日持久撕逼扯皮,花费大量时间自证清白,影响开发效率,因而建议引入单元测试,增强撕逼信心。其他场景下,可以根据条件决定是否引入。

工具链

  • jest
  • @tesing-library/react
  • @tesing-library/jest-dom

测试内容

一般而言,测试用例的主体是函数,尤其无副作用纯函数。传入参数、执行函数、匹配期望值,便是一个基本的 test case。示例如下:

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);
});

单元测试的基本骨架都与此类似,总结三点基本原则:

  • 快速稳定 -- 运行环境可控
  • 安全重构 -- 关注输入输出,不关注内部实现
  • 表达清晰 -- 明确反映测试目的

提及 React,无法绕过组件,一般划分为 stateless componentstateful component 两种。先讨论无逻辑无状态组件,从形态上来说,与纯函数较为接近。

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>
  );
}

组件接受 props 参数,不依赖 react context,不依赖 global variables,组件职责包括:

  • 计算容器类名
  • 绑定数据到 DOM 节点

组件功能依然以渲染为主,内含轻量逻辑,是否进行单元测试覆盖,视组件内部逻辑复杂度确定。如果存在基于入参的多分支渲染,或者存在复杂的入参数据派生,建议进行单元测试覆盖。数据派生建议抽取独立函数,独立覆盖,渲染分支测试的方式考虑以 snapshot 为主。

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();
  });
});

snapshot 数量不宜过多,且必须进行交叉 code review,否则很容易流于形式,导致效果大打折扣,不如不要。

此处不针对 type 参数做其他 snapshot 测试,主要原因在于,不同的 type 入参,处理逻辑完全相同,不需要重复、多余的尝试。

接着讨论状态组件,一般称之为为 smart component。状态组件,顾名思义,其在内部维护可变状态,同时混杂着用户交互、网络请求、本地存储等副作用,示例如下:

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');
  });
});

如果使用 class component 配合 enzyme 渲染,可以直接访问实例内部状态,此处不多做说明,不做评价,取决于你的选择。

状态组件存在变种,维护内部状态之外,也存在跨组件通信需求,一般表现为回调函数,函数调用可以纳入单元测试覆盖内容。

组件通信频繁,耦合严重之时,使用 reduxmobx 等全局状态管理方案顺理成章。引入 redux 后,组件基本只负担 renderdispatch action 职责,单元测试覆盖的重点便从组件渲染演变为状态管理。

redux 举例说明,一般包括 actionaction creatorreducerselector 部分。actionaction creator 可以看做标量,除非逻辑特别复杂,且无法拆分,否则不建议进行任何测试。

最核心的环节为 reducer = (previousState, action) => nextState,形态为纯数据处理,天然适合进行单元测试覆盖,依然采用上述案例:

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 });
  });
});

实践中,业务逻辑不会如此简单,明确每一个 action 的作用,明确每一个 action 对全局状态的影响。selector用于节选部分数据,用于组件绑定,一般除非逻辑复杂,否则不推荐做单元测试覆盖。

使用全局状态管理,绕不过去 side effects 的处理。side effects 通过 redux-thunkredux-promise 等中间件实现,起始于 dispatch compound action,终于 dispatch pure action,关注的重点在于触发的 action,副作用流程的异常逻辑、业务逻辑、数据更新都应通过 action 表达。

依然实现简单计数功能,触发计时之后,持续按秒迭代直到触发终止。

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;
        })
      )
    )
  );
};

此处使用 redux-observable 实现,处理业务逻辑功能非常强大,基本无需引入其他副作用中间件。形态接近纯函数,输入输出都为 action stream,比较蛋疼的地方在于单元测试与 rxjs 如出一辙,编写测试用例存在难度,甚至高于业务功能实现本身。此处使用 Marble Diagrams 测试作为案例,实践中推荐使用更加简单粗暴的方式。

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 },
      ]);
    });
  });
});

应用状态全局管理之后,组件业务逻辑轻量化,仅负责数据渲染、dispatch action。渲染部分,与前文所示轻量化逻辑组件同样考量,dispatch 调用正确的 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
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",
      }
    `);
  });
});

编写测试用例时,选择直接模拟 useSelectoruseDispatch 函数,没有传入 mock store,主要考量在于组件测试关注数据,不关注数据来源,且 selector function 已经独立覆盖,没必要从 mock state 选择数据。如果没有使用 react hooks,或者重度依赖传统 connect 高阶组件,可视具体情况作出选择。

总结

关于 React 的单元测试,上述内容为个人的一点想法,总结如下:

  • 不要测试无逻辑纯渲染组件。
  • 不要重复测试相同逻辑。
  • 谨慎使用 snapshot。
  • 组件测试以渲染结果为主,避开直接操纵实例。
  • 重点关注数据管理。

如果看到这儿还没有睡着,欢迎留下你的想法和指点。