组件单元测试最佳实践

1,212 阅读7分钟

一、单元测试是什么?

单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证 - 百科

二、收益有哪些?

  • 快速反馈异常,问题及早暴露
  • 大版本平稳重构升级,降低测试、全量回归成本
  • CI/CD 的基本盘,保障工程质量,在 MR、PUSH 等阶段自动触发
  • 提升代码质量
  • 优秀的单元测试用例也是最佳的代码使用文档,方便新同学上手

三、选择什么单元测试框架?

方案选用为 jest + testing-library + node-canvas,原因让我们来看下面对比情况

常见组件库单测方案

组件库单元测试方案
antdJest + testing-library
arcoJest + testing-library
semiJest + enzyme
okeeJest + enzyme

单元测试框架对比

测试框架断言Mock异步快照测试报告star
Jest ✅默认支持默认支持友好默认支持默认支持41k
Mocha不支持(可配置)不支持(可配置)友好不支持(可配置)不支持(可配置)21.8k
jasmine默认支持默认支持不友好默认支持默认支持15.6k

单元测试 React 渲染器框架对比

React Testing Library ✅Enzyme
React 组件渲染@testing-library/jest-dom:用于 dom、样式类型等元素的选取(支持)enzyme-adapter-react:对 React 的适配器(支持)
异步渲染等待提供 waitfor (支持)需要通过递归轮训自行实现(不支持)
event 模拟@testing-library/user-event:用于单测场景下事件的模拟(支持)不需要额外安装 (支持)
hook 测试testing-library/react-hooks(支持)必须通过运行 React 组件的形式来测试(半支持)
canvas 模拟额外安装 Jest-canvas-mock / node-canvas额外安装 Jest-canvas-mock / node-canvas
新版 React 兼容性来自 Facebook 团队,更新及时 ✅迭代速度慢,从 React17 开始是使用者自行实现,且存在问题 ,目前官方只有一个维护者 ❌
运行速度(Button 组件)21s23s
start17.9k20k
Npm 近一年下载量

四、如何编写测试用例?

遵循 FIRST 原则

F ——Fast:快速

简洁、单一、快速运行

I ——Isolated:隔离

能在任何时间,以任何顺序,运行任何一个测试

// 错误示范 ❌
const obj = {};

test("str", () => {
  obj.str = "1";
});

test("str", () => {
  expect(obj.str).toEqual("1");
});

R ——Repeatable:可重复

单元测试需要保持运行稳定,每次运行都需要得到同样的结果

// 错误示范 ❌
test("random", () => {
  expect(Math.random() > 0.5).toBeTruthy();
});

S ——Self-verifying:自我验证

自动化,测试结果是简单的 true/false,程序能直接判断,不需要人工介入

T ——Timely:及时

最有效的方式是在开发功能前就规划好单元测试的 case,也就是 TDD 测试驱动开发

Jest 与 Testing-library 的使用

这里介绍部分常见和特殊用法,完整 api 可看 Jest 官方文档Testing-library 官方文档

单元测试描述

标准单元测试的构成

describe("被测试的对象", () => {
  it("该对象的什么功能", () => {
    expect(实际得到的结果).toEqual(期望的结果);
  });
});

组件单元测试例子

import { render, screen } from "@testing-library/react";

describe("Button", () => {
  it("Button props text", async () => {
    render(<Button text="按钮文案" />);

    expect(screen.getBytext("按钮文案")).toBeVisible();
  });
});

常见场景例子

1. toMatchSnapshot 节点快照

例子

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

describe('Button', () => {
  it('Button render', async () => {
    const {container} =render(<Button text="按钮文案" />);

    expect(container).toMatchSnapshot();
  });
});

快照结果

// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Button Button render`] = `
     <div>
       <div>
         <button>
           按钮文案
         </button>
       </div>
     </div>
     `;

2. 断言节点是否存在、显示/隐藏

  • toBeVisible 断言节点是否显示

  • toBeInTheDocument 断言节点是否被销毁

import { render, screen } from "@testing-library/react";

describe("Drawer", () => {
  it("Drawer content toBeShow", () => {
    render(
      <Drawer show={true}>
        <p>抽屉内容</p>
      </Drawer>
    );

    expect(screen.getBytext("抽屉内容")).toBeVisible();
  });

  it("Drawer content toBeHide", () => {
    render(
      <Drawer show={false}>
        <p>抽屉内容</p>
      </Drawer>
    );

    expect(screen.getBytext("抽屉内容")).not.toBeVisible();
  });

  it("Drawer content tobeDestroy", () => {
    render(
      <Drawer hideToDestroy={true} show={false}>
        <p>抽屉内容</p>
      </Drawer>
    );

    expect(screen.getBytext("抽屉内容")).toBeInTheDocument();
  });

  it("Drawer content not tobeDestroy", () => {
    render(
      <Drawer hideToDestroy={true} show={false}>
        <p>抽屉内容</p>
      </Drawer>
    );

    expect(screen.getBytext("抽屉内容")).not.toBeInTheDocument();
  });
});

3. 断言组件事件是否触发,以及回调函数值是否符合预期

  • toBeCalledTimes(n) 断言函数触发过 n 次
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

describe("RadioGroup", () => {
  it("RadioGroup onValueChange", async () => {
    const onValueChange = jest.fn((val) => val);

    render(
      <RadioGroup onValueChange={onValueChange}>
        <RadioGroup.Radio value="fill">fill</RadioGroup.Radio>
        <RadioGroup.Radio value="border">border</RadioGroup.Radio>
      </RadioGroup>
    );

    await userEvent.click(screen.getBytext("border"));

    // 断言 onValueChange 被调用过一次
    expect(onValueChange).toBeCalledTimes(1);

    // 第一次函数调用的返回值是 border
    expect(onValueChange.mock.results[0].value).toBe("border");
  });
});

4. 要键盘热键触发的场景

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

describe("Input", () => {
  it("Input events input", async () => {
    const onValueChange = jest.fn((val) => val);
    const testText = "测试输入";

    render(<Input onValueChange={onValueChange}></Input>);

    await userEvent.type(screen.getByRole("textbox"), testText);

    expect(eventsInput).toBeCalledTimes(4);

    const val = eventsInput.mock.results;

    expect(val?.[0].value.value).toEqual(testText.substring(0, 1));
    expect(val?.[1].value.value).toEqual(testText.substring(0, 2));
    expect(val?.[2].value.value).toEqual(testText.substring(0, 3));
    expect(val?.[3].value.value).toEqual(testText.substring(0, 4));
  });
});
  • userEvent.keyword 方法可以模拟一系列键盘事件,包括 Tab、Enter、Backspace、Delete 等
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

describe("Drawer", () => {
  it("Drawer content toBeShow", () => {
    render(
      <Drawer show={true}>
        <p>抽屉内容</p>
      </Drawer>
    );

    await userEvent.keyboard("{Escape}");

    expect(screen.getBytext("抽屉内容")).not.toBeVisible();
  });
});

5. 断言是否存在某个 classname 或 style 样式

  • toHaveStyle
  • toHaveClass
import { render, screen } from "@testing-library/react";

describe("Button", () => {
  it("toHaveStyle example", async () => {
    render(<Button text="按钮文案" style={{ width: 200 }} />);

    expect(screen.getBytext("按钮文案")).toHaveStyle({
      width: "200px",
    });
  });

  it("toHaveClass example", async () => {
    render(<Button text="按钮文案" className="test-class" />);

    expect(screen.getBytext("按钮文案")).toHaveClass("test-class");
  });
});

6. canvas 组件场景下的方案

6.1 利用 jest-canvas-mock 记录所有绘制路径
  • 优势

    • 明确记录所有行为,排查问题可以及时发现问题节点进行反馈
  • 缺点

    • 需要很大的快照空间,对比速度慢
import { render, screen } from "@testing-library/react";
import { setupJestCanvasMock } from "jest-canvas-mock";

describe("lineChart", () => {
  beforeEach(() => {
    jest.resetAllMocks();
    setupJestCanvasMock();
  });

  it("linecanvas events", async () => {
    const { container } = render(<LineChart />);

    const canvas = container.querySelector("canvas");
    const ctx = canvas?.getContext("2d");

    const events = ctx && ctx.__getEvents();
    expect(events).toMatchSnapshot();
  });
});
  • 快照结果
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Canvas path 1`] = `
Array [
  Object {
    "props": Object {},
    "transform": Array [1,0,0,1,0,0,],
    "type": "beginPath",
  },
  Object {
    "props": Object {
      "value": "#6fce29",
    },
    "transform": Array [1,0,0,1,0,0,],
    "type": "fillStyle",
  },
  Object {
    "props": Object {
      "height": 100,
      "width": 100,
      "x": 0,
      "y": 0,
    },
    "transform": Array [1,0,0,1,0,0,],
    "type": "fillRect",
  },
]
`;
6.2 利用 canvas.toDataURL 生成 base64 快照进行比对
  • 优势

    • 存储空间小,对比速度快
  • 缺点

    • 提示有差异后,需要通过浏览器查看 base64 后手动对比差异
import { render, screen } from "@testing-library/react";

describe("lineChart", () => {
  it("linecanvas create base64 snapshot", async () => {
    const { container } = render(<LineChart />);

    const canvas = container.querySelector("canvas");

    const base64Snapshot = canvas?.toDataURL("image/jpeg", 1);

    expect(base64Snapshot).toMatchSnapshot();
  });
});
  • 快照结果
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MeasureCard MeasureCard canvas imageData 1`] = `
Array [
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB...",
]
`;

7. Mock 第三方依赖

import axios from "axios";

jest.mock("axios");

test("should fetch data from API", async () => {
  const data = { results: [{ name: "John" }] };
  axios.get.mockResolvedValueOnce({ data });

  const response = await axios.get("/api/users");

  expect(response.data).toEqual(data);
  expect(axios.get).toHaveBeenCalledWith("/api/users");
});

8. 组件内部包含异步延迟处理逻辑

  • 假设组件内有个 button 在延迟 1s 后展示,可以利用 waitFor 进行等待
import { render, screen, waitFor } from "@testing-library/react";

test("MyComponent should render a button", async () => {
  render(<DelayComponent />);

  await waitFor(() => {
    const button = screen.getByRole("button");
    expect(button).toBeInTheDocument();
  });
});

9. 断言 throw error 场景

function myFunction() {
  throw new Error("My error message");
}

test("myFunction should throw an error", () => {
  expect(() => {
    myFunction();
  }).toThrow(new Error("My error message"));
});
  • toThrow 针对 promise 异步函数
async function myAsyncFunction() {
  throw new Error("My error message");
}

test("myAsyncFunction should throw an error", () => {
  return expect(myAsyncFunction()).rejects.toThrow(
    new Error("My error message")
  );
});

五、单元测试报告分析

通过配置 jest.config 中的 coverageDirectory 可以指定单元测试报告的生成目录

单元测试报告中包含什么?

执行 npm run testnpx jest 后会生成 coverage 目录,打开目录中的 index.html 可查看报告具体内容 代码覆盖率(执行过的代码 / 可执行的代码 * 100),也称为测试覆盖率,是用来度量代码测试完整性的一个指标

image.png

  • Statements(语句覆盖率):程序中有多少语句已执行
  • Branches(分支覆盖率):控制结构的分支(例如 if 语句)中有多少已执行
  • Functions(函数覆盖率): 已定义的函数中有多少被调用
  • Lines(行覆盖率):是否每行都执行过,大部分情况下等于 Statements

六、将单元测试集成到 ci 当中

这里以 github 举例,在公司基建配置会稍有不同,原理就是将 jest 的报告目录上传给 codecov

1. 打开 codecov,用 github 账户后,选中项目获取对应 token

image.png

2. 在 github 对应项目中以 secrets 形式配置 token 防止泄露

配置路径: 项目 settings > secrets and variable > actions

image.png

3. 在项目中配置 github ci

根目录创建 .github/workflows/ci.yml 文件

name: test

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]
  workflow_dispatch:

jobs:
  coverage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: "16"
      - name: install
        run: npm install
      - name: test
        run: npm run test --coverage
      - name: Upload coverage reports to Codecov
        uses: codecov/codecov-action@v3
        env:
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

4. 查看测试报告

对项目 master 分支发起 push 和 pr 时触发 github action ci

image.png

在 pr 中可以看到报告,点击链接可以跳转到 codecov 看更具体信息

image.png

codecov 中可以看到具体行数覆盖情况

image.png

七、jest 小技巧

1. 多进程运行 jest

--maxWorkers=|

通过开启多进程方式提高 jest 运行速度,默认为 cpu 核心数 - 1

2. 安装 jest runner 插件快速调试

image.png

3. 在 ci 环境中上传 jest cache 提高运行速度

在 ci.yml 中新增如下配置(以 github 配置举例)

image.png