前言

我想大部分人的前端测试,都是运行项目,直接在浏览器上操作,看看功能正不正常。虽然明明有测试库可以使用,但是因为“要快”的原因,让好好做测试变成了一件影响效率的事。

因为这种无奈的原因而放弃测试,实在是很可惜。这种原因也并不能够说明测试没有必要,测试仍然是需要重视的东西。

我将简单介绍如何在 React 中进行单测。本文中使用的代码仍然是通过 vite 创建的 React-ts 项目,所以可能不适用于其他的项目。

我们需要什么东西?

我们需要安装几个包,很烦。每个包的功能当然是不一样的,更难受的是这些测试库既然依赖于其他包的功能,为什么不干脆集成在一起呢。

我先总的介绍一下这几个包的关系:

  1. vitest:单测库,用于自动运行测试代码,下面介绍的几个包,会通过 vitest 运行起来。
  2. @testing-library/react:testing-library 是个 UI 测试库,用于在测试中模仿浏览器渲染组件,@testing-library/react 是指适用于 react 的版本。
  3. happy-dom:用于在测试中提供浏览器的 document 功能,如果没有这个包,测试中会抛出 document is not defined,所以我们需要提供这个 document
  4. msw:提供 mock 功能的库。如果你测试的组件中有发起请求,那么在测试中需要 mock 这些请求。如果没有发起请求,也可以无需要这个 mock 库。在我之前的文章 React 简单教程-5-使用 mock 中有介绍过这个库在开发时的用法,但是开发时的 mock 用法跟我们测试时的 mock 用法是不同的。

没错,上面这几个包都是我们需要安装的。因为是在开发时使用,所以都在安装到 devDependencies 下。这几个包你都可以直接搜索名字方便地找到官网。

npm i -D vitest
npm i -D @testing-library/react
npm i -D happy-dom
npm i -D msw

都安装好后,我们就开始配置了。

配置

使用 vitest 的好处之一就是节省一个配置文件。vitest 的配置可以写在 vite.config.ts 文件中。

vite.config.ts 文件的 defineConfig 中添加 test 节点,这个节点就是我们 vitest 的配置。

/// <reference types="vitest" />

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: "happy-dom",
},
});

上面代码的第一行是 ts 的三斜线指令,功能是用于引入 vitest。如果没有这一句,编译器会警告你没有 test 字段的定义。因为我们的 test 字段是 vitest 提供的功能,所以类型声明也需要由它提供。

接下来看 test 字段。先看 environment,这个字段用于配置我们测试运行时的环境,这里的值为我们安装了的 happy-dom。如果没有配置这个字段,运行测试将出现 document is not defined 的错误。

在项目根目录下新建文件夹 test,我们有关测试的代码都将放在这个文件夹下面。

好的,配置的部分完毕,现在我们简单弄一个组件,然后来测试一下。

要测试的组件

我们要测试的组件代码如下,我直接放在了 src/displayer.tsx 里:

import { useState } from "react";
import "./displayer.css"; export function Displayer(props: { name: string, content: string }) {
const name = props.name;
const [hidden, setHidden] = useState(false);
const [content, setContent] = useState(props.content); function handleClick() {
setHidden(true);
} async function handleRequest() {
const result = await fetch("https://api.backend.dev/getSth");
const j = await result.json();
setContent(JSON.stringify(j));
} return (
<div className="container">
<div className="nav">
<span className="btn-red"></span>
<button
className="btn-yellow"
data-testid="yellow"
onClick={handleRequest}
></button>
<button
className="btn-gray"
data-testid="gray"
onClick={handleClick}
></button>
</div>
{hidden || (
<div className="body" role="displayer-content">
<div>{name}</div>
<div>{content}</div>
</div>
)}
</div>
);
}

相关的样式代码如下:

.container {
background-color: rgb(31 41 55 / 1);
background-color: rgb(31 41 55 /1);
padding: 0.5rem;
margin: 0.5rem;
border-radius: 0.5rem;
} .nav {
display: flex;
margin-bottom: 0.3rem;
} .btn-red {
height: 12px;
width: 12px;
background-color: #ff1d1d;
border-radius: 15px;
} .btn-yellow {
height: 12px;
width: 12px;
background-color: rgb(255, 251, 29);
border-radius: 15px;
margin: auto 0.5rem;
} .btn-gray {
height: 12px;
width: 12px;
background-color: rgb(220, 220, 220);
border-radius: 15px;
} .body {
padding: 0.5rem 0;
}

这个组件的功能是点击左上角黄色的按钮,会发起一个请求,点击灰色的按钮会隐藏下方的内容。那么我们要测试的功能,就有两个:

  1. 点击灰色按钮,查看内容元素有没有隐藏
  2. 点击黄色按钮,查看有没有发起请求,需要 MOCK

由于这是同一个组件的测试,所以我们可以写到一个测试文件里,测试文件的名字也有讲究,我们使用 ts 编写,所以必须以 .test.tsx.spec.tsx结尾。

在 test 文件夹中新建 displayer.test.tsx 测试文件,当我们使用 vitest 测试时,vitest 会自动找到这些测试文件运行。

导入我们需要的测试套件:

import { assert, describe, it } from "vitest";

describe("test displayer", () => {
it("load and click gray button", () => {});
it("load and click yellow button", () => {});
});

上面的 describe() 方法用于定义一套测试内容,第一个参数是名字,你可以起这套测试的名字,第二个参数测试内容,你能够看到内容中我使用 it() 方法,这个方法用于定义具体的测试内容。

所以你看,其实 describe() 可以不使用,直接使用 it 定义测试也可以的。运行测试会执行 it 方法。

看我们代码里的 it 方法,第一个参数是测试名,用于简单描述测试什么,"load and click gray button",描述了我们将加载这个组件并点击灰色按钮。第二个参数就是测试代码了。那么测试代码里应该怎么写呢?

一般情况下,测试代码都会遵循这个三个范式:

it("load and click gray button", () => {
// Arrange
// Act
// Assert
});

Arrange 测试前的准备,在这里,我们要先把组件渲染了;Act 测试行为,在这里,我们要模仿点击按钮的行为;Assert 测试断言,在这里,我要检查点击按钮后组件是否达到预期的行为。

那么我们便照着这三步骤来。

测试点击灰色按钮

在测试中渲染组件,需要导入 @testing-library/reactrender 方法,和我们的组件。然后使用 render 渲染:

import { render } from "@testing-library/react";
import { Displayer } from "../src/displayer"; it("load and click gray button", () => {
// Arrange
const { getByTestId } = render(
<Displayer name="test name" content="test content" />
);
});

render() 方法返回了一些方法,我们可以使用这些方法来获取组件里的信息,在断言时很有用。更多关于 render() 和返回值的信息查看 这里官网

下来要模拟点击灰色按钮,组件点击事件的触发需要导入 @testing-library/reactfireEvent

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

it("load and click gray button", () => {
// Arrange
const { getByTestId } = render(
<Displayer name="test name" content="test content" />
);
// Act
fireEvent.click(getByTestId("gray"));
});

这里简单使用了 fireEvent.click 方法来模拟点击,该方法需要的参数是点击的元素,那么我们怎么获取到元素呢?

代码里我们使用了 getByTestId(...) 方法获取,这个方法会指定带有属性 data-testid 的元素,如上面我们使用 getByTestId("gray") 获取元素属性data-testid 的值为 gray 的元素。关于更多查询元素的 API,这里查看

模拟点击后,我们便要检查组件点击后的行为是否正确,就到了断言这一步了。

在我们的组件里,点击灰色按钮后,内容元素(这个元素我标记了 role 属性为 displayer-content)将会被卸载,所以我们要尝试获取这个元素,预料中是获取不到的。

import { render, fireEvent } from "@testing-library/react";
import { assert, describe, it } from "vitest"; it("load and click gray button", () => {
// Arrange
const { getByTestId, queryByRole } = render(
<Displayer name="test name" content="test content" />
);
// Act
fireEvent.click(getByTestId("gray"));
// Assert
const body = queryByRole("displayer-content");
assert.isNull(body);
});

我们使用了 queryByRole() 来通过元素的 role 属性获取元素,注意我们使用的查询方法是 query... 开头的,在 testing-library 的规范中,query... 开头的方法在获取不到元素是会返回 null。关于更详细的查询方法规范查看这里

最后我们使用了 assert 断言,判断 body 元素应该是为空。如果断言通过,则测试通过,否则测试失败。

现在就来运行测试,先在 package.json 里配置测试的脚本:

{
"scripts": {
"test": "vitest"
},
}

该脚本将会运行 vitest 命令来启动测试,vitest 相当于 vitest watch,运行此命令,当我们修改了测试代码,就会自动测试修改的代码。在终端中输入 npm run test,你应该会看到如下信息:

这种和谐美满的输出表示测试成功。如果是下面这种充满铁和血的画面:

表示测试失败,还贴心地告诉你哪里失败。

需要 mock 的测试,点击黄色按钮

上面我们测试了组件的行为,但是如果有发起请求的事件,我们要怎么测试呢?当然是使用 mock 了,我上一章讲过 mock 库在开发时如何使用,React 简单教程-5-使用 mock。我们使用同一个 mock 库,但在测试时使用 mock 和开发时方法不同,单元测试时我们不需要拦截浏览器行为。

我们要测试的组件中,点击黄色按钮后会发起一个请求,这个行为就是我们需要 mock 的。思路就是,运行测试前启动 mock,测试结束后关闭 mock。

vitest 提供了两个方法,beforeAll() 将在所有测试开始前运行传入的方法,afterAll()将在所有测试开始后运行传入的方法。

先直接在测试文件中安装 mock 对象,:

//  省略

const mockObj = { userName: "admin" };
const mockResult = JSON.stringify(mockObj); const mockServer = setupServer(
rest.get("https://api.backend.dev/getSth", (req, res, ctx) => {
return res(ctx.json(mockObj));
})
); beforeAll(() => {
mockServer.listen();
}); afterAll(() => {
mockServer.close();
}); // 省略

如上,我们伪造的假响应 mockObj和它的字符串形式 mockResult,在 beforeAll() 中启动 mock,在 afterAll 中关闭 mock。

先回过头看组件中黄色按钮的实现:我们使用 fetch 发起了请求——注意!单测中的请求地址必须为完整地址,mock 中的也一样,然后将请求结果在内容元素中显示。由于我们 mock 了假响应,所以内容元素中显示的应该会是我们提供的假数据。

那么思路就有了,渲染组件,点击黄色按钮,找找看有没有假数据的信息。

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

it("load and click yellow button", async () => {
// Arrange
const { getByTestId, getByText } = render(
<Displayer name="test name" content="test content" />
);
// Act
fireEvent.click(getByTestId("yellow"));
const e = await waitFor(() => getByText(mockResult));
// Assert
assert.isNotNull(e);
});

看,我们使用了一个新的方法 waitFor(),这个方法接收另一个返回元素的方法,waitFor() 会一直尝试获取,直到获取到了或者超时,默认的超时事件是 1 秒,拿到元素后就将元素返回。

waitFor() 中我们使用了 getByText()get... 开头的方法如果拿不到元素就会抛出异常。由于我们是查找有没有包含我们提供的假数据的元素,所以,如果没有抛出异常的话就是找到了。最后的断言 assert.isNotNull(e); 也是可以不用的。

一定要亲自试一试。

两次一起测试的问题

写了两个测试,现在运行起来的话你会看到如下的错误:

下方还有详细错误信息和组件树的结构,意思是说你使用了 getByTestId("yellow") 获取元素,这个方法预期是只有一个元素的,现在拿到了多个,于是报错了。

不对啊,我们的组件中只有一个黄色按钮!详细看看错误信息显示出来的组件树,赫然有两个组件!

发生这种事的原因是测试时渲染组件后,会将组件放在一个虚拟的 dom 环境中测试,在我们的一个 it 测试用例中,测试后没有将这个组件清理了,就导致下一个测试用例需要渲染同一个组件,就重复添加了组件到虚拟的 dom 环境中。

解决这个问题,要用到 vitest 提供的另一个方法 afterEach(),这个方法将在每一个测试用例结束后执行,使用这个 cleanup() 清理渲染的组件。

afterEach(() => {
cleanup();
});

然后测试便可以正常执行。

完整测试代码

import { render, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { afterAll, afterEach, assert, beforeAll, describe, it } from "vitest";
import { rest } from "msw";
import { setupServer } from "msw/node"; import { Displayer } from "../src/displayer"; const mockObj = { userName: "admin" };
const mockResult = JSON.stringify(mockObj); const mockServer = setupServer(
rest.get("https://api.backend.dev/getSth", (req, res, ctx) => {
return res(ctx.json(mockObj));
})
); beforeAll(() => {
mockServer.listen();
}); afterAll(() => {
mockServer.close();
}); afterEach(() => {
cleanup();
}); describe("test displayer", () => {
it("load and click gray button", () => {
// Arrange
const { getByTestId, queryByRole } = render(
<Displayer name="test name" content="test content" />
);
// Act
fireEvent.click(getByTestId("gray"));
// Assert
const body = queryByRole("displayer-content");
assert.isNull(body);
}); it("load and click yellow button", async () => {
// Arrange
const { getByTestId, getByText } = render(
<Displayer name="test name" content="test content" />
);
// Act
fireEvent.click(getByTestId("yellow"));
const e = await waitFor(() => getByText(mockResult));
// Assert
assert.isNotNull(e);
});
});

参考资料

testing-library/react by testing-library

vitest by Vitest

msw by msw

React简单教程-6-单元测试的更多相关文章

  1. React简单教程-4-事件和hook

    前言 在上一章 React 简单教程-3-样式 中我们建立了一个子组件,并稍微美化了一下.在另一篇文章 React 简单教程-3.1-样式之使用 tailwindcss 章我们使用了 tailwind ...

  2. React简单教程-3-样式

    前言 在上一章 React 简单教程-2-ts 和组件参数 中我们新建的子组件 Displayer 没有样式,显得平平无奇,这一篇我们将给他美化一下. CSS 文件 一般的做法,是在你的组件级目录下新 ...

  3. React简单教程-2-ts和组件参数

    前言 在上一章:React 简单教程-1-组件 我们知道了 React 的组件是什么,长什么样,用 js 和 HTML 小小体验了一下组件.在这一章,我们将使用 typescript(简称 ts) 来 ...

  4. React简单教程-4.1-hook

    前言 虽然我们简单感受了一下 useState 的用法,但我想你还是对 React 里的 hook 迷迷糊糊的.本文我们将明确下 React 的概念. HOOK 前生今世 在我示例中,写的 React ...

  5. React简单教程-1-组件

    前言 React,Facebook开发的前端框架.当时Facebook对市面上的前端框架都不满意,于是自己捣鼓出了React,使用后觉得特别好用,于是就在2013年开源了. 我也用React开发了一个 ...

  6. React简单教程-5-使用mock

    前言 一个前后端分离的项目,前端人员需要对接后端的接口.如果在后端的接口没有开发好,或者没有测试版可以对接的情况下,前端人员也不能坐等后端接口写好后再开始开发. 一个项目的,理想情况下接口的规范应该是 ...

  7. React简单教程-3.1-样式之使用 tailwindcss

    前言 本文是作为一个额外内容,主要介绍 tailwindcss 的用法 tailwindcss 是一个功能类优先的 CSS 框架,我在以前的文章里有描述为什么使用功能类优先:为什么我在 css 里使用 ...

  8. react+redux教程(五)异步、单一state树结构、componentWillReceiveProps

    今天,我们要讲解的是异步.单一state树结构.componentWillReceiveProps这三个知识点. 例子 这个例子是官方的例子,主要是从Reddit中请求新闻列表来显示,可以切换reac ...

  9. react+redux教程(四)undo、devtools、router

    上节课,我们介绍了一些es6的新语法:react+redux教程(三)reduce().filter().map().some().every()....展开属性 今天我们通过解读redux-undo ...

随机推荐

  1. 拼写检查-c++

    [问题描述] 作为一个新的拼写检查程序开发团队的成员,您将编写一个模块,用已知的所有形式正确的词典来检查给定单词的正确性.        如果字典中没有这个词,那么可以用下列操作中的一个来替换正确的单 ...

  2. python 反序列化

    Python-反序列化函数使用 pickle.dump(obj, file) : 将对象序列化后保存到文件 pickle.load(file) : 读取文件, 将文件中的序列化内容反序列化为对象 pi ...

  3. Spring配置文件-Bean实例化的三种方式

    1.无参构造方法实例化(详见我的博客) 2.工厂静态方法实例化 创建StaticFactory类 public class StaticFactory { public static UserDao ...

  4. [python][flask] Jinja 模板入门

    Flask 和 Django 附带了强大的 Jinja 模板语言. 对于之前没有接触过模板语言的人来说,这类语言基本上就是包含一些变量,当准备渲染呈现 HTML 时,它们会被实际的值替换. 这些变量放 ...

  5. petite-vue源码剖析-逐行解读@vue-reactivity之Map和Set的reactive

    本篇我们会继续探索reactive函数中对Map/WeakMap/Set/WeakSet对象的代理实现. Map/WeakMap/Set/WeakSet的操作 由于WeakMap和WeakSet分别是 ...

  6. Istio实践(3)- 路由控制及多应用部署(netcore&springboot)

    前言:接上一篇istio应用部署及服务间调用,本文介绍通过构建.netcore与springboot简单服务应用,实现服务间调用及相关路由控制等 1..netcore代码介绍及应用部署 新建.netc ...

  7. Java之IO流技术详解

    何为IO? 首先,我们看看百度给出的解释. I/O输入/输出(Input/Output),分为IO设备和IO接口两个部分. i是写入,Input的首字母.o是输出,Output的首字母. IO 也称为 ...

  8. 团队Beta演示

    组长博客 本组(组名)所有成员 短学号 姓名 2236 王耀鑫(组长) 2210 陈超颖 2209 陈湘怡 2228 许培荣 2204 滕佳 2205 何佳琳 2237 沈梓耀 2233 陈志荣 22 ...

  9. 没错,华为开始对IoT下手了!

    最近,有很多粉丝在后台私信 想知道目前最热的技术是什么? 小编觉得,5G时代到来 物联网技术将迎来快速的发展 加上目前,国内物联网人才短缺 每年人才缺口达百万 IoT物联网将成为最热门的技术 最近,小 ...

  10. 《Java编程思想》读书笔记(二)

    三年之前就买了<Java编程思想>这本书,但是到现在为止都还没有好好看过这本书,这次希望能够坚持通读完整本书并整理好自己的读书笔记,上一篇文章是记录的第一章到第十章的内容,这一次记录的是第 ...