一、前面的话
各位掘金的小伙伴大家好,新年的第一篇文章我打算介绍一下如何给我们的React前端组件做单元测试。
起因是笔者在公司也会维护一些公共的组件,这些复用的公共组件的使用频率和重要程度都在慢慢增加,相应的风险 和维护难度 也越来越大,因此很有必要为它们编写规范的单元测试。
本文就尝试以实操的方式给大家分享如何给我们的React组件做单元测试,从多方面讲解如何给一个React组件编写测试用例,耐心看完本篇文章,你会知道以下问题的答案:
- 为什么要写单元测试?
- mock 、spy 、断言 、快照的含义?
- 如何使用 vitest + @testing-library/react?
- 如何模拟用户事件?
- 如何测试react的hooks?
二、概念
(1) 为什么要单元测试
本来写业务代码就已经很劳心劳力了,为什么还要写单元测试 呢?这可能是包括我在内很多同学的想法,但我们知道:一件事件值不值得做,是看它获得的收益是否能够cover住投入的成本,而不是看这件事情本身是否麻烦或者复杂
,以下就是我们做单元测试可以得到的收益:
1、排除故障
每个应用的开发中,多少会出现一些意料之外的 bug。通过测试应用程序,可以帮助我们大大减少此类问题,并且增强应用程序的逻辑性。
2、保证团队成员的逻辑统一
如果您是团队的新成员,并且对应用程序还不熟悉,那么一组测试就好像是有经验的开发人员监视你编写代码,确保您处于代码应该执行的正确路线之内。通过这些测试,您可以确信在添加新功能或更改现有代码时不会破坏任何东西。
3、可以提高质量代码
当您在编写 React 组件时,由于考虑到测试,最好的方案将是创建独立的、更可重用的组件。如果您开始为您的组件编写测试,并且您注意到这些组件不容易测试,那么您可能会重构您的组件,最终起到改进它们的效果。
4、起到很好的说明文档作用
测试的另一个作用是,它可以为您的开发团队生成良好的文档。当某人对代码库还不熟悉时,他们可以查看测试以获得指导,这可以提供关于组件应该如何工作的意图的洞察,并为可能要测试的边缘部分提供线索。
如果以上的理由都无法说服你,那么我要说,书写规范的单元测试可以让你在你的同事面前狠狠的装上一杯,你的主管看到你的单元测试,也会心想:这小子,值得培养。升职加薪这不就来了么😊!
(2) 几个名词
如果以上的理由说服了你,或许你就会开始尝试写单元测试了,但是怎么开始呢?单元测试的建立的逻辑又是什么?
其实我们可以这么来理解,程序员其实就是老师,我们写的程序就像一个个"学生",保证这些学生学业有成(程序稳定)就是我们的首要任务,如何检验这些学生学习的效果呢?
答案就是考试 ------ 单元测试
而我们就是出题人,编写单元测试的过程就像是我们在出题一样,如果学生答对了,则测试通过,答错了,则不通过。
在我们出题的过程中,我们提出了问题,也要给出标准答案,就像下面这样。
sh
期望(学生的回答)是(标准答案) --> expect(xxx).toBe(xxx)
如果上面运行的结果是正确的那么断定测试通过,否则断定失败。这个出题、验证并进行判断的过程我们就叫做断言。
但是当我们在真实测试场景下去做的时候并不那么简单,因为我们的应用程序几乎都是由若干的模块组成的,每个模块可能有多个函数,它们是相互依赖的,而单元测试 追求的是细粒度的测试,因此就会出现这样的问题:
比如我们想要测试某个test 函数,test函数可能会依赖自己模块中的一些变量、对象。也可能依赖别的模块的一些对象或者变量。
所以如果要运行test,就需要把它所依赖的模块都加载进来来可以测试,如果这些模块有定时器、网络IO、随机变量的话就会有两个问题:
- 特别耗时
- 无法断言
特别耗时很好理解,加载这些模块以及这些模块中运行都需要耗时,但是无法断言怎么理解呢?我们举个例子:
ts
// sum.js
const getRandom = ()=> Math.ceil(Math.random() * 1000);
export const test = (base:number)=>{
return getRandom() + base;
}
// test.js
import { test } from './sum.js'
expect(test(10)).toBe( 这个没办法确定 );
在上面这个测试案例当中,我们根本就没办法确定test()应该等于什么,因为test函数里面依赖了 随机 元素,就像我们出了一个特别主观的题目,根本没有标准答案一样,显然就无法判定对错了。
因此我们需要借助某种手段,在测试环境的上下文下,去模拟真实的随机、IO、对象、变量等,让一切外部因素是可控的,这样就可以更好的断言 了。而这个过程其实就是模拟(Mock)。
通过模拟我们在测试任何细粒度的单元 的时候就可以很快 并且很准确 的进行测试,但是有的时候我们还会有这样的需求,我们需要知道某个依赖 在我们测试的单元中的工作情况。例如:
ts
// sum.js
export const test = (callback:Function , is: boolean)=>{
if(is){
callback();
}
}
// test.js
import { test } from './sum.js'
const callback = ()=> null;
test(callback , true)
expect( callback ).toBe(执行了)
// 我们需要知道callback有没有执行这个信息来进行断言,但现在不知道怎么判定,因为函数
// 自身并没有保存它调用情况的信息。
因此我们需要监听 某个函数,使我们能够拿到它的执行情况信息,比如被调用了多少次,传入的参数是什么,返回了什么等等;由于监听在过去一般是由间谍做的,因此也叫做spy。
但是对于一般的前端程序而言,除了少数的纯javascript的库以外,大部分的前端工作都会涉及到UI界面,而现代前端框架的出现,让前端的工作大部分都围绕着 UI = f(state) 而展开,state主要来自自身的状态和外部的props,它的输出其实就是一个UI,而如果我们期望某个组件产生某个UI,直接在断言里面写显然不切实际,因此产生了快照的概念。
对于UI的测试,我们可以给出确定的state,然后让它和上一次产生的快照做对比,如果相同则测试通过,不同则测试不通过。
三、框架与库
上面讲了那么多概念主要目的希望大家在心里种下一些概念,现在我们开始使用框架 和库来上手单元测试。编写好的单元测试并不容易,好在有成熟的框架和库来来帮助我们更好的做到。
vite已然成为前端的基础设置之一,它快速的毫秒级的热更新深受开发者的热爱,而vitest 则可以更好的与vite 结合起来,而且开箱即用的支持TS、JSX、TSX ,而老牌Jest框架来说,要支持TS、JSX还会有一些配置,因此本文选择使用vitest作为演示,除此之外,vitest还有以下优势:
(1) vitest
第一步:使用vite来创建一个react项目,并下载依赖
sh
npm create vite@latest react-test-demo --template react-ts
cd react-test-demo
npm i
第二步:再安装vitest
sh
npm install -D vitest
第三步:然后在package.json中配置script脚本。
json
// package.json
{
"test":"vitest"
}
第四步:在src
目录下创建一个index.test.ts
文件
ts
import { expect, describe, it } from "vitest";
export function sum(a: number, b: number) {
return a + b;
}
describe("第一个测试文件", () => {
it("测试", () => {
expect(sum(1, 1)).toBe(2);
});
});
第五步:运行npm run test
运行该命令后,vitest会扫描项目中的测试文件,通常是以 .test.js
、.test.ts
、.spec.js
、.spec.ts
等为后缀的文件,扫描后符合条件的文件就会执行其中的测试用例,于是我们就可以得到一个单元测试的结果,并且箭头所指的部分我们可以看到测试依然处于未结束 的状态,当我们改变用例代码的时候,测试用例会再次重新执行,就像开发组件的热更新一样非常好用。
(2) @testing-library/react
通过vitest我们可以测试一些简单的函数,完全没有任何问题,但是如果要测试react组件就有点力不从心了,因此我们需要借助专门测试react组件的库,当然这种库有很多,但是根据npm trends的下载量来看@testing-library/react其实是使用的最多的,因此我们使用它来测试我们的react组件。
第一步:下载依赖
sh
npm i @testing-library/react
第二步:准备一个组件
tsx
import React from "react";
type ButtonProps = {
children?: React.ReactNode;
loading?: boolean;
disabled?: boolean;
onClick?: () => void;
};
const Button: React.FC<ButtonProps> = ({
children,
loading,
disabled,
onClick,
}) => {
return (
<button disabled={disabled} onClick={onClick}>
{" "}
{loading ? "loading" : ""} {children || "blank"}
</button>
);
};
export default Button;
这是一个极其简单的react组件,接下来我们就以它为例,来进行测试
测试是否可以正常渲染
tsx
//__test__/index.test.tsx
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import Button from "../index";
describe("test Button component", () => {
it("shoud mount and unmount normally", () => {
const { unmount, rerender } = render(<Button />);
expect(() => {
rerender(<Button />);
unmount();
}).not.toThrow();
});
});
一般情况下测试组件我们可以先测试这个组件是否可以正常渲染和卸载,然后次运行npm run test
之后,会发现报了一个错:
这是因为在react的组件渲染是基于DOM的,而测试环境没有document等文档对象,包括浏览器对象其实都是没有的,因此我们需要配置相应的dom环境,我们选择happy-dom方法如下:
sh
npm i happy-dom
然后再在根目录下建一个vitest.config.ts
,指定happy-dom为dom环境的依赖
ts
// vitest.config.ts
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";
export default defineConfig(() =>
mergeConfig(
viteConfig,
defineConfig({
test: {
environment: "happy-dom",
},
})
)
);
再次运行npm run test
就可以看到我们的测试都通过了✅ ,接下来我们就来详细说说该如何测试我们的react组件。
四、小试牛刀
我们把上面的那个Button组件再稍微改动一下,以便能够演示所有的测试例子🌰:
tsx
// index.tsx
import React from "react";
import useTimeout from "./hooks/useTimeout";
type ButtonProps = {
children?: React.ReactNode;
loading?: boolean;
disabled?: boolean;
size?: string;
onClick?: () => void;
timeout?: number;
};
const Button: React.FC<ButtonProps> = ({
children,
loading,
disabled,
onClick,
size,
timeout,
}) => {
const { text } = useTimeout(timeout);
if (size === "large") {
console.warn("Dont be so large");
}
return (
<button disabled={disabled} onClick={onClick}>
{loading ? "loading" : ""}
{children || "blank"}
{text}
</button>
);
};
export default Button;
// useTimeout.ts
import { useEffect, useState } from "react";
export default function useTimeout(timeout?: number) {
const [text, setText] = useState("");
useEffect(() => {
if (timeout) {
setTimeout(() => {
setText("i am here");
}, timeout);
}
}, [timeout]);
return { text };
}
(1) 测试UI
tsx
it("should render correctly", () => {
const { asFragment } = render(<Button />);
expect(asFragment()).toMatchSnapshot();
})
asFragment是渲染组件后暴露出来的一个函数,它调用后会产生一个组件片段,一般可以用这个来做快照,测试后会在文件夹中产生一个文件,如图所示:
(2) 测试props - loading
测试props的逻辑的就是,当我们有一个新的props流入组件时,我们期待看到它发生了期待的作用,在我们的这个组件中,我们期待它出现"loading" 这个字样,我们顺便把blank也测一下。
tsx
it("should render loading", () => {
const { findByText, asFragment } = render(<Button loading />);
expect(findByText("loading")).toBeTruthy();
expect(findByText("blank")).toBeTruthy();
expect(asFragment()).toMatchSnapshot();
});
(3) 测试函数
如果我们输入的是一个函数,我们就需要模拟用户点击和模拟一个函数出来,来进行测试
tsx
import { render, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
it("onClick should be called", () => {
const onClick = vi.fn();
const { container } = render(<Button onClick={onClick} />);
fireEvent.click(container.firstChild!);
expect(onClick).toBeCalled();
expect(onClick).toBeCalledTimes(1);
});
这个地方有两个知识点,我们引入了 fireEvent,它可以模拟几乎所有的用户事件,如图所示:
所以你能在这里对某个DOM做任何事,另外就是 vi ,它可以模拟出一个函数,从而监听这个函数的执行情况,我们可以通过它获取函数的执行情况,次数,获得的参数等等。
(4) 穿越时空
另外我们就需要测试一下关于异步的方法了,通过这个组件我们可以得出,当我们传入timeout=1000 这个prop的时候,我们期望1秒后出现i am here这样的界面。很显然要测试这样的效果,我们就需要等1s后才能得知结果,1s还是可以接受的,那么如果是1小时呢?
因此在测试过程中我们可以通过某种手段使我们的测试环境的定时器提前执行,以便加快测试的效率,下面是测试的例子🌰:
tsx
import { render, fireEvent, renderHook, waitFor } from "@testing-library/react";
it("should render with timeout", () => {
vi.useFakeTimers();
const { container } = render(<Button timeout={1000} />);
expect(container.firstChild).not.toMatch(/i am here/);
vi.runAllTimers();
waitFor(() => expect(container.firstChild).toMatch(/i am here/));
vi.useRealTimers();
});
在传入指定的"timeout=1000"参数后,第一时间界面应该是不能出现i am here 的,然后调用vi.runAllTimers() ,内部定时器都会跑完。这个时候不能立马检测见面,因为大家都知道定时器内函数运行仅仅代表状态发生了更新,而UI的改变是异步的,因此我们还需要借助 waitFor ,它是vitest提供的为测试异步方法提供的辅助函数。含义是等待回调成功执行,如果回调抛出错误 或返回拒绝的承诺,它将继续等待,直到成功或超时
(5) 测试hooks
我们来尝试测试组件中的一个hook,useTimeout,废话不多说,直接看代码
tsx
import { render, fireEvent, renderHook, waitFor } from "@testing-library/react";
it("test useTimeout", () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(() => useTimeout());
expect(result.current.text).toBe("");
rerender(() => useTimeout(500));
vi.runAllTimers();
waitFor(() => expect(result.current.text).toBe("i am here"));
vi.useRealTimers();
});
我们需要引入renderHook ,它支持执行ReactHook,所谓的hook其实就是扔给他一堆数据,检测它返回的是否符合期望,上面的例子中我们依然使用vi.runAllTimers来加速测试
(6) 监听函数的执行
当开发者不鼓励使用某个API的时候,会在控制台报一个警告,我们的Button同样不鼓励开发者,使用size="large"这样的传入,因此我们需要监听系统的函数
tsx
it("do not use size to be large", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(warnSpy).not.toHaveBeenCalled();
render(<Button size="large" />);
expect(warnSpy).toHaveBeenCalledWith("Dont be so large");
});
(7) 查看测试覆盖率
当我们觉得自己的测试用例写的差不多的时候,并且都测试通过后就可以考虑查看我们的测试覆盖率
我们需要安装一个依赖
sh
npm i @vitest/coverage-v8 -D
然后配置一个脚本
json
//package.json
{
"coverage": "vitest run --coverage",
}
运行后会得到一个测试覆盖率的报告如下:
我们的部分文件显然还没有测试,而且会提示我们哪些分支没有测到,因此我们可以通过这个工具来完善我们的测试用例,此外有些不需要测试的文件我们可以通过配置来进行排除:
ts
// vitest.config.ts
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";
export default defineConfig(() =>
mergeConfig(
viteConfig,
defineConfig({
test: {
environment: "happy-dom",
coverage: {
exclude: ["src/app.tsx", "src/main.tsx"],
include: ["src/*/**"],
},
},
})
)
);
补充完所有的测试之后,在运行就基本可以测试通过啦😄
五、最后的话
本文主要介绍了如何测试我们的React组件,是一篇关于单元测试的入门级文章,如果对你有帮助,欢迎多多点赞支持一下!祝各位帅哥美女龙年大吉😄!
本文的所有代码均已上传至github,可以自行拉取体验(保熟)
我的其他好文:
前端是怎么解析Excel、PDF、Word、PPT等文件的? 🔥
搭建一个个人网站 🔥