React 基础体系 · 第 49/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 单元测试:纯函数、Hook、时间、网络和稳定断言
React 单元测试的难点通常不在“如何调用断言”,而在于确定被测单元的边界:一个纯函数应当只根据输入计算结果;一个 Hook 还包含状态和生命周期;时间会让同一输入在不同瞬间产生不同结果;网络请求则把异步、失败、取消和外部系统引入测试;断言如果依赖实现细节,又会在无行为变化时频繁失效。
本文使用 React 19、现代 TypeScript、Vitest 和 React Testing Library 展开。测试示例面向客户端 React 代码;服务端函数、Server Component 和真实网络边界会单独说明。
先建立测试运行环境
一个最小的客户端测试环境通常包含:
- Vitest:测试运行器、Mock、Fake Timer 和断言扩展。
- React Testing Library:通过 DOM 和用户可观察行为测试组件。
@testing-library/jest-dom:提供toBeInTheDocument、toHaveTextContent等 DOM 断言。@testing-library/user-event:模拟更接近真实用户的点击、输入和键盘操作。
安装依赖:
npm install -D vitest jsdom \
@testing-library/react \
@testing-library/jest-dom \
@testing-library/user-event \
@types/node
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
restoreMocks: true,
clearMocks: true,
},
});
jsdom 提供浏览器 DOM 的模拟环境,但它不是完整浏览器。它不会真实执行布局、绘制、网络栈和所有浏览器 API。因此,依赖真实布局的逻辑,例如 getBoundingClientRect()、IntersectionObserver 或复杂的 CSS 行为,不能仅凭普通 DOM 单元测试证明正确。
src/test/setup.ts:
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});
测试命令可以写入 package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
测试文件通常使用 .test.ts 或 .test.tsx 后缀。涉及 JSX 的测试必须使用 .tsx。
一、纯函数:先证明输入到输出的确定性
1. 什么是适合单元测试的纯函数
函数
如果对任意输入 ,函数都满足:
- 结果只由输入决定:同一个输入总是得到同一个输出;
- 没有可观察副作用:不修改外部变量、不写文件、不发请求、不改变全局时间或随机数状态;
- 不修改输入对象:调用后输入保持不变;
那么它可以视为纯函数。
形式化地说,如果同一执行上下文中:
再次调用仍然满足:
并且调用前后的外部状态 不变:
这带来一个直接推论:纯函数测试不需要 React、DOM、网络或 Fake Timer。测试只需准备输入、调用函数、检查输出和输入是否保持不变。
2. 完整示例:购物车金额计算
先定义业务类型和函数:
// src/cart/total.ts
export type CartItem = {
id: string;
name: string;
unitPriceCents: number;
quantity: number;
};
export type Pricing = {
subtotalCents: number;
discountCents: number;
shippingCents: number;
totalCents: number;
};
export function calculatePricing(
items: readonly CartItem[],
freeShippingThresholdCents = 10_000,
): Pricing {
const subtotalCents = items.reduce(
(sum, item) => sum + item.unitPriceCents * item.quantity,
0,
);
const discountCents = subtotalCents >= 5_000
? Math.floor(subtotalCents * 0.1)
: 0;
const discountedSubtotalCents = subtotalCents - discountCents;
const shippingCents =
discountedSubtotalCents >= freeShippingThresholdCents ? 0 : 800;
return {
subtotalCents,
discountCents,
shippingCents,
totalCents: discountedSubtotalCents + shippingCents,
};
}
这里使用“分”为金额单位,而不是浮点数元。原因不是测试专属,而是货币运算需要避免:
0.1 + 0.2 !== 0.3
测试:
// src/cart/total.test.ts
import { describe, expect, it } from 'vitest';
import { calculatePricing, type CartItem } from './total';
describe('calculatePricing', () => {
it('计算小计、折扣、运费和总价', () => {
const items: CartItem[] = [
{
id: 'keyboard',
name: 'Keyboard',
unitPriceCents: 3_000,
quantity: 2,
},
];
expect(calculatePricing(items)).toEqual({
subtotalCents: 6_000,
discountCents: 600,
shippingCents: 800,
totalCents: 6_200,
});
});
it('达到免运费门槛后运费为零', () => {
const items: CartItem[] = [
{
id: 'monitor',
name: 'Monitor',
unitPriceCents: 10_000,
quantity: 1,
},
];
expect(calculatePricing(items)).toEqual({
subtotalCents: 10_000,
discountCents: 1_000,
shippingCents: 0,
totalCents: 9_000,
});
});
it('不会修改输入数组和数组中的对象', () => {
const items: CartItem[] = [
{
id: 'mouse',
name: 'Mouse',
unitPriceCents: 2_000,
quantity: 1,
},
];
const before = structuredClone(items);
calculatePricing(items);
expect(items).toEqual(before);
});
});
第一组断言验证完整业务结果,而不是只断言 totalCents。这样可以在折扣或运费计算错误时更快定位失败阶段。第二组验证边界条件。第三组验证“不修改输入”这一副作用约束。
3. 反例:看起来简单,但实际上不是纯函数
let discountRate = 0.1;
export function calculateTotal(price: number): number {
return price * (1 - discountRate);
}
这个函数的输出还依赖模块级变量 discountRate。测试如果只覆盖初始值:
expect(calculateTotal(100)).toBe(90);
并没有证明函数对输入是确定的。另一个测试可能修改了 discountRate,导致测试顺序影响结果。
更明确的设计是把隐藏依赖变成参数:
export function calculateTotal(
priceCents: number,
discountRate: number,
): number {
return Math.round(priceCents * (1 - discountRate));
}
现在,测试输入完整表达了计算条件:
expect(calculateTotal(1_000, 0.1)).toBe(900);
4. 纯函数测试中的边界和不变量
除了示例值,还应根据函数的业务约束测试不变量。例如购物车总价应满足:
并且:
可以写成参数化测试:
import { describe, expect, it } from 'vitest';
import { calculatePricing } from './total';
describe('calculatePricing invariants', () => {
it.each([
{ price: 0, quantity: 0 },
{ price: 1, quantity: 1 },
{ price: 4_999, quantity: 1 },
{ price: 5_000, quantity: 1 },
{ price: 20_000, quantity: 3 },
])('结果满足金额关系 %#', ({ price, quantity }) => {
const result = calculatePricing([
{
id: 'item',
name: 'Item',
unitPriceCents: price,
quantity,
},
]);
expect(result.totalCents).toBe(
result.subtotalCents -
result.discountCents +
result.shippingCents,
);
expect(result.discountCents).toBeGreaterThanOrEqual(0);
expect(result.discountCents).toBeLessThanOrEqual(
result.subtotalCents,
);
});
});
参数化测试不是为了增加样例数量,而是把边界条件显式化。若规则复杂到需要大量随机输入,可以引入 property-based testing;但随机测试必须固定随机种子或在失败时输出可复现输入,否则诊断成本会抵消收益。
二、Hook:测试状态转换和生命周期,而不是内部变量
1. Hook 的测试对象是什么
Hook 不是普通纯函数。它通常包含:
- 当前状态;
- 状态更新函数;
useEffect等副作用生命周期;- 依赖变化时的重新执行;
- 卸载时的清理;
- 异步回调与组件生命周期之间的竞态。
因此,Hook 测试关注的是状态转换:
以及生命周期:
不能直接调用一个依赖 React Dispatcher 的 Hook:
// 错误思路
const result = useCounter();
React 只允许在组件或自定义 Hook 内部调用 Hook。脱离 React 渲染上下文调用会产生 “Invalid hook call” 错误。
2. 一个状态 Hook 的完整测试
Hook 实现:
// src/hooks/useCounter.ts
import { useCallback, useState } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount((current) => current + 1);
}, []);
const decrement = useCallback(() => {
setCount((current) => current - 1);
}, []);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return {
count,
increment,
decrement,
reset,
};
}
测试:
// src/hooks/useCounter.test.tsx
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('支持增加、减少和重置', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(11);
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(10);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
renderHook 把 Hook 放进一个由测试库管理的测试组件中。result.current 表示当前渲染返回的值。调用会触发 React 状态更新的操作应放在 act 中,使 React 有机会完成更新、执行相关副作用并刷新测试可见状态。
React Testing Library 的部分用户交互封装已经包含 act,但直接调用 Hook 返回的更新函数时,显式使用 act 更清楚,也能避免 React 关于未包装更新的警告。
3. 依赖变化和清理
下面的 Hook 订阅一个外部事件源:
// src/hooks/useOnlineStatus.ts
import { useEffect, useState } from 'react';
export function useOnlineStatus() {
const [online, setOnline] = useState(() => navigator.onLine);
useEffect(() => {
const handleOnline = () => setOnline(true);
const handleOffline = () => setOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return online;
}
测试既要证明状态变化,也要证明卸载后不会继续更新:
// src/hooks/useOnlineStatus.test.tsx
import { renderHook, act } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useOnlineStatus } from './useOnlineStatus';
describe('useOnlineStatus', () => {
it('根据 online/offline 事件更新状态', () => {
vi.stubGlobal('navigator', {
...navigator,
onLine: true,
});
const { result } = renderHook(() => useOnlineStatus());
expect(result.current).toBe(true);
act(() => {
window.dispatchEvent(new Event('offline'));
});
expect(result.current).toBe(false);
act(() => {
window.dispatchEvent(new Event('online'));
});
expect(result.current).toBe(true);
});
it('卸载后移除事件监听器', () => {
const removeSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useOnlineStatus());
unmount();
expect(removeSpy).toHaveBeenCalledWith(
'online',
expect.any(Function),
);
expect(removeSpy).toHaveBeenCalledWith(
'offline',
expect.any(Function),
);
});
});
这里对 removeEventListener 的断言是合理的,因为“卸载后解除订阅”属于 Hook 的外部行为契约。相反,断言 Hook 内部使用了几个 useState,则是实现细节,重构时不应导致测试失败。
4. React Strict Mode 下的效果执行
开发模式下,React Strict Mode 可能额外执行一轮 Effect 的 setup 和 cleanup,以帮助发现不安全副作用。测试环境是否启用了 Strict Mode 取决于测试包装器和工具配置。
因此,不应简单把“某个 Effect 只执行一次”当作普遍保证。更可靠的契约是:
- 每次订阅都有对应的清理;
- 清理后不会残留监听器;
- 副作用具有幂等性,重复 setup 不会造成重复业务结果;
- 网络请求或日志等不可逆副作用是否需要去重,应由业务设计决定。
如果 Hook 使用 renderHook 的 wrapper 包裹 Provider,应测试 Provider 提供的外部契约,而不是测试上下文内部的实现:
import { renderHook } from '@testing-library/react';
function TestWrapper({ children }: { children: React.ReactNode }) {
return <ThemeProvider value="dark">{children}</ThemeProvider>;
}
// renderHook(() => useTheme(), { wrapper: TestWrapper });
三、时间:把“现在”变成可控输入
1. 时间为什么会让测试不稳定
以下代码的结果依赖当前时刻:
export function isExpired(expiresAt: Date): boolean {
return expiresAt.getTime() <= Date.now();
}
测试若直接使用真实时间:
expect(isExpired(new Date())).toBe(true);
存在边界竞态:构造 Date 和执行 Date.now() 之间可能跨越毫秒,结果虽然通常符合预期,但测试依赖了执行速度。
时间问题通常有三类:
- 当前时间:
Date.now()、new Date(); - 定时器:
setTimeout、setInterval; - 时间推进:需要证明过期、轮询或倒计时。
稳定测试必须控制时钟,而不是等待真实时间流逝。
2. 使用 Fake Timer 验证过期逻辑
实现一个轮询 Hook:
// src/hooks/usePolling.ts
import { useEffect, useRef, useState } from 'react';
export function usePolling(
task: () => Promise<void>,
intervalMs: number,
) {
const [running, setRunning] = useState(false);
const taskRef = useRef(task);
taskRef.current = task;
useEffect(() => {
let cancelled = false;
const run = async () => {
if (cancelled) return;
setRunning(true);
try {
await taskRef.current();
} finally {
if (!cancelled) {
setRunning(false);
}
}
};
const timer = window.setInterval(run, intervalMs);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [intervalMs]);
return { running };
}
测试时不等待真实的 60 秒:
// src/hooks/usePolling.test.tsx
import { renderHook, act } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { usePolling } from './usePolling';
afterEach(() => {
vi.useRealTimers();
});
describe('usePolling', () => {
it('每经过一个周期执行一次任务', async () => {
vi.useFakeTimers();
const task = vi.fn().mockResolvedValue(undefined);
renderHook(() => usePolling(task, 60_000));
expect(task).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
expect(task).toHaveBeenCalledTimes(1);
});
it('卸载后停止轮询', async () => {
vi.useFakeTimers();
const task = vi.fn().mockResolvedValue(undefined);
const { unmount } = renderHook(() => usePolling(task, 60_000));
unmount();
await act(async () => {
await vi.advanceTimersByTimeAsync(120_000);
});
expect(task).not.toHaveBeenCalled();
});
});
这里有三个关键点:
vi.useFakeTimers()把定时器替换为可控实现;vi.advanceTimersByTimeAsync()推进时间,并等待由定时器触发的异步任务;- 测试结束后调用
vi.useRealTimers(),防止 Fake Timer 泄漏到其他测试。
如果只使用同步的 advanceTimersByTime,定时器回调中的 Promise 可能尚未完成,立即断言会产生假失败。异步任务和时间推进同时存在时,优先使用异步版本,并把状态更新放入 act。
3. 使用 setSystemTime 固定当前时刻
// src/time/isExpired.ts
export function isExpired(expiresAt: Date): boolean {
return expiresAt.getTime() <= Date.now();
}
测试:
import { afterEach, describe, expect, it, vi } from 'vitest';
import { isExpired } from './isExpired';
afterEach(() => {
vi.useRealTimers();
});
describe('isExpired', () => {
it('在到期时刻之前未过期', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
expect(isExpired(new Date('2025-01-01T00:00:01.000Z'))).toBe(false);
});
it('在到期时刻及之后过期', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
expect(isExpired(new Date('2024-12-31T23:59:59.999Z'))).toBe(true);
expect(isExpired(new Date('2025-01-01T00:00:00.000Z'))).toBe(true);
});
});
Date.now() 的比较使用毫秒时间戳,所以边界必须明确:本例把 expiresAt <= now 定义为已过期。如果产品规则是“到整秒结束才过期”,则实现和测试都应表达该规则,而不能靠测试运行时机碰巧通过。
4. 更容易测试的设计:显式注入时钟
Fake Timer 适合测试现有代码,但业务核心也可以直接接收时间:
export function isExpiredAt(
expiresAt: Date,
now: Date,
): boolean {
return expiresAt.getTime() <= now.getTime();
}
此时测试不需要全局替换:
expect(
isExpiredAt(
new Date('2025-01-01T00:00:00.000Z'),
new Date('2025-01-01T00:00:00.000Z'),
),
).toBe(true);
显式注入的优点是依赖关系可见,缺点是需要在更多调用层传递 now。通常可以让边界层读取真实时间,再把时间传给纯业务函数:
export function createExpirationChecker(clock = () => new Date()) {
return (expiresAt: Date) => isExpiredAt(expiresAt, clock());
}
这样测试核心逻辑不依赖全局时钟,集成测试仍可验证真实时钟接入。
四、网络:控制边界,验证加载、成功、失败和取消
1. 网络请求不是纯函数
网络请求的结果不仅由参数决定,还取决于:
- 服务器响应;
- 网络延迟;
- HTTP 状态;
- JSON 格式;
- 用户是否离开页面;
- 请求是否被取消;
- 多个请求返回顺序。
因此,测试网络代码时不应访问真实后端。真实后端会让单元测试依赖环境、数据、认证和网络可用性。单元测试应在请求边界替换 fetch 或使用请求拦截工具,主动定义响应。
2. 一个带状态和 AbortController 的 Hook
// src/hooks/useUser.ts
import { useEffect, useState } from 'react';
export type User = {
id: string;
name: string;
};
type State =
| { status: 'loading'; data: null; error: null }
| { status: 'success'; data: User; error: null }
| { status: 'error'; data: null; error: Error };
export function useUser(userId: string): State {
const [state, setState] = useState<State>({
status: 'loading',
data: null,
error: null,
});
useEffect(() => {
const controller = new AbortController();
setState({
status: 'loading',
data: null,
error: null,
});
fetch(`/api/users/${encodeURIComponent(userId)}`, {
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return (await response.json()) as User;
})
.then((data) => {
setState({
status: 'success',
data,
error: null,
});
})
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
setState({
status: 'error',
data: null,
error: error instanceof Error
? error
: new Error('Unknown error'),
});
});
return () => controller.abort();
}, [userId]);
return state;
}
状态转移可以表示为:
stateDiagram-v2
[*] --> loading: mount 或 userId 变化
loading --> success: HTTP 2xx 且 JSON 合法
loading --> error: HTTP 非 2xx 或解析失败
loading --> [*]: unmount / abort
success --> loading: userId 变化
error --> loading: userId 变化
AbortController 的作用不是让请求一定停止于服务器,而是向 fetch 和相关客户端代码发出取消信号,并防止组件卸载后继续提交无意义的成功或失败状态。服务器是否已经收到请求、是否能撤销计算,取决于服务器协议和实现。
3. Mock fetch,但不要 Mock 被测状态机
测试请求成功:
// src/hooks/useUser.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useUser } from './useUser';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('useUser', () => {
it('先进入 loading,再进入 success', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ id: 'u1', name: 'Ada' }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
);
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useUser('u1'));
expect(result.current).toEqual({
status: 'loading',
data: null,
error: null,
});
await waitFor(() => {
expect(result.current).toEqual({
status: 'success',
data: { id: 'u1', name: 'Ada' },
error: null,
});
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/users/u1',
expect.objectContaining({
signal: expect.any(AbortSignal),
}),
);
});
});
waitFor 会重复执行回调,直到回调不再抛出异常或超时。它适合等待异步状态转换;不应在回调外立即断言异步结果,因为 Promise 微任务尚未完成。
测试 HTTP 错误:
it('HTTP 非成功状态进入 error', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response('Not Found', { status: 404 }),
),
);
const { result } = renderHook(() => useUser('missing'));
await waitFor(() => {
expect(result.current.status).toBe('error');
});
expect(result.current).toMatchObject({
status: 'error',
data: null,
error: new Error('HTTP 404'),
});
});
这里需要注意 Error 对象的比较。toEqual(new Error(...)) 在不同断言库和配置下可读性不如显式检查消息,因此也可以写为:
expect(result.current.error?.message).toBe('HTTP 404');
测试网络拒绝:
it('网络拒绝进入 error', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockRejectedValue(new Error('network down')),
);
const { result } = renderHook(() => useUser('u1'));
await waitFor(() => {
expect(result.current.status).toBe('error');
});
expect(result.current.error).toEqual(new Error('network down'));
});
测试卸载时取消:
it('卸载时调用 abort', () => {
const abort = vi.fn();
class TestAbortController {
signal = new AbortSignal();
abort = abort;
}
vi.stubGlobal('AbortController', TestAbortController);
vi.stubGlobal(
'fetch',
vi.fn(() => new Promise<Response>(() => {})),
);
const { unmount } = renderHook(() => useUser('u1'));
unmount();
expect(abort).toHaveBeenCalledTimes(1);
});
这个测试用一个永不完成的 Promise 模拟挂起请求,验证卸载路径是否触发取消。它没有等待请求完成,因为请求被刻意设计为不会完成。
在真实项目中,若多个测试、多个组件都需要模拟 API,使用 MSW 这类请求拦截工具通常比在每个测试里直接替换 global.fetch 更接近真实请求边界,也能统一维护成功、错误和延迟场景。但无论使用哪种工具,测试仍应断言应用状态和用户可观察结果,而不是只断言 Mock 被调用。
4. HTTP 错误与网络错误必须分开理解
fetch 的规范行为是:收到 HTTP 404、500 等响应时,Promise 通常仍然 resolve;只有网络层失败、请求被取消等情况才会 reject。因此,必须显式检查:
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
只写下面的代码是不完整的:
const response = await fetch(url);
const data = await response.json();
因为 404 响应可能继续进入 json(),最终把 HTTP 错误误报成 JSON 解析错误,或者在错误响应不是 JSON 时产生完全不同的失败信息。
5. 客户端和服务端边界
fetch 在现代浏览器和现代服务端 JavaScript 运行时中都可能存在,但测试环境的默认能力不同:
- 客户端组件测试通常运行在
jsdom; - 服务端函数测试可以使用 Node 环境;
- React Server Component 不是普通客户端组件,不能简单通过
render当作浏览器组件测试; - 框架中的 Server Action、路由处理器、数据库访问应作为服务端模块单独测试;
- 客户端组件测试应把服务端能力视为外部边界,通过 props、context、请求拦截或框架测试工具提供结果。
例如,数据库函数应直接测试其输入、返回值和错误映射,而不是在 jsdom 中伪造整个服务器运行时。客户端只需测试“服务端返回成功时显示什么、失败时显示什么、加载中如何表现”。
五、组件测试:通过用户可观察结果连接纯函数和 Hook
单元测试组件时,最稳定的边界通常是 DOM:
- 用户能看到的文本;
- 可访问角色和名称;
- 按钮是否可用;
- 表单提交后的结果;
- 错误信息是否出现。
示例组件:
// src/components/UserPanel.tsx
import { useUser } from '../hooks/useUser';
export function UserPanel({ userId }: { userId: string }) {
const state = useUser(userId);
if (state.status === 'loading') {
return <p role="status">Loading...</p>;
}
if (state.status === 'error') {
return (
<p role="alert">
Failed: {state.error.message}
</p>
);
}
return <h1>{state.data.name}</h1>;
}
测试:
// src/components/UserPanel.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi, afterEach } from 'vitest';
import { UserPanel } from './UserPanel';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('UserPanel', () => {
it('显示加载状态和用户名称', async () => {
let resolveRequest!: (response: Response) => void;
vi.stubGlobal(
'fetch',
vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveRequest = resolve;
}),
),
);
render(<UserPanel userId="u1" />);
expect(screen.getByRole('status')).toHaveTextContent('Loading');
resolveRequest(
new Response(JSON.stringify({ id: 'u1', name: 'Ada' }), {
status: 200,
}),
);
expect(await screen.findByRole('heading', { name: 'Ada' }))
.toBeInTheDocument();
});
it('显示请求错误', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockRejectedValue(new Error('network down')),
);
render(<UserPanel userId="u1" />);
expect(
await screen.findByRole('alert'),
).toHaveTextContent('network down');
});
});
getByRole 是同步查询,元素不存在时立即失败;findByRole 是异步查询,适合等待元素出现;queryByRole 在元素不存在是正常情况时返回 null,适合断言“不存在”。
这里没有断言:
expect(screen.getByTestId('user-panel-internal-state')).toBe(...);
因为内部状态不是组件对用户的契约。只要组件仍然显示加载、成功和错误状态,内部是 useState、Reducer 还是其他实现,都不应影响测试。
六、稳定断言:验证契约,而不是冻结实现
1. 稳定断言的含义
稳定断言是指:当外部行为没有变化、只有内部实现重构时,测试仍然通过;而当外部行为真的违反契约时,测试能够失败。
可以把测试依赖分为三层:
- 业务输出:例如金额、状态、错误消息;
- 用户可观察行为:例如可访问名称、按钮禁用、页面文本;
- 实现细节:例如 state 变量名、组件层级、私有函数调用顺序。
通常应优先断言前两层。第三层只有在它本身就是公共契约,例如某个适配器必须调用特定 SDK,才值得验证。
2. 几类稳定断言
断言完整对象时控制范围
expect(result).toMatchObject({
status: 'success',
data: { id: 'u1' },
});
toMatchObject 只要求指定字段符合预期,适合响应中包含不关心的服务端字段时使用。但不能滥用,否则关键字段缺失也可能漏测。核心业务对象应使用 toEqual 或逐字段断言。
对集合断言业务内容
不稳定:
expect(screen.getAllByRole('listitem')).toHaveLength(3);
如果数量不是业务契约,这个断言会因为增加装饰项而失败。
更稳定:
expect(screen.getByRole('listitem', { name: 'Keyboard' }))
.toBeInTheDocument();
如果顺序是业务要求,应明确断言顺序;否则不要因为 DOM 排列变化而绑定测试。
异步元素使用语义查询
expect(await screen.findByRole('alert')).toHaveTextContent(
'Failed',
);
不要用固定延时:
await new Promise((resolve) => setTimeout(resolve, 1000));
固定等待不能证明状态已经完成,只会让测试变慢;在繁忙机器上,一秒也可能不够。findBy* 和 waitFor 根据 DOM 或断言条件等待,失败时还会提供更接近原因的上下文。
事件处理器调用次数只在契约需要时断言
expect(onSubmit).toHaveBeenCalledTimes(1);
这只有在“提交一次只能通知一次”属于契约时才有意义。如果组件内部改成调用另一层函数,但用户看到的结果正确,测试不应因为调用次数变化而失败。
3. 快照测试的边界
快照会记录一大段序列化输出:
expect(container).toMatchSnapshot();
它适合以下情况:
- 输出结构本身是稳定的;
- 变更需要人工审阅;
- 快照内容不会包含随机 ID、时间、环境路径和无关属性。
它不适合代替行为断言。大组件快照一旦变得很长,开发者容易在更新快照时忽略真实回归。通常应先写:
expect(screen.getByRole('button', { name: 'Save' }))
.toBeEnabled();
再在确有结构审查价值的局部使用快照。
七、Mock、Stub、Spy 的边界和清理
Vitest 中常见的三种替换方式含义不同:
- Mock:提供一个替代实现,例如
vi.fn().mockResolvedValue(...); - Stub:替换全局或模块暴露的对象,例如
vi.stubGlobal('fetch', ...); - Spy:保留原实现,同时记录调用,例如
vi.spyOn(window, 'removeEventListener')。
如果测试替换了全局对象,必须恢复:
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
clearMocks: true 只清除调用记录,不一定恢复实现;restoreMocks: true 用于恢复 Spy 等被替换的原实现,但全局 stub 仍应根据 Vitest 配置和使用方式明确清理。不要依赖测试执行顺序来恢复环境。
模块 Mock 也有边界。假设直接 Mock 了整个 useUser:
vi.mock('../hooks/useUser');
那么 UserPanel 测试只验证了“组件能渲染一个预设 Hook 结果”,没有验证请求、加载和错误状态机。这样做并非错误,但它是组件单元测试,而不是 Hook 或网络集成测试。测试名称和职责应保持一致。
八、常见失败表现与诊断路径
1. act 警告
典型现象:
An update to ... inside a test was not wrapped in act(...)
原因通常是:
- 直接调用了状态更新函数;
- Promise 完成后触发了状态更新,但测试没有等待;
- Timer 推进后没有等待异步回调;
- 组件在测试结束后仍有异步更新。
诊断顺序:
- 找到产生状态更新的 Promise、Timer 或事件;
- 同步更新用
act(() => ...); - 异步更新用
await act(async () => ...)或findBy*/waitFor; - 检查卸载时是否清理定时器、事件和请求。
2. 测试偶尔失败
常见根因不是 Vitest 随机,而是未控制的外部状态:
- 使用真实当前时间;
- 等待真实网络;
- 测试之间共享模块变量;
- Fake Timer 未恢复;
- Promise 未等待完成;
- 使用随机 ID 但没有固定随机源;
- 断言完整 DOM 结构而非业务行为。
应先把外部依赖逐个变成可控输入,而不是增加重试次数。重试可以掩盖竞态,不能修复竞态。
3. “找不到元素”但页面最终会显示
错误:
render(<UserPanel userId="u1" />);
expect(screen.getByRole('heading', { name: 'Ada' }))
.toBeInTheDocument();
组件初次渲染仍处于 loading,所以同步查询必然失败。应改为:
expect(
await screen.findByRole('heading', { name: 'Ada' }),
).toBeInTheDocument();
如果元素可能出现也可能不出现,则使用 waitFor 或 queryBy*,但必须明确“不出现”是否是预期行为。
4. 更新状态后读到旧值
React 状态更新不是直接修改当前渲染中的普通变量:
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
应从下一次渲染后的 result.current 读取值。不要保存旧对象后期待它被原地修改:
const oldResult = result.current;
act(() => {
oldResult.increment();
});
// oldResult.count 不是可靠的“最新渲染结果”
Hook 返回对象可能在每次渲染中重新创建,测试应读取测试库暴露的当前结果。
九、一个可执行的测试组织方式
可以按被测边界组织文件:
src/
cart/
total.ts
total.test.ts
hooks/
useCounter.ts
useCounter.test.tsx
usePolling.ts
usePolling.test.tsx
useUser.ts
useUser.test.tsx
components/
UserPanel.tsx
UserPanel.test.tsx
test/
setup.ts
这种组织方式不是强制规范,但它使测试职责清晰:
total.test.ts不需要 DOM;useCounter.test.tsx验证状态转换;usePolling.test.tsx验证 Timer、异步和清理;useUser.test.tsx验证请求状态机;UserPanel.test.tsx验证用户可观察界面。
测试层次之间可以共享业务类型和测试数据,但不应把一个层次的 Mock 规则隐式泄漏到另一个层次。
十、从单元测试到真实边界验证
单元测试能快速验证局部因果关系,但不能证明所有系统行为。例如:
- jsdom 不能证明真实浏览器布局;
- Mock
fetch不能证明后端 API 契约; - Hook 测试不能证明路由、Provider 和框架数据加载器已经正确连接;
- Server Component 单独测试不能证明客户端水合和浏览器交互;
- 纯函数测试不能证明数据库事务或权限配置。
因此可以按边界增加更高层测试:
- 纯函数单元测试:输入和输出确定;
- Hook 单元测试:状态、Effect、清理和异步转换;
- 组件测试:DOM、可访问角色和用户行为;
- 请求拦截测试:客户端与 API 形状的连接;
- 端到端测试:真实浏览器、路由、服务端和关键业务路径。
层次越高,环境越真实,执行成本和故障来源也越多。稳定断言的原则在每一层都成立:测试当前层负责的契约,不越界替代其他层的证明。
当纯函数、Hook、时间和网络被分别建模后,测试就不再是“渲染组件然后等待某个文本”的技巧集合,而是对状态、时间和外部边界的可重复实验。稳定断言则把实验结果绑定到业务行为,而不是绑定到某次实现方式。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 实时数据:WebSocket、SSE、重连、心跳和缓存同步
- 下一篇:React 组件集成测试:用户行为、异步 UI、Provider 和边界
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论