入门单元测试当然要使用最流行的 vitest,在这里仅使用vitest提供的周边库来实现单元测试。所以使用 vitest-browser-react 替代@testing-library/react。
目前vitest已经支持browser mode,即允许在浏览器中原生运行测试,并提供对浏览器全局变量,如 window 和 document 的访问。
在本地运行测试时,vitest支持复用已经安装好的浏览器。但是在跑流水线的时候,进行测试只能使用 playwright
or webdriverio
。Vitest建议在本地测试时就应该使用它们俩个包之一,而不是使用模拟事件的默认模式preview
(用本地的浏览器)。
vitest建议使用 playwright,因为它支持并行执行而且使用 Chrome DevTools 协议,所以比 WebDriver 更快。
另外还需要vitest-browser-react 来渲染React组件。
得出结论需要下载这4个包:
bash
npm install -D vitest @vitest/browser playwright vitest-browser-react
接下来配置 vitest.config.ts
:
ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
browser: {
enabled: true,
provider: "playwright",
instances: [{ browser: "chromium" }],
},
},
});
配合typescript使用的话,需要确保在 tsconfig.json
中include vitest的配置文件(vitest.config 或 vitest.setup 或 vitest.workplace),并且在types数组中加上类型。可参考 vitest react的例子。
json
{
"compilerOptions": {
"types": [
"@vitest/browser/providers/playwright" /* 加上使用的浏览器的类型 */
],
},
"include": [
"src",
"vitest.config.ts" /* 确保加上 */
]
/* 省略其它配置 */
}
接下来,通过 4 个小例子来入门单元测试。
组件渲染测试
编写单元测试通常遵循'描述-示例-断言'模式:
- describe()方法定义被测试模块或功能
- 通过it()方法列举具体测试场景并明确预期结果
- 最后在测试用例中使用expect()断言来验证实际输出是否符合预期。
tsx
describe('subtract function', () => {
it('should return the difference between two numbers', () => {
const result = subtract(5, 3);
expect(result).toBe(2);
});
});
以下是待测试的react组件:
tsx
// Greeting.tsx
function Greeting({ name }: {name?:string}) {
return <h1>Hello, {name || "World"}!</h1>;
}
export default Greeting;
编写测试用例,可以考虑这两个方面:
- 如果没有提供 name ,显示 "Hello, World!"
- 如果提供了 name ,显示 "Hello, {name}!"
tsx
// Greeting.test.tsx
import { render } from "vitest-browser-react";
import { describe, it, expect } from "vitest";
import Greeting from "./Greeting";
describe("Greeting", () => {
it("renders a default greeting", () => {
const { getByText } = render(<Greeting />);
expect(getByText("Hello, World!").element()).toBeInTheDocument();
});
it("renders a greeting with a name", () => {
const { getByText } = render(<Greeting name="Jiaqi" />);
expect(getByText("Hello, Jiaqi!").element()).toBeInTheDocument();
});
});
使用命令 npx vitest
执行测试:
类似于 toBeInTheDocument
可在 jest-dom 或者vitest browser mode中查看。
这种以 get
开头的定位器可以在Locators这节查看更多API。
用户交互测试
上面的组件是纯渲染的,如果给组件增加了用户交互,应该如何测试:
tsx
// Counter.tsx
import { useState } from "react"
const Counter = () => {
const [count,setCount] = useState(0)
return (
<div>
<p>{count}</p>
<button onClick={()=>setCount(pre=>pre+1)}>increment</button>
</div>
)
}
export default Counter
测试逻辑:
- 断言p标签初始时的textContent是否为0
- 使用
useEvent
触发按钮点击 - 断言p标签此时的textContent为1
tsx
import { render } from "vitest-browser-react";
import { describe, it, expect } from "vitest";
import Counter from "./Counter";
import { userEvent } from "@vitest/browser/context";
describe("Counter", async () => {
it("increments counter on button click", async () => {
const screen = render(<Counter />);
// 获取按钮 在Counter组件中按钮的名字是increment
const btn = screen.getByRole("button", { name: /increment/ });
// 获取p标签
const counterValue = screen.getByRole("paragraph");
expect(counterValue.element()).toHaveTextContent("0"); // 默认应该为0
// 用户触发按钮点击,userEvent来自@vitest/browser/context
await userEvent.click(btn);
expect(counterValue.element()).toHaveTextContent("1");
});
});
在这里恰巧只有一个p标签,若考虑到复杂情况,可以给p加上一个 data-testid
。不过vitest建议,只有当其它的选择器无法使用的时候才考虑使用 data-testid
。
测试有请求的组件
以下是一个使用 fetch
发送请求的组件:
tsx
// UserProfile.tsx
import { useEffect, useState } from "react";
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<{ name: string; email: string } | null>(null);
useEffect(() => {
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data));
}, [userId]);
if (!user) return <p>Loading...</p>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
export default UserProfile;
但是在单元测试中会直接mock数据,避免实际发送请求。使用真实 API 可能会因为数据变化导致测试失败。此外Mock 数据保持一致,确保测试结果可重复。
因为单元测试的核心理念是隔离被测试的代码单元,确保测试只关注组件本身的行为,而不依赖于外部系统。
因此在测试用例中加上这两个函数:
- beforeEach : 在每个测试用例执行前运行,这里使用 vi.stubGlobal 模拟全局的 fetch 函数
- afterEach : 在每个测试用例执行后运行,清理所有模拟并恢复全局函数
tsx
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
vi.stubGlobal("fetch", vi.fn())
这行代码做了以下事情:
- 将全局的 fetch 函数替换为一个由 Vitest 创建的模拟函数(mock function)
- 这个模拟函数默认不做任何事情,只是一个可以被跟踪和控制的空函数
tsx
import { render } from "vitest-browser-react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import UserProfile from "./UserProfile";
describe("UserProfile", () => {
beforeEach(() => {
// Mock the global fetch function
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
// Clear all mocks after each test
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("fetches and displays the user data", async () => {
const fetchMock = vi.mocked(fetch);
const mockUser = { name: "John Doe", email: "[email protected]" };
// 提前设置fetch的返回值
fetchMock.mockResolvedValue({
json: () => Promise.resolve(mockUser),
} as unknown as Response);
// 渲染组件,UserProfile在useEffect调用的fetch被替换成设置的fetchMock
const screen = render(<UserProfile userId="1" />);
// 渲染后页面上有loading文字的加载
expect(screen.getByText(/loading/i).element()).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith("https://jsonplaceholder.typicode.com/users/1");
// 这里使用了retry的语法
await expect.element(screen.getByText("John Doe")).toBeInTheDocument();
await expect.element(screen.getByText("[email protected]")).toBeInTheDocument();
});
});
这里除了直接给模拟的fetch设置好返回值,还可以编写fetch的模拟实现:
tsx
// 设置返回的promise
fetchMock.mockResolvedValue({
json: () => Promise.resolve(mockUser),
} as unknown as Response);
// 直接设置fetchMock的模拟实现
fetchMock.mockImplementation(() =>
Promise.resolve({
json: () => Promise.resolve(mockUser),
} as unknown as Response)
);
由于浏览器的异步特性,vitest提供了可重试的断言语法,可以参考这里。
非常适合这里展示用户信息时的断言:
tsx
// 这里使用了retry的语法
await expect.element(screen.getByText("John Doe")).toBeInTheDocument();
await expect.element(screen.getByText("[email protected]")).toBeInTheDocument();
hook测试
写一个简单的 hook useCounter.ts
:
tsx
import { useState } from "react";
export const useCounter = (initialValue: number = 0) => {
const [count, setCount] = useState(initialValue);
const increment = () => {
setCount((c) => c + 1);
};
const decrement = () => {
setCount((c) => c - 1);
};
return {
count,
increment,
decrement,
};
};
在 Counter.tsx
中使用它:
tsx
import { useCounter } from "./hooks/useCounter"
const Counter = () => {
const {count,increment } = useCounter()
return (
<div>
<p data-testid='counter-value'>{count}</p>
<button onClick={increment}>increment</button>
</div>
)
}
export default Counter
之前在 Counter.test.tsx
写的测试用例依然可以用于 Counter
组件,但是应该如何测试 useCounter
hook?
参考vitest-browser-react
文档可知,使用 renderHook
来测试,同时使用 react 的 act api更新状态。act
是个测试辅助工具,用于在断言前应用待处理的 React 更新。
所以最终的测试文件可以这样写:
tsx
import { renderHook } from "vitest-browser-react";
import { useCounter } from "./useCounter";
import { describe, it, expect } from "vitest";
import { act } from "react";
describe("useCounter", () => {
it("initial value is 2", () => {
// 使用renderHook渲染hook
const { result } = renderHook(() => useCounter(2));
expect(result.current.count).toBe(2);
});
it("increment", () => {
const { result } = renderHook(() => useCounter());
// 调用increment
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrement", () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(-1);
});
});
总结
核心工具与环境配置
-
工具选择
- 使用
vitest
+vitest-browser-react
替代传统@testing-library/react
- 浏览器模式支持:Vitest 原生支持浏览器环境,通过
@vitest/browser
集成 Playwright 或 Webdriver - Playwright 优势:并行执行、基于 Chrome DevTools 协议,速度更快
- 使用
-
依赖安装
bashnpm install -D vitest @vitest/browser playwright vitest-browser-react
-
配置要点
-
Vitest 配置 :启用浏览器模式,指定 Playwright 和 Chromium
tstest: { browser: { enabled: true, provider: "playwright", instances: [{ browser: "chromium" }], }, }
-
TypeScript 配置 :包含 Vitest 类型声明和配置文件路径
json"types": ["@vitest/browser/providers/playwright"], "include": ["src", "vitest.config.ts"]
-
测试模式与实例
-
基础测试结构
• 描述-示例-断言模式:
tsdescribe("模块", () => { it("场景", () => { expect(实际值).匹配器(预期值); }); });
-
组件测试场景
-
渲染测试 :验证默认值与 Props 传递
tsxexpect(getByText("Hello, World!")).toBeInTheDocument();
-
交互测试 :使用
userEvent
模拟点击,验证状态更新tsxawait userEvent.click(按钮); expect(元素).toHaveTextContent("1");
-
-
API 请求测试
-
Mock Fetch :隔离外部依赖,确保测试稳定性
tsxvi.stubGlobal("fetch", vi.fn()); fetchMock.mockResolvedValue({ json: () => mockData });
-
加载态与数据渲染断言 :
tsxawait expect.element(数据元素).toBeInTheDocument();
-
-
Hook 测试
-
renderHook
+act
:测试自定义 Hook 状态与逻辑tsxconst { result } = renderHook(() => useCounter()); act(() => result.current.increment()); expect(result.current.count).toBe(1);
-
最佳实践与注意事项
- 选择器优先级 :优先使用语义化角色(如
getByRole
),避免过度依赖data-testid
- 测试隔离 :通过
beforeEach
/afterEach
清理 Mock 和状态 - 浏览器环境适配:本地使用真实浏览器,流水线集成 Playwright
- 核心原则 :
• 单元测试需独立于外部服务(如 API)
• 聚焦组件自身逻辑,Mock 外部依赖
通过以上步骤,可快速搭建基于 Vitest 的 React 单元测试环境,覆盖组件渲染、交互、异步请求及 Hook 等场景,确保代码质量与可维护性。
参考信息: