日常开发中都需要编写一些通用的组件来给多个模块复用,为了提高组件的稳定性和可靠性,通常需要编写一些测试用例来保证组件在开发阶段能的质量和确保功能一致性。

React Testing library

本文将介绍下 React Testing library 如何使用,它是 create react app 默认自带的测试库,比较轻量,简单易懂。而且能满足 React 新版本和特性,例如 hooks。

核心理念

React Testing library 推崇编写测试用例不应该关注组件内部实现细节,应该关注结果是否准确。比如关注渲染的 DOM 结构是否正确,用户交互是否符合预期,而不是关注组件的状态,内部方法,声明周期,子组件等等。

安装依赖

默认 cra 项目会集成 testing-library 功能。为了加深理解,我们通过 pnpm run eject 命令将配置暴露出来,如果不是通过 cra 项目创建的,可以通过下面命令安装所需依赖:

pnpm add @types/jest @testing-library/react @testing-library/jest-dom jest ts-jest -D

jest.config.ts

通过命令 pnpm run eject 出来的后,可以在 package.json 中看到 jest 的配置。我们也可以在根目录下新建 jest.config.ts 文件,配置 jest 的一些配置项,配置如下:

module.export = {
  "roots": [
    "<rootDir>/src"
  ],
  "setupFiles": [
    "react-app-polyfill/jsdom"
  ],
  "setupFilesAfterEnv": [
    "<rootDir>/src/setupTests.ts"
  ],
  "testMatch": [
    "<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
    "<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
  ],
  "testEnvironment": "jsdom",
  "transform": {
    "^.+\\.(js|jsx|mjs|cjs|ts|tsx)$": "<rootDir>/config/jest/babelTransform.js",
    "^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
    "^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
  },
  "transformIgnorePatterns": [
    "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$",
    "^.+\\.module\\.(css|sass|scss)$"
  ],
  "modulePaths": [],
  "moduleNameMapper": {
    "^react-native$": "react-native-web",
    "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
  },
  "moduleFileExtensions": [
    "web.js",
    "js",
    "web.ts",
    "ts",
    "web.tsx",
    "tsx",
    "json",
    "web.jsx",
    "jsx",
    "node"
  ],
  "resetMocks": true
}

解释下部分配置的含义:

setupFilesAfterEnv

指定 jest 运行测试用例前需要执行的文件,用于配置一些全局的测试环境,例如引入 jest-dom:

在 src/setupTests.ts 文件,输入:

import '@testing-library/jest-dom';

transform

指定 jest 如何处理不同类型的文件,例如 tsx 文件,css 文件等。具体可以查看。

这里的配置的处理 .+\\.(js|jsx|mjs|cjs|ts|tsx) 类型的文件,使用 babelTransform.js 处理。

const babelJest = require('babel-jest').default;

const hasJsxRuntime = (() => {
  if (process.env.DISABLE_NEW_JSX_TRANSFORM === 'true') {
    return false;
  }

  try {
    require.resolve('react/jsx-runtime');
    return true;
  } catch (e) {
    return false;
  }
})();

module.exports = babelJest.createTransformer({
  presets: [
    [
      require.resolve('babel-preset-react-app'),
      {
        runtime: hasJsxRuntime ? 'automatic' : 'classic',
      },
    ],
  ],
  babelrc: false,
  configFile: false,
});

最后在 package.json 中配置 jest 的运行命令:

"scripts": {
  "test": "jest",
  "test:watch": "jest --watch"
}

编写测试用例,以 Button 组件为例,新建 Button.tsx 文件:

import React, { ReactNode } from 'react'

interface Props {
  /** 按钮文字 */
  children: ReactNode,
  /** 点击回调 */
  onClick: () => void
}

const Button = (props: Props) => {
  const {
    children,
    onClick
  } = props

  return (
    <button onClick={onClick}>
      {children}
    </button>    
  )
}

export default Button

编写测试用例,测试 Button 的渲染结果和点击事件,新建 Button.test.tsx 文件:

import { render, screen } from '@testing-library/react';n
import Button from './Button';

test('Button组件渲染', () => {
  render(<Button onClick={() => {}}>Click me</Button>);
  const buttonElement = screen.getByText(/click me/i);
  expect(buttonElement).toBeInTheDocument();
});

test('Button组件点击', () => {
  const handleClick = jest.fn();
  render(<Button onClick={handleClick}>Click me</Button>);
  const buttonElement = screen.getByText(/click me/i);
  buttonElement.click();
  expect(handleClick).toHaveBeenCalled();
});

运行 pnpm run test 命令跑测试用例,可以看到下面结果:

61c598c6-b117-4a9c-b6e1-e192c9232815.webp

这样就完成了一个简单的测试用例编写。

hook测试用例

上述是针对函数组件编写的测试用例,对于 hook 可以使用 @testing-library/react 的 renderHook 方法来测试。

注:旧版本的 react-test-renderer 不支持 hook 测试,所以需要使用 @testing-library/react-hooks。

随便拿个 ahook 的 hook 来举例,比如 useLockFn

function useLockFn<P extends any[] = any[], V = any>(fn: (...args: P) => Promise<V>) {
  const lockRef = useRef(false);

  return useCallback(
    async (...args: P) => {
      if (lockRef.current) return;
      lockRef.current = true;
      try {
        const ret = await fn(...args);
        return ret;
      } catch (e) {
        throw e;
      } finally {
        lockRef.current = false;
      }
    },
    [fn],
  );
}

编写测试用例,新建 useLockFn.test.ts 文件:

import { renderHook, act } from '@testing-library/react';
import { useRef } from 'react';
import useLockFn from './useLockFn';

describe('useLockFn', () => {
  let mockFn;

  beforeEach(() => {
    // 创建一个模拟函数,该函数会返回一个 Promise
    // 并在异步操作完成后返回 done 
    mockFn = jest.fn(() => new Promise(resolve => setTimeout(() => resolve('done'), 100)));
  });

  it('当多个并发调用时,函数仅被调用一次', async () => {
    // 使用 renderHook 执行 useLockFn Hook
    const { result, waitForNextUpdate } = renderHook(() => useLockFn(mockFn));

    // 获取锁定后的函数引用
    const lockedFn = result.current;

    // 创建多个并发调用的 Promise 数组
    const promises = [];
    for (let i = 0; i < 5; i++) {
      promises.push(lockedFn());
    }

    await Promise.all(promises);

    expect(mockFn).toHaveBeenCalledTimes(1);
  });

  it('当函数已被锁定时,不应再次调用函数', async () => {
    const { result } = renderHook(() => useLockFn(mockFn));

    // 获取锁定后的函数引用
    const lockedFn = result.current;

    await act(async () => {
      await lockedFn();
    });

    // 当函数锁定时,再次尝试调用
    const secondCallPromise = act(() => lockedFn());

    // 验证 mockFn 的调用次数为 1
    expect(mockFn).toHaveBeenCalledTimes(1);

    await expect(secondCallPromise).rejects.toBeUndefined();
  });

  it('函数在调用后应被正确解锁', async () => {
    const { result } = renderHook(() => useLockFn(mockFn));

    const lockedFn = result.current;

    await act(async () => {
      await lockedFn();
    });

    expect(result.current).not.toThrow();
  });
});