使用Vitest + React Testing Library进行单元测试

4,094 阅读6分钟

前言

本文将在项目中添加Vitest用于单元测试,并将React Testing Library集成到Vitest中,用于测试React组件。同样,这套方案可以被集成到任意React项目中,如Next.js

通过5分钟阅读10分钟实践掌握解决方案,后文附有完整源码

Vitest

Vitest是由 Vite 提供支持的极速单元测试框架,最近十分火热,主要是因为以下Vitest具有以下几个优点:

  1. Vitest为单元测试提供更快的运行速度
  2. 可以无缝的将 Jest 替换成 Vitest
  3. 开箱即用的 TypeScript / JSX 支持

React Testing Library

React Testing Library用于 DOM 和 UI 组件测试的一系列工具,主要 API 包含 DOM 查询。UI 测试工具还有 Airbnb 的 enzyme (opens new window)。后文简称RTL。

经过本人在团队业务中的亲身实践,放弃enzyme的原因有:

  • Enzyme 依赖 React 的内部实现,React 团队不鼓励使用它
  • Enzyme 不适用于函数式组件,因为函数式组件没有实例,所以无法通过类似 instance.xxx 的方式来对状态进行验证,也无法通过 instance.method的方式来获取组件实例的方法
  • React 17目前只能使用非官方的 Enzyme 适配器(比如 @wojtekmaj/enzyme-adapter-react-17)
  • RTL 可以作为 Enzyme 的替代品。RTL 不直接测试组件的实现细节,而是从一个 React 应用的角度去测试。

代码配置

安装依赖

首先我们在项目中安装Vitest

yarn add vitest -D

然后在package.json中添加如下代码

"scripts": {
   ...
    "test": "vitest"
	 ...
  }

然后在项目文件夹内新增__test__文件夹

mkdir __test__

在__test__文件夹中新建test.test.ts

import { describe, it, expect } from "vitest";
describe("suite", () => {
  it("test true", async () => {
    expect(true).toBe(true);
  });
  it("test false", async () => {
    expect(false).toBe(false);
  });
});

这部分的describe, it, expect跟jest里的使用方式一样,唯一的区别在于需要从vitest里导出

Untitled.png

Vitest配置

在项目文件夹内新增vite.config.js配置

项目内不存在vite

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    // ...
  },
})

项目内存在vite

我的项目内采用的是vite,所以后文的所有配置基于此配置进行

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";

export default defineConfig({
  plugins: [react()],
});

React Testing Library配置

接下来我们将安装RTL,用于React组件的测试。

yarn add @testing-library/react @vitejs/plugin-react jsdom -D

jsdom是许多Web标准的纯JavaScript实现,特别是WHATWG DOMHTML标准,用于Node.js。此处需要注意开发机器上的node版本,可以考虑采用nvm切换不同的node版本。

jsdom的基本用法如下:

const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const dom = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);
console.log(dom.window.document.querySelector("p").textContent); // "Hello world"

const { window } = new JSDOM(`...`);
// or even
const { document } = (new JSDOM(`...`)).window;

Viteset集成RTL的配置

接下来我们将RTL集成到vitest中

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";

// <https://vitejs.dev/config/>
export default defineConfig({
  plugins: [react()],
});

测试App组件

接下来我们进行React组件的测试,首先在test目录下新建一个app.test.ts文件,导入以下代码。这段的判断是是否能渲染APP组件,并且APP组件dom的文字节点内包含「vitest」字符串,用RTL文档内的描述就是:这将搜索具有与给定 Text 匹配的 textContent 的文本节点的所有元素。

import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { App } from "../src/App";
import React from "react";
describe("App", () => {
  it("it should be render", () => {
    render(<App />);
    expect(screen.getByText("vitest")).toBeInTheDocument();
  });
});

运行yarn test Untitled 1.png

这时在控制台报错,vitest 默认没有 toBeInTheDocument 方法, toBeInTheDocument 是 RTL中的断言方法。因此我们需要新增一个配置文件,让vitest去继承RTL的断言库。新建一个RTLInVitest.setup.ts文件

import { expect, afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import matchers, {
  TestingLibraryMatchers,
} from "@testing-library/jest-dom/matchers";

declare global {
  namespace Vi {
    interface JestAssertion<T = any>
      extends jest.Matchers<void, T>,
        TestingLibraryMatchers<T, void> {}
  }
}
// 继承 testing-library 的扩展 except
expect.extend(matchers);
// 全局设置清理函数,避免每个测试文件手动清理
afterEach(() => {
  cleanup();
});

在vite.config.js中引入该ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";

// <https://vitejs.dev/config/>
export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    setupFiles: "./RTLInVitest.setup.ts",
  },
});

运行yarn test

Untitled 2.png

编写React组件

我们简单完成一个DataList的组件,其中通过数据map多个DataItem组件。DataList组件支持传入data,createable和flex参数,

DataList

import { DataItem } from "./DataItem";

interface Props {
  createable?: boolean;
  flex?: boolean;
  data: DataItemType[];
}
export type DataItemType = { name: string; id: string };

export function DataList({ data, createable, flex }: Props) {
  return (
    <div>
      {data.map((item) => (
        <DataItem
          key={item.id}
          createable={createable}
          item={item}
          flex={flex}
        />
      ))}
    </div>
  );
}

DataItem

import { DataItemType } from "./DataList";

interface Props {
  createable?: boolean;
  flex?: boolean;
  item: DataItemType;
}

export function DataItem({ item, createable, flex }: Props) {
  return (
    <div
      className="dataItem"
      key={item.id}
      style={{ display: flex ? "flex" : "block" }}
    >
      <div>
        <span>id:{item.id}</span>
        <span>name:{item.name}</span>
      </div>

      {createable && (
        <div>
          <button>create</button>
          <button>delete</button>
        </div>
      )}
    </div>
  );
}

测试自定义的React组件

接下来我们进行React组件的测试,首先在__test__目录下新建一个data-list.test.ts文件。

import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { DataList } from "../src/component/DataList";
import React from "react";
const mockDataLength = 3;
const mockData = Array.from({ length: mockDataLength }, (_, index) => ({
  name: `${index}`,
  id: `${index}`,
}));

describe("DataList", () => {
  it("it should be render", () => {
    const { container } = render(<DataList data={mockData} />);
    expect(container).toMatchSnapshot();
  });

  it("it should be createable with createable props", () => {
    const { container } = render(<DataList data={mockData} createable />);
    expect(screen.queryAllByText("create")).toHaveLength(mockData.length);
    expect(screen.queryAllByText("delete")).toHaveLength(mockData.length);
  });

  it("it should not be createable without createable props", () => {
    const { container } = render(<DataList data={mockData} />);
    expect(screen.queryAllByText("create")).toHaveLength(0);
    expect(screen.queryAllByText("delete")).toHaveLength(0);
  });

  it("it should be flex with flex props", () => {
    const { container } = render(<DataList data={mockData} flex />);
    const targets = document.querySelectorAll(".dataItem");
    const matchs = [...targets].filter((item) =>
      item.getAttribute("style")?.includes("flex")
    );
    expect(matchs).toHaveLength(mockData.length);
  });

  it("it should not be flex with flex props", () => {
    const { container } = render(<DataList data={mockData} />);
    const targets = document.querySelectorAll(".dataItem");
    const matchs = [...targets].filter((item) =>
      item.getAttribute("style")?.includes("block")
    );
    expect(matchs).toHaveLength(mockData.length);
  });
});

接下来运行yarn test

Untitled 3.png

如果测试没有通过,vitest也会提示出具体的期望和实际以及错误的行数,用于修复

Untitled 4.png

TDD 测试驱动开发

关于TDD和BDD网上有各种不同的说法,个人理解的TDD,就是首先明确自己想要写什么代码,尽量从最简单的开始,写一个测试,再去具体地实现这小段代码,以达到这个测试通过的效果,之后再继续写测试、实现、测试通过,这样不停循环重构。

例如写一个函数,我们可以首先写函数的调用的测试,然后实现代码并测试成功后,再测试函数的返回值,再继续实现函数...

TDD的好处就是会减少程序逻辑的错误,尽可能地减少bug。而缺点就是如果更改了代码的实现逻辑,就需要修改测试,可能会使得测试代码难以维护。

BDD是TDD的一种补充,在TDD的基础上,采用了更详细的功能描述,通过考虑用户的行为、组件的功能,来编写测试。

考虑到项目的复杂度和依赖性,TDD需要简单和易于测试的代码,否则需要开发人员花较多的时间进行一些桥接的工作。因此个人比较推荐BDD的测试模式,通过功能和行为的描述,即行为/条件...结果...,进行测试。

但是具体的模式可以根据实际开发工作进行选择。

TDD和BDD的比较可以参考: 关于TDD和BDD

后记

demo代码:uWydnA/TestingLibraryInVitestSetup: TestingLibraryInVitestSetup (github.com)

参考文档:

React单元测试策略及落地 - 简书 (jianshu.com)

node.js - 关于TDD和BDD - 个人文章 - SegmentFault 思否