单元测试
单元测试(Unit Testing)是指对前端应用中的单个最小功能单元进行测试,以验证其是否按预期工作。这个"最小功能单元"通常是一个函数、方法、组件或模块。
单元测试的目标是确保代码在最基本的粒度上是正确的,通常由开发人员自己编写,并且是在开发过程中进行的。
作用
- 提高代码质量:通过早期发现代码中的错误,减少了后期调试和修复的成本。
- 代码重构更容易:有了单元测试,开发人员可以在重构代码时更有信心,因为可以通过运行现有的单元测试来验证重构后代码的正确性。
- 文档化代码行为:单元测试可以作为代码行为的一部分文档,其他开发人员可以通过阅读测试代码了解函数的预期行为。
- 调试方便:通过执行单元测试,可以快速定位出问题所在,特别是在复杂的逻辑中。
单元测试入门
单元测试对项目非常有帮助,笔者的公司对单元测试非常看重,要求所有项目单元测试覆盖率达到80%以上。
笔者站在零基础的角度讲述如何写好单元测试。本篇文章中基于React示例代码和Vitest
测试框架讲述单元测试的方方面面。
React是一个非常流行的前端UI库,用于构建前端页面,对React不熟的小伙伴可以通过React文档进行了解
Vitest
测试框架是基于Vite开发的新一代测试框架,完美兼容Jest,功能十分强大。
Test Runner
上面提到了Vitest
测试框架,在使用Vitest
前,先来学习Test Runner
概念。
Test Runner是一个用于自动化执行测试用例、收集测试结果并报告测试执行状态的工具或组件。在软件开发中,测试运行器帮助开发人员方便地执行单元测试、集成测试等,并输出相关的结果,如成功、失败、跳过等信息。
Test Runner 的主要功能:
- 自动化执行测试:Test Runner 会自动执行预定义的测试用例,而无需手动逐一运行每个测试。
- 结果报告:它会生成测试执行的结果报告,通常包括通过的测试、失败的测试、错误信息、测试覆盖率等。
- 管理测试用例:Test Runner 通常能够管理和组织测试用例,支持按照不同的分组、标签、优先级等运行。
- 提供反馈:Test Runner 会为每个测试用例提供反馈,指明测试是通过了还是失败了,并给出详细的错误信息。
- 集成到持续集成(CI)工具:Test Runner 常常与持续集成(CI)工具(如 Jenkins、GitLab CI、Travis CI)结合,自动化执行测试并生成报告。
而Vitest
就是一个 Test Runner
单元测试表现形式
形如上图中的形式就是单元测试。下面来分解其中的所有概念
describe
- 定义 :
describe
是一个用于组织和分组测试用例的函数。它用来将一组相关的测试放在一起,通常用来描述测试的功能或模块。 - 作用 :
describe
用于将多个it
测试用例进行分组,使得测试报告更加清晰。例如,可以将所有与某个函数相关的测试放在同一个describe
块中。 - 语法 :
describe(description, callback)
,其中description
是描述测试组的文本,callback
是一个包含多个测试用例的函数。
js
// 把 describe 整体称为测试套件
describe('Array operations', () => {
// 包含的多个测试用例,
});
it/test
- 定义 :
it
是定义具体测试用例的函数。它通常用来描述某个功能的预期行为。 - 作用 :
it
定义了一个具体的测试场景,通常以"should"开始,描述这个测试应该验证什么行为或期望的输出。每个it
都包含一个测试步骤,通常是一个断言(如expect()
)。 - 语法 :
it(description, callback)
,其中description
是对当前测试行为的简短描述,callback
是一个函数,包含实际的测试逻辑。
js
// 下面的代码整体称为一个测试用例
it('should add two numbers correctly', () => {
expect(add(1, 2)).toBe(3);
});
expect
- 定义 :
expect
是一种断言机制,用来检查某个值是否符合预期。它是 JavaScript 测试框架(如 Jest)提供的 API。 - 作用 :
expect
用来生成一个期望值,之后通过调用匹配器(如.toBe()
)来验证该期望值是否满足预期条件。 - 语法 :
expect(value)
,表示测试值。然后可以使用不同的匹配器来判断该值是否符合期望。例如,expect(1).toBe(1)
会检查1
是否等于1
。
js
// expect(1)是断言语句,后面的 toBe 是匹配器
expect(1).toBe(1); // 检查 1 是否等于 1
匹配器
- 定义 :
toBe
是一个匹配器,用于检查被测试的值是否与期望值完全相等。它是 Jest 提供的一个常见的匹配方法,属于 "相等" 类型的断言。 - 作用 :它检查实际值和预期值是否严格相等(即使用
===
比较)。 - 语法 :
expect(value).toBe(expectedValue)
,其中value
是要测试的值,expectedValue
是期望的值。
总结
一个完整的测试文件可以写多个测试套件,测试套件由测试用例、断言语句和匹配器构成
实战
下面通过一个简单的工具函数来具体说明如何写测试。请看下面的工具方法示例
js
// isNumber.js
function isNumber(value) {
return typeof value === "number";
}
这段代码定义了一个名为 isNumber
的 JavaScript 函数,用于检查一个给定的值是否是数字类型。这是非常简单的工具函数,它的单元测试也非常简单
创建测试文件
示例中的代码在isNumber.js
文件,那么创建测试文件名只需要加上test
或spec
即可,比如isNumber.test.js
或isNumber.spec.js
。通用做法是在要测试的文件名后直接添加test
或spec
即可,这样不需要思考如何测试文件命名
定义测试套件
describe
用来将 isNumber
相关的测试放在一起。describe
接收的第一个参数通常写成要测试的内容,本题中是测试 isNumber
那就写成 isNumber
js
describe("isNumber", () => {});
定义测试用例
测试用例用来描述某个功能的预期行为。
js
describe("isNumber", () => {
it("should return true for numbers", () => {
expect(isNumber(1)).toBe(true);
});
it("should return false for strings", () => {
expect(isNumber("1")).toBe(false);
});
});
上面的代码中有两个测试用例,it方法接收的第一个参数是测试用例描述,一般是测试功能的语义化描述,第二个参数是匿名方法,函数题是断言语句,判断isNumber传入的值是否符合预期的返回值。
isNumber
的测试整体结构就是这样简单。还可以添加更多的测试用例覆盖更多的情况
React中的单元测试
通过前面的学习,大家对单元测试有一个大概的了解,通过简单示例大家了解如何书写单元测试。而实际项目中的代码非常复杂,那么相应的单元测试也会非常复杂
下面对单元测试进行分类,给出代码示例,也可以看看从哪些角度写单元测试
工具方法
在React中,工具方法常用于处理数据,格式化等。
示例代码
js
export function toLowercase(val) {
return val.toLowerCase();
}
toLowercase 的单元测试如下,
js
import { toLowercase } from "../toLowercase";
describe("toLowercase", () => {
it("should convert a string to lowercase", () => {
expect(toLowercase("HELLO")).toBe("hello");
});
it("should return an empty string if input is an empty string", () => {
expect(toLowercase("")).toBe("");
});
it("should handle mixed case strings", () => {
expect(toLowercase("HeLLo WoRLd")).toBe("hello world");
});
it("should handle strings with numbers and special characters", () => {
expect(toLowercase("Hello123!")).toBe("hello123!");
});
it("should throw an error if input is not a string", () => {
expect(() => toLowercase(123)).toThrow();
expect(() => toLowercase(null)).toThrow();
expect(() => toLowercase(undefined)).toThrow();
});
});
需要测试任何数据类型的参数被toLowercase处理后的结果。像这些工具方法与业务弱相关或者无关,安全可以利用AI工具帮助生成,其准确率特别高
React组件UI展示
React组件是构建页面的基石,所以在React项目中,基本上每一个React组件都应该写测试,对于React组件,主要从UI元素正常显示的角度去写测试
示例代码
js
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import "./App.css";
function App() {
const [count, setCount] = useState(0);
return (
<>
<div>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p data-testid="test_HMR_text">
Edit <code>src/App.jsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
);
}
export default App;
在上面的React组件中,单元测试覆盖所有的页面UI正常显示,利用AI写出基础的单元测试,再根据情况修改
js
import { render, screen, fireEvent } from "@testing-library/react";
import App from "../App";
describe("App component", () => {
it("renders Vite and React logos", () => {
render(<App />);
const reactLogo = screen.getByAltText("React logo");
expect(reactLogo).toBeInTheDocument();
});
it("renders the heading", () => {
render(<App />);
const heading = screen.getByText("Vite + React");
expect(heading).toBeInTheDocument();
});
it("renders the edit message", () => {
render(<App />);
const editMessage = screen.getByTestId("test_HMR_text");
expect(editMessage).toBeInTheDocument();
});
it("renders the documentation message", () => {
render(<App />);
const docsMessage = screen.getByText(
/Click on the Vite and React logos to learn more/i
);
expect(docsMessage).toBeInTheDocument();
});
});
React 组件交互
上面写完了组件的UI的单元测试,同等重要的是UI交互测试,也就是React组件的交互测试,以上面例子的中组件而言,请看它的组件交互测试
js
import { render, screen, fireEvent } from "@testing-library/react";
import App from "../App";
describe("App component", () => {
it("increments count on button click", () => {
render(<App />);
const button = screen.getByRole("button", { name: /count is/i });
fireEvent.click(button);
expect(button).toHaveTextContent("count is 1");
fireEvent.click(button);
expect(button).toHaveTextContent("count is 2");
});
});
点击按钮,可以看到页面上的内容发生变化,
React Hooks
在React项目中,React Hooks 自然是离不开的重要内容。对于自定义的React Hooks需要使用@testing-library/react-hooks
第三方依赖
示例代码
js
import { useState, useEffect } from "react";
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export default useDebounce;
测试代码如下所示
js
import { renderHook } from "@testing-library/react-hooks";
import useDebounce from "../useDebounce";
import { act } from "react";
describe("useDebounce", () => {
vi.useFakeTimers();
it("should return the initial value immediately", () => {
const { result } = renderHook(() => useDebounce("initial", 500));
expect(result.current).toBe("initial");
});
it("should debounce the value", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{
initialProps: { value: "initial", delay: 500 },
}
);
rerender({ value: "changed", delay: 500 });
// Value should not change immediately
expect(result.current).toBe("initial");
// Fast-forward time
act(() => {
vi.advanceTimersByTime(500);
});
// Value should be updated after the delay
expect(result.current).toBe("changed");
});
it("should reset the debounce timer if value changes within the delay period", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{
initialProps: { value: "initial", delay: 500 },
}
);
rerender({ value: "changed", delay: 500 });
// Fast-forward time by less than the delay
act(() => {
vi.advanceTimersByTime(300);
});
// Value should not change yet
expect(result.current).toBe("initial");
// Change the value again
rerender({ value: "changed again", delay: 500 });
// Fast-forward time by the remaining delay
act(() => {
vi.advanceTimersByTime(200);
});
// Value should still not change
expect(result.current).toBe("initial");
// Fast-forward time by the new delay
act(() => {
vi.advanceTimersByTime(300);
});
// Value should be updated after the new delay
expect(result.current).toBe("changed again");
});
});
自定义Hooks的单元测试相对组件UI测试更简单,由于没有UI干扰,只需要验证逻辑操作前后的状态变化就行了
异步测试
在写React组件时,如果组件用的数据来自服务端,有小伙伴会在useEffect
中写接口请求,此时需要注意异步请求的问题
js
import React, { useState, useEffect } from "react";
export default function AccountList() {
const [users, setUsers] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
// 模拟API请求
async function fetchUsers() {
try {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Failed to fetch users");
}
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err.message);
}
}
fetchUsers();
}, []);
if (error) {
return <div>Error: {error}</div>;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
单元测试如下所示
js
// AccountList.test.js
import { render, screen, waitFor } from "@testing-library/react";
import AccountList from "../AccountList/AccountList";
// 模拟 API 请求
global.fetch = vi.fn();
describe("AccountList", () => {
it("should display users after successful fetch", async () => {
// 模拟成功的 API 响应
fetch.mockResolvedValueOnce({
ok: true,
json: async () => [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
});
render(<AccountList />);
// 等待用户数据加载
await waitFor(() => screen.getByText("Alice"));
await waitFor(() => screen.getByText("Bob"));
// 检查用户是否渲染
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
});
it("should display an error message when fetch fails", async () => {
// 模拟失败的 API 响应
fetch.mockRejectedValueOnce(new Error("Failed to fetch users"));
render(<AccountList />);
// 等待错误消息渲染
await waitFor(() => screen.getByText(/Error:/));
// 检查错误消息
expect(
screen.getByText("Error: Failed to fetch users")
).toBeInTheDocument();
});
it("should display an error message when API response is not ok", async () => {
// 模拟 API 返回失败的响应(非 2xx 状态)
fetch.mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({ message: "Internal Server Error" }),
});
render(<AccountList />);
// 等待错误消息渲染
await waitFor(() => screen.getByText(/Error:/));
// 检查错误消息
expect(
screen.getByText("Error: Failed to fetch users")
).toBeInTheDocument();
});
});
在异步测试中,尤其要注意请求的等待时间,需要等数据更新后,再验证状态变化
异常测试
异常测试是专门测试代码在异常情况(如错误输入、异常状态、网络失败等)下的行为。目的是确保应用在遇到错误时能正确处理,而不是崩溃或产生意外行为。
示例代码
js
import React, { useState, useEffect } from "react";
export default function AccountList() {
const [users, setUsers] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
// 模拟API请求
async function fetchUsers() {
try {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Failed to fetch users");
}
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err.message);
}
}
fetchUsers();
}, []);
if (error) {
return <div>Error: {error}</div>;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
快照测试
快照测试(Snapshot Testing)是一种用于测试 UI 组件的技术,主要用于检测 React 组件的输出是否发生了意外的变化。它的核心思想是将组件的渲染结果(HTML 结构)保存为快照文件,并在后续测试时对比当前渲染结果和快照文件是否一致
示例代码
js
export default function Button({ label }) {
return <button>{label}</button>;
}
快照测试代码如下
js
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Button Component Snapshot > should match the snapshot 1`] = `
<DocumentFragment>
<button>
Click Me
</button>
</DocumentFragment>
`;
边界测试
边界测试(Boundary Testing)是一种测试输入数据边界值的测试方法 ,主要用于检测系统在极端值 或边界情况下的行为是否符合预期。
尤其是一些工具方法的测试,方法要接收所有类型的参数的测试,写出所有的测试用例