React 单元测试初体验

367 阅读3分钟

背景

前几天一个需求让给三年前的系统改个文案,而由于历史包袱的原因,该页面之前的代码无法通过 husky 的 eslint 规则校验,我格式化了下代码查看我的需求没有问题就上线了。

上线没十分钟,运营就来找我说,复制按钮无法复制了。

马上回去 debugger 代码,发现原因竟然是

之前的代码(逻辑很奇怪,我也不知道为什么是这样

<Button
  className='ml5'
  type='primary'
  size='small'
  onClick={onCopy(props?.content?.props?.children?.props?.children)}>
  复制
</Button>

render() {
  <>
    .......
  </>
}

然后命中了 Fragments should contain more than one child - otherwise, there’s no need for a Fragment at all 这条 eslint 规则,格式化后自动去掉了<></> 导致onCopy函数无法使用了。

整个无语住。 E0A13000-C96D-49EB-9400-9FB5C74AF8E8

说明,如果一个没有测试兜底的系统,无论多小的改动,很可能会导致之前的功能崩坏,更不用说重构某个功能了。

那如何添加一个测试,话不多说,直接开干

  • vite 快速创建一个 react 项目 pnpm create vite react-test --template react-ts

  • 安装相关依赖 pnpm add -D babel-jest @babel/core @babel/preset-env @babel/preset-typescript ts-jest @jest/globals

  • 根目录创建.babelrc 文件夹

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "node": "current"
        }
      }
    ],
    "@babel/preset-typescript"
  ]
}
  • package.json 添加指令
{
  "scripts": {
    "test": "jest"
  }
}
  • 实现简单的测试

在根目录下创建名为test的文件夹 添加两个文件

创建sum.ts文件,内容为:

export default (a: number, b: number) => {
  return a + b;
};

创建sum.test.ts,内容为:

import { describe, expect, test } from "@jest/globals";
import sum from "./sum";
describe("sum module", () => {
  test("adds 1 + 2 to equal 3", () => {
    expect(sum(1, 2)).toBe(3);
  });
});

run pnpm test 控制台会打印以下信息:

PASS  ./sum.test.js
✓ adds 1 + 2 to equal 3 (5ms)

自此我们的第一个基础测试已经实现,下面我们尝试使用 jest 来测试自定义 hook

创建名为index.test.ts文件,内容为:

import { expect, test } from "@jest/globals";
import { useState, useEffect } from "react";

interface Item {
  key: number;
  title: string;
}
type TypeList = Item[];
const defaultVal = [{ title: "吃饭", key: 1 }];
function useTodolist() {
  const [list, setList] = useState<TypeList>([]);
  useEffect(() => {
    setList(defaultVal);
  }, []);

  return list;
}

test("happy path", () => {
  const list = useTodolist();
  expect(list).toEqual(defaultVal);
});

run pnpm test 我们期待控制台会输出:

PASS  ./index.test.js
✓ happy path (5ms)

不出意外的话 大概率要出意外了。控制台输出:

Warning: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
      1. You might have mismatching versions of React and the renderer (such as React DOM)
      2. You might be breaking the Rules of Hooks
      3. You might have more than one copy of React in the same app
happy path
TypeError: Cannot read properties of null (reading 'useState')

我们安装一个新的库

pnpm add -D @testing-library/react

我们修改一下index.test.ts文件

import { renderHook } from "@testing-library/react";
import { expect, test } from "@jest/globals";
import { useState, useEffect } from "react";

export default function useName() {
  const [name, setName] = useState("");
  useEffect(() => {
    setName("Alice");
  }, []);

  return name;
}

test("happy path", () => {
  const { result } = renderHook(() => useName());
  expect(result.current).toBe("Alice");
});

run pnpm test

 ● happy path

    The error below may be caused by using the wrong test environment, see https://jestjs.io/docs/configuration#testenvironment-string.
    Consider using the "jsdom" test environment.

测试环境有误,需要使用jsdom环境

pnpm i -D jest-environment-jsdom
  • package.json 添加:
{
  "jest": {
    "testEnvironment": "jsdom"
  }
}

run pnpm test

ok 终于成功了。

我们给 hook 添加新增和删除功能

import { describe, expect, test } from "@jest/globals";
import { renderHook, act } from "@testing-library/react";
import useTodolist from "./useTodolist";
import { useState } from "react";
let uid = 0;

interface Item {
  key: number;
  title: string;
}
type TypeList = Item[];

const useTodolist = () => {
  const [list, setList] = useState<TypeList>([]);
  const handleRemove = (index: number) => {
    setList((v) => v.filter((v, idx) => idx !== index));
  };
  const handleAdd = (title: string) => {
    setList((v) => [...v, { key: uid++, title }]);
  };
  return {
    list,
    handleRemove,
    handleAdd,
  };
};

describe("useTodolist", () => {
  test("happy path", async () => {
    const { result } = renderHook(() => useTodolist());
    const { handleAdd, handleRemove } = result.current;
    expect(result.current.list.length).toBe(0);
    act(() => {
      handleAdd("吃饭");
    });
    expect(result.current.list.length).toBe(1);
    act(() => {
      handleAdd("睡觉");
    });
    expect(result.current.list.length).toBe(2);
    act(() => {
      handleRemove(1);
    });
    expect(result.current.list.length).toBe(1);
  });
});

注意的是 @testing-library/react-hooks 中,它并没有额外的差异,是同一个函数。在组件状态更新时,组件需要被重新渲染,而这个重渲染是需要 React 调度的,因此是个异步的过程。通过使用 act 函数,我们可以将所有会更新到组件状态的操作封装在它的 callback 里面来保证 act 函数执行完之后我们定义的组件已经完成了重新渲染。

项目地址

总结

本次使用 jest + @testing-library 实现了对一个自定义简单的 hook 的功能测试,而真正的业务远比这个 demo 复杂更多,对此还需要更多的探索。

愿世上没有 bug! 952FE821-4015-4163-B62A-F0DB1259CA2C