针对单测,JS 通常使用 Jest

此外,为了模拟 Dom 和事件,还需要 React Testing Library 进行辅助测试

Jest 的安装

对于一个不具备单测能力的 React 项目(如 vite),可以通过以下方式安装并配置 Jest

# 安装依赖
npm install --save-dev jest @types/jest @jest/types

# 初始化 Jest 配置
npx jest --init
# Choose the test environment that will be used for testing: 如果要涉及 dom 的单测就选 yes;只涉及 node 的纯逻辑就选 no
# Which provider should be used to instrument code for coverage:选择 babel,因为它可以转 ES5,避免兼容性问题

# 增加 babel 对应配置
npm install --save-dev babel-jest @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript

# 安装 ts-node
npm install ts-node --save-dev

# 如果碰到类似“找不到 jest-environment-jsdom”的报错,就自行安装一下对应的依赖
npm install jest-environment-jsdom --save-dev

在根目录创建一个 babel.config.cjs 用于配置 babel

// ./babel.config.cjs
module.exports = {
  presets: [
    ["@babel/preset-env", { targets: { node: "current" } }],
    ["@babel/preset-react", { runtime: "automatic" }], // 自动导入 React,不然后续单测的开发会要求对 React 进行 import。
    "@babel/preset-typescript",
  ],
};

配置额外的扩展名识别

因为 jest 不使用 webpack 等打包工具,因此不知道如何加载除了 js/jsx 之外的其他文件拓展名。所以需要加一个转换器

// jest.config.ts
export default {
  // ... other config
  transform: {
    // ...
    "^.+.(js|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
  },
};

Svg mock 转换

Jest 无法识别 svg。所以要对它进行 mock,返回相同的输出结果

// jest.config.ts
export default {
  // ... other config
  transform: {
    // ...
    "^.+.svg$": "<rootDir>/svg-transform.js",
  },
};

// ./svg-transform.js
export default {
  process() {
    return { code: "module.exports = {};" };
  },
  getCacheKey() {
    return "svgTransform"; // SVG固定返回这个字符串
  },
};

CSS 代理

由于 Jest 本身不知道如何处理不同扩展的文件,我们可以通过配置代理的方式,告知 Jest 将对此对象模拟为导入的 CSS 模块

npm install --save-dev identity-obj-proxy
// jest.config.ts
export default {
  // ... other config
  moduleNameMapper: {
    ".(css|less)$": "identity-obj-proxy", // 有使用 sass 需求的话可以把正则换成 ^\.(css|less|sass|scss)$
  },
};

React Testing Library 的安装

安装相关依赖:

# @testing-library/jest-dom:用于 dom、样式类型等元素的选取
# @testing-library/react:提供针对 React 的单测渲染能力
# @testing-library/user-event:用于单测场景下事件的模拟。
npm install @testing-library/jest-dom @testing-library/react @testing-library/user-event --save-dev

为了能让 expect 适配 React Testing Library 提供的相关断言,需要全局导入一下 @testing-library/jest-dom

在根目录新建一个 jest-dom-setup.js

//  jest_dom_setup.js
import "@testing-library/jest-dom";

然后将该文件配置到 jest.config.ts 中:

// jest.config.ts
export default {
  // 字段意义:将指定的配置文件,在安装测试框架之后、执行测试代码本身之前运行
  setupFilesAfterEnv: ["<rootDir>/jest-dom-setup.js"],
};

Jest 断言

一个最简单的单元测试长这样:

// ./src/App.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';

// describe 表示一组分组,其中可以包括多组 test
describe("test", () => {
    // test 用于定义单个的用例
    test('renders learn react link', () => {
        render(<App />);
        const linkElement = screen.getByText(/learn react/i);
        expect(linkElement).toBeInTheDocument();
    });
}

常见断言场景

基础类型的比较

import React from "react";

test("examples for jest expect", () => {
  // tobe
  expect(1 + 1).toBe(2);

  // not tobe
  expect(1 + 1).not.toBe(3);
  // 判断 Boolean
  expect(true).toBe(true);
  expect(true).toBeTruthy();
  expect(false).toBeFalsy();
  // 判断 undefined
  expect(undefined).toBe(undefined);
  expect(undefined).not.toBeDefined();
  expect(undefined).toBeUndefined();
  // 判断函数返回值
  const test = () => {};
  expect(test()).toBeUndefined();
  // 针对浮点数比较,用上面的 api 会出问题。需要使用专门提供的 `toBeCloseTo`,用于判断对象和预期的精度是否足够接近
  expect(0.2 + 0.1).toBeCloseTo(0.3);
});

引用类型的比较

import React from "react";

test("examples for jest expect", () => {
  // 对于深拷贝或是属性完全相同的对象,需要使用 toEqual。toEqual 会深度递归对象的每一个属性,进行深度比较。只要原始值相同,就能通过断言
  // toEqual 同样可以用于简单类型
  const a = { name: "name", age: 1 };
  const b = JSON.parse(JSON.stringify(a));
  expect(a).not.toBe(b);
  expect(a).toEqual(b);
});

数值比较

import React from "react";

test("examples for jest expect", () => {
  // >
  expect(3).toBeGreaterThan(2);
  // <
  expect(3).toBeLessThan(4);
  // >=
  expect(3).toBeGreaterThanOrEqual(3);
  // <=
  expect(3).toBeLessThanOrEqual(3);
});

正则匹配

import React from "react";

test("examples for jest expect", () => {
  // toMatch 会匹配字符串是否能满足正则的验证
  expect("regexp validation").toMatch(/regexp/);
  // toMatchObj 用于验证 value 是否是匹配对象的子集
  const obj1 = { props1: "test", props2: "regexp validation" };
  const obj2 = { props1: "test" };
  // 由于 obj2 是 obj1 的子集,所以验证通过
  expect(obj1).toMatchObj(obj2);
});

表单验证

import React from "react";

test("examples for jest expect", () => {
  // toContain 判断某个值是否存在于数组中
  expect([1, 2, 3]).toContain(1);
  // arrayContaining 匹配接收的数组。此处和 toEqual 使用可以用于判定数组 [1, 2] 是否是数组 [1, 2, 3] 的子集
  expect([1, 2, 3]).toEqual(expect.arrayContaining([1, 2]));
  // toContainEqual 判定某个对象元素是否在数组中
  expect([{ a: 1, b: 2 }]).toContainEqual({ a: 1, b: 2 });
  // toHaveLength 断言数组长度
  expect([1, 2, 3]).toHaveLength(3);
  // toHaveProperty 断言对象中是否包含某个属性
  // 针对多层级的对象,可以通过 xx.yy 的方式进行传参断言
  const obj = {
    props1: 1,
    props2: {
      props3: 2,
    },
  };
  expect(obj).toHaveProperty("props1");
  expect(obj).toHaveProperty("props2.props3");
});

错误抛出

import React from "react";

test("examples for jest expect", () => {
  const throwError = () => {
    const err = new Error("console err");
    throw err;
  };
  expect(throwError).toThrow();
  expect(throwError).toThrowError();

  const catchError = () => {
    try {
      const err = new Error("console err");
      throw err;
    } catch (err) {
      console.log(err);
    }
  };
  expect(catchError).not.toThrow();
  expect(catchError).not.toThrowError();
});

自定义断言

可以使用 Expect.extend 来自定义断言

同步的匹配器

e.g. 断言一个数字是否在 0-10 之间

test("0-10 sync test", () => {
  const toBeBetweenZeroAndTen = (num: number) => {
    if (num >= 0 && num <= 10) {
      return {
        message: () => "",
        pass: true,
      };
    }
    return {
      message: () => "expect num to be a number between 0 and 10",
      pass: false,
    };
  };
  expect.extend({
    toBeBetweenZeroAndTen,
  });

  expect(3).toBeBetweenZeroAndTen();
  expect(13).not.toBeBetweenZeroAndTen();
});

异步的匹配器

test("0-10 async test", async () => {
  const toBeBetweenZeroAndTen = async (num: number) => {
    const res = await new Promise<{ message: () => string; pass: boolean }>(
      (resolve) => {
        setTimeout(() => {
          if (num >= 0 && num <= 10) {
            resolve({
              message: () => "",
              pass: true,
            });
          } else {
            resolve({
              message: () => "expected num to be a number between zero and ten",
              pass: false,
            });
          }
        }, 1000);
      }
    );
    return (
      res || {
        message: () => "expected num to be a number between zero and ten",
        pass: false,
      }
    );
  };
  expect.extend({
    toBeBetweenZeroAndTen,
  });

  await expect(8).toBeBetweenZeroAndTen();
  await expect(11).not.toBeBetweenZeroAndTen();
});