前端测试实践
前端测试是保障 UI 交互逻辑正确性、组件稳定性和代码质量的核心手段。与后端测试不同,前端测试需要处理 DOM 操作、异步状态、浏览器 API 和视觉呈现等多维度的验证。
1. 前端测试金字塔
前端测试遵循与后端类似的金字塔模型,但由于涉及渲染层,各层级的侧重点和工具链有所不同。
┌───────────────┐
│ E2E │ 少量、关键用户路径覆盖
├───────────────┤
│ 集成测试 │ 中等、页面级交互流程
├───────────────┤
│ 组件测试 │ 较多、UI 组件行为校验
├───────────────┤
│ 单元测试 │ 大量、纯逻辑快速验证
└───────────────┘
| 层级 | 测试对象 | 工具 | 占比建议 | 运行速度 |
|---|---|---|---|---|
| 单元测试 | Utils、Composables / Hooks、纯函数 | Vitest / Jest | 50% | 毫秒级 |
| 组件测试 | 单个 Vue / React 组件 | Vue Test Utils / React Testing Library | 30% | 毫秒级 |
| 集成测试 | 页面模块、Store + 组件联动 | Testing Library + MSW | 15% | 秒级 |
| E2E 测试 | 完整用户操作流程 | Playwright / Cypress | 5% | 秒~分钟级 |
各层级职责
- 单元测试:验证工具函数、自定义 Hooks / Composables、Store Actions 的纯逻辑逻辑,不涉及 DOM 渲染
- 组件测试:验证组件渲染输出、Props 驱动、事件触发、Slot 内容等,不关心内部实现细节
- 集成测试:验证多个组件协作、路由切换、状态管理流程、API 交互等真实场景
- E2E 测试:模拟用户在真实浏览器中完成完整操作链路(登录 -> 操作 -> 跳转),覆盖核心业务流程
2. 测试框架选型对比
2.1 核心测试框架
| 维度 | Vitest | Jest | Mocha + Chai |
|---|---|---|---|
| 性能 | 基于 Vite,HMR 原生支持,速度极快 | 中等,配置较重 | 快,但需额外选配断言库 |
| 配置复杂度 | 零配置,Vite 项目开箱即用 | 需要 jest.config 配置 | 需要搭配 Chai/Sinon |
| TypeScript 支持 | 原生支持,无需额外配置 | 需要 ts-jest / @swc/jest | 需额外配置 |
| ESM 支持 | 原生 ESM | 兼容性一般 | 部分支持 |
| Mock 能力 | 内置 vi(兼容 jest API) | 内置 jest.fn/jest.mock | 需 Sinon |
| 代码覆盖率 | 内置 c8 / istanbul | 内置 istanbul | 需 nyc |
| 社区生态 | 快速增长 | 最成熟 | 较成熟但逐渐边缘化 |
| 推荐场景 | Vite 项目首选 | 遗留 CRA 项目 | 不推荐新项目使用 |
结论:新项目统一推荐 Vitest。它兼容 Jest API(describe/it/expect),迁移成本极低,且与 Vite 共享配置,速度优势明显。
2.2 Vue 组件测试工具
| 维度 | Vue Test Utils | Testing Library (Vue) |
|---|---|---|
| 哲学 | 关注组件实例和内部状态 | 关注用户可见行为 |
| 查询方式 | .find(), .findComponent() |
getByText, getByRole 等 |
| 可访问性 | 不强调 | 核心设计原则 |
| 内部状态访问 | 支持 vm 直接访问 |
不推荐访问内部状态 |
| 触发事件 | wrapper.trigger() |
fireEvent / userEvent |
| 异步处理 | await wrapper.vm.$nextTick() |
waitFor, findByText |
| 成熟度 | 最成熟 | 较新 |
| 推荐场景 | 需要验证组件内部逻辑时 | 关注用户交互体验时 |
推荐组合:Vue Test Utils + @vue/test-utils 作为基础,结合 Testing Library 的查询哲学,优先测试用户可见行为。
2.3 React 组件测试工具
| 维度 | React Testing Library | Enzyme |
|---|---|---|
| 哲学 | 行为驱动,模拟用户视角 | 实现驱动,访问组件实例 |
| 维护状态 | 活跃维护(推荐) | 已停止维护(Airbnb) |
| 渲染方式 | render() + screen |
shallow/mount/render |
| 浅渲染 | 不支持(不推荐) | 支持 shallow |
| 状态访问 | 不提供 | 提供 instance() / state() |
| 推荐度 | 强烈推荐 | 不推荐新项目 |
结论:React 新项目只推荐 React Testing Library,Enzyme 已停维护。
3. 测试原则:前端版的 FIRST
对标 Java 的 FIRST 原则(Fast, Isolated, Repeatable, Self-validating, Timely),前端测试也有一套细化的指导原则。
| 原则 | 说明 | 前端落地要点 |
|---|---|---|
| Fast(快速) | 测试应该在毫秒级完成 | 避免真实浏览器渲染(用 jsdom);避免真实 API 调用(用 MSW Mock) |
| Isolated(隔离) | 每个测试独立运行 | 每个 beforeEach 重置 Mock、清除 DOM、恢复定时器 |
| Repeatable(可重复) | 任意顺序运行结果一致 | Mock 随机数、Date.now、Math.random;避免测试间共享状态 |
| Self-validating(自验证) | 测试自身判断通过/失败 | 避免手动查看 DOM;使用 expect + 断言匹配器 |
| Timely(及时) | 测试与代码同步编写 | 采用 TDD 或与实现同时编写;组件变更时同步更新测试 |
3.1 补充原则
| 前端特有原则 | 说明 |
|---|---|
| 用户视角优先 | 通过用户可见的文本/角色查询元素,而非 CSS class 或组件内部状态 |
| 避免测试实现细节 | 不测试私有方法、内部 state、生命周期钩子顺序 |
| 测试行为而非实现 | 关注"点击按钮后发生了什么",而非"点击按钮调用了哪个方法" |
| 可访问性驱动 | 优先使用 getByRole、getByLabelText 等可访问性查询 |
3.2 好的实践 vs 不好的实践
✅ 好的实践(用户视角,测试行为):
// React - 好的实践
it('should show error when email is invalid', async () => {
render(<LoginForm />);
await userEvent.type(screen.getByLabelText('邮箱'), 'invalid-email');
await userEvent.click(screen.getByRole('button', { name: '登录' }));
expect(screen.getByText('请输入有效的邮箱地址')).toBeInTheDocument();
});
❌ 不好的实践(实现细节,测试内部状态):
// React - 不好的实践
it('should set error state when email is invalid', () => {
const wrapper = mount(<LoginForm />);
wrapper.find('input[type="email"]').simulate('change', { target: { value: 'invalid' } });
wrapper.find('button').simulate('click');
expect(wrapper.state('emailError')).toBe(true); // 测试内部状态,脆弱
expect(wrapper.find('.error-msg').exists()).toBe(true); // 依赖 CSS class
});
4. AAA 测试结构
Arrange-Act-Assert(准备-执行-断言)是通用的测试结构模式,前端测试同样适用。
4.1 Vue 组件测试示例
<!-- Counter.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue';
const props = withDefaults(defineProps<{ initial?: number; min?: number; max?: number }>(), {
initial: 0,
min: 0,
max: 100,
});
const emit = defineEmits<{ change: [value: number] }>();
const count = ref(props.initial);
const disabled = computed(() => count.value <= props.min || count.value >= props.max);
function increment() {
if (count.value < props.max) {
count.value++;
emit('change', count.value);
}
}
function decrement() {
if (count.value > props.min) {
count.value--;
emit('change', count.value);
}
}
</script>
<template>
<div class="counter">
<button :disabled="count <= min" @click="decrement" aria-label="减">-</button>
<span role="status">{{ count }}</span>
<button :disabled="count >= max" @click="increment" aria-label="加">+</button>
</div>
</template>
// Counter.spec.ts - Vue 组件测试
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';
describe('Counter.vue', () => {
it('should increment count when + button is clicked', async () => {
// Arrange:准备组件
const wrapper = mount(Counter, {
props: { initial: 0, min: 0, max: 10 },
});
// Act:执行操作
await wrapper.find('button[aria-label="加"]').trigger('click');
// Assert:验证结果
expect(wrapper.find('span').text()).toBe('1');
});
it('should emit change event with new value when increment', async () => {
// Arrange
const wrapper = mount(Counter, {
props: { initial: 5, min: 0, max: 10 },
});
// Act
await wrapper.find('button[aria-label="加"]').trigger('click');
// Assert
expect(wrapper.emitted('change')).toBeTruthy();
expect(wrapper.emitted('change')![0]).toEqual([6]);
});
it('should disable increment button when count reaches max', async () => {
// Arrange
const wrapper = mount(Counter, {
props: { initial: 10, min: 0, max: 10 },
});
// Act & Assert
expect(wrapper.find('button[aria-label="加"]').attributes('disabled')).toBeDefined();
expect(wrapper.find('span').text()).toBe('10');
});
});
4.2 React 组件测试示例
// Counter.tsx
import React, { useState, useCallback } from 'react';
interface CounterProps {
initial?: number;
min?: number;
max?: number;
onChange?: (value: number) => void;
}
export const Counter: React.FC<CounterProps> = ({
initial = 0,
min = 0,
max = 100,
onChange,
}) => {
const [count, setCount] = useState(initial);
const disabled = count <= min || count >= max;
const increment = useCallback(() => {
setCount((prev) => {
const next = Math.min(prev + 1, max);
onChange?.(next);
return next;
});
}, [max, onChange]);
const decrement = useCallback(() => {
setCount((prev) => {
const next = Math.max(prev - 1, min);
onChange?.(next);
return next;
});
}, [min, onChange]);
return (
<div className="counter">
<button disabled={count <= min} onClick={decrement} aria-label="减">-</button>
<span role="status">{count}</span>
<button disabled={count >= max} onClick={increment} aria-label="加">+</button>
</div>
);
};
// Counter.test.tsx - React 组件测试
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('should increment count when + button is clicked', async () => {
// Arrange
render(<Counter initial={0} min={0} max={10} />);
const user = userEvent.setup();
// Act
await user.click(screen.getByRole('button', { name: '加' }));
// Assert
expect(screen.getByRole('status')).toHaveTextContent('1');
});
it('should call onChange with new value when increment', async () => {
// Arrange
const onChange = vi.fn();
render(<Counter initial={5} min={0} max={10} onChange={onChange} />);
const user = userEvent.setup();
// Act
await user.click(screen.getByRole('button', { name: '加' }));
// Assert
expect(onChange).toHaveBeenCalledWith(6);
});
it('should disable increment button when count reaches max', async () => {
// Arrange
render(<Counter initial={10} min={0} max={10} />);
// Assert
expect(screen.getByRole('button', { name: '加' })).toBeDisabled();
expect(screen.getByRole('status')).toHaveTextContent('10');
});
});
5. Vue 单元测试
5.1 测试 Composables
Composables 是 Vue 3 的逻辑复用单元,测试时不需要挂载组件,直接调用组合式函数即可。
// composables/useCounter.ts
import { ref, computed } from 'vue';
export function useCounter(initial = 0, min = 0, max = 100) {
const count = ref(initial);
const canIncrement = computed(() => count.value < max);
const canDecrement = computed(() => count.value > min);
function increment() {
if (canIncrement.value) count.value++;
}
function decrement() {
if (canDecrement.value) count.value--;
}
function reset() {
count.value = initial;
}
return { count, canIncrement, canDecrement, increment, decrement, reset };
}
// composables/__tests__/useCounter.spec.ts
import { describe, it, expect } from 'vitest';
import { useCounter } from '../useCounter';
describe('useCounter', () => {
it('should initialize with default value', () => {
const { count } = useCounter();
expect(count.value).toBe(0);
});
it('should initialize with custom value', () => {
const { count } = useCounter(10);
expect(count.value).toBe(10);
});
it('should increment correctly', () => {
const { count, increment } = useCounter(0, 0, 10);
increment();
expect(count.value).toBe(1);
});
it('should not exceed max value', () => {
const { count, canIncrement, increment } = useCounter(10, 0, 10);
increment();
expect(count.value).toBe(10);
expect(canIncrement.value).toBe(false);
});
it('should reset to initial value', () => {
const { count, increment, reset } = useCounter(5, 0, 10);
increment(); // 6
increment(); // 7
reset();
expect(count.value).toBe(5);
});
});
测试带异步请求的 Composable
// composables/useFetch.ts
import { ref, onUnmounted } from 'vue';
interface UseFetchOptions {
immediate?: boolean;
timeout?: number;
}
export function useFetch<T>(url: string, options: UseFetchOptions = {}) {
const { immediate = true, timeout = 5000 } = options;
const data = ref<T | null>(null);
const error = ref<string | null>(null);
const loading = ref(false);
let controller: AbortController | null = null;
async function execute() {
controller = new AbortController();
loading.value = true;
error.value = null;
const timeoutId = setTimeout(() => controller!.abort(), timeout);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
data.value = await response.json();
} catch (e: unknown) {
if (e instanceof DOMException && e.name === 'AbortError') {
error.value = '请求超时';
} else {
error.value = e instanceof Error ? e.message : '未知错误';
}
} finally {
loading.value = false;
clearTimeout(timeoutId);
}
}
if (immediate) execute();
onUnmounted(() => controller?.abort());
return { data, error, loading, execute };
}
// composables/__tests__/useFetch.spec.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useFetch } from '../useFetch';
describe('useFetch', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllTimers();
});
it('should start with loading state when immediate is true', () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'));
const { loading, data, error } = useFetch('/api/users', { immediate: true });
expect(loading.value).toBe(true);
expect(data.value).toBeNull();
expect(error.value).toBeNull();
});
it('should set data on successful fetch', async () => {
const mockData = { id: 1, name: 'Alice' };
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(mockData), { status: 200 })
);
const { loading, data } = useFetch('/api/users');
// 等待微任务队列执行
await vi.advanceTimersToNextTimerAsync();
expect(data.value).toEqual(mockData);
expect(loading.value).toBe(false);
});
it('should set error on HTTP error', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(null, { status: 500 })
);
const { error, loading } = useFetch('/api/users');
await vi.advanceTimersToNextTimerAsync();
expect(error.value).toBe('HTTP 500');
expect(loading.value).toBe(false);
});
it('should not fetch when immediate is false', () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
useFetch('/api/users', { immediate: false });
expect(fetchSpy).not.toHaveBeenCalled();
});
});
5.2 测试组件(Props、Emits、Slots)
<!-- UserCard.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import type { User } from '@/types';
const props = defineProps<{
user: User;
showEmail?: boolean;
}>();
const emit = defineEmits<{
select: [id: number];
delete: [id: number];
}>();
const displayName = computed(() => {
return props.user.nickname || props.user.username;
});
</script>
<template>
<div class="user-card" @click="emit('select', user.id)">
<slot name="avatar">
<img :src="user.avatar || '/default-avatar.png'" alt="头像" />
</slot>
<h3>{{ displayName }}</h3>
<p v-if="showEmail && user.email">{{ user.email }}</p>
<button v-if="!user.disabled" @click.stop="emit('delete', user.id)" aria-label="删除">
删除
</button>
<slot name="footer" />
</div>
</template>
// __tests__/UserCard.spec.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import UserCard from '../UserCard.vue';
const mockUser: User = {
id: 1,
username: 'alice',
nickname: 'Alice',
email: 'alice@example.com',
avatar: '/avatar.png',
disabled: false,
};
describe('UserCard.vue', () => {
it('should render username when nickname is empty', () => {
const wrapper = mount(UserCard, {
props: { user: { ...mockUser, nickname: '' } },
});
expect(wrapper.find('h3').text()).toBe('alice');
});
it('should emit select event when card is clicked', async () => {
const wrapper = mount(UserCard, { props: { user: mockUser } });
await wrapper.find('.user-card').trigger('click');
expect(wrapper.emitted('select')).toBeTruthy();
expect(wrapper.emitted('select')![0]).toEqual([1]);
});
it('should not show email when showEmail is false', () => {
const wrapper = mount(UserCard, {
props: { user: mockUser, showEmail: false },
});
expect(wrapper.find('p').exists()).toBe(false);
});
it('should show email when showEmail is true', () => {
const wrapper = mount(UserCard, {
props: { user: mockUser, showEmail: true },
});
expect(wrapper.text()).toContain('alice@example.com');
});
it('should not show delete button when user is disabled', () => {
const wrapper = mount(UserCard, {
props: { user: { ...mockUser, disabled: true } },
});
expect(wrapper.find('button[aria-label="删除"]').exists()).toBe(false);
});
it('should render slot content', () => {
const wrapper = mount(UserCard, {
props: { user: mockUser },
slots: {
footer: '<div class="footer-extra">额外信息</div>',
},
});
expect(wrapper.find('.footer-extra').exists()).toBe(true);
expect(wrapper.find('.footer-extra').text()).toBe('额外信息');
});
});
5.3 测试 Pinia Store
// stores/__tests__/userStore.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useUserStore } from '../userStore';
import { useRouter } from 'vue-router';
// Mock Vue Router
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
})),
}));
describe('useUserStore', () => {
beforeEach(() => {
// 每次测试前重置 Pinia 实例
setActivePinia(createPinia());
});
it('should initialize with default state', () => {
const store = useUserStore();
expect(store.users).toEqual([]);
expect(store.loading).toBe(false);
expect(store.currentUser).toBeNull();
});
it('should add user correctly', () => {
const store = useUserStore();
store.addUser({ id: 1, name: 'Alice' });
expect(store.users).toHaveLength(1);
expect(store.userCount).toBe(1);
});
it('should compute userCount correctly', () => {
const store = useUserStore();
store.addUser({ id: 1, name: 'Alice' });
store.addUser({ id: 2, name: 'Bob' });
expect(store.userCount).toBe(2);
});
it('should remove user by id', () => {
const store = useUserStore();
store.addUser({ id: 1, name: 'Alice' });
store.addUser({ id: 2, name: 'Bob' });
store.removeUser(1);
expect(store.users).toHaveLength(1);
expect(store.users[0].id).toBe(2);
});
});
5.4 模拟 Vue Router
// 方式一:全局 mock
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
params: { id: '123' },
query: { page: '1' },
})),
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
})),
createRouter: vi.fn(),
createWebHistory: vi.fn(),
}));
// 方式二:在每个测试中定制 mock
import { useRouter, useRoute } from 'vue-router';
describe('UserDetail.vue', () => {
it('should fetch user by route param id', async () => {
const push = vi.fn();
const mockRoute = { params: { id: '42' } };
vi.mocked(useRouter).mockReturnValue({ push } as any);
vi.mocked(useRoute).mockReturnValue(mockRoute as any);
const wrapper = mount(UserDetail, {
global: {
stubs: ['router-link', 'router-view'],
},
});
// ... 验证组件行为
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('用户 42');
});
});
6. React 单元测试
6.1 测试自定义 Hooks(renderHook)
renderHook是@testing-library/react提供的专门测试 Hooks 的方法,不需要创建组件。
// hooks/useCounter.ts
import { useState, useCallback, useMemo } from 'react';
interface UseCounterOptions {
initial?: number;
min?: number;
max?: number;
}
export function useCounter({ initial = 0, min = 0, max = 100 }: UseCounterOptions = {}) {
const [count, setCount] = useState(initial);
const canIncrement = count < max;
const canDecrement = count > min;
const increment = useCallback(() => {
setCount((prev) => Math.min(prev + 1, max));
}, [max]);
const decrement = useCallback(() => {
setCount((prev) => Math.max(prev - 1, min));
}, [min]);
const reset = useCallback(() => {
setCount(initial);
}, [initial]);
return { count, canIncrement, canDecrement, increment, decrement, reset };
}
// hooks/__tests__/useCounter.test.ts
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from '../useCounter';
describe('useCounter', () => {
it('should initialize with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('should initialize with custom value', () => {
const { result } = renderHook(() => useCounter({ initial: 10 }));
expect(result.current.count).toBe(10);
});
it('should increment correctly', () => {
const { result } = renderHook(() => useCounter({ initial: 0, max: 10 }));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
it('should not exceed max value', () => {
const { result } = renderHook(() => useCounter({ initial: 10, min: 0, max: 10 }));
act(() => result.current.increment());
expect(result.current.count).toBe(10);
expect(result.current.canIncrement).toBe(false);
});
it('should reset to initial value', () => {
const { result } = renderHook(() => useCounter({ initial: 5, min: 0, max: 10 }));
act(() => { result.current.increment(); });
act(() => { result.current.increment(); });
act(() => { result.current.reset(); });
expect(result.current.count).toBe(5);
});
});
测试带异步逻辑的 Hook
// hooks/useFetch.ts
import { useState, useEffect, useCallback, useRef } from 'react';
interface UseFetchResult<T> {
data: T | null;
error: string | null;
loading: boolean;
execute: () => void;
}
export function useFetch<T = unknown>(url: string, immediate = true): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const execute = useCallback(async () => {
abortRef.current?.abort();
abortRef.current = new AbortController();
setLoading(true);
setError(null);
try {
const response = await fetch(url, { signal: abortRef.current.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
setData(json);
} catch (e: unknown) {
if (e instanceof DOMException && e.name === 'AbortError') return;
setError(e instanceof Error ? e.message : '未知错误');
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
if (immediate) execute();
return () => abortRef.current?.abort();
}, [immediate, execute]);
return { data, error, loading, execute };
}
// hooks/__tests__/useFetch.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from '../useFetch';
describe('useFetch', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllTimers();
});
it('should start with loading state when immediate is true', () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
() => new Promise(() => {}) // 永不 resolve
);
const { result } = renderHook(() => useFetch('/api/users'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
expect(result.current.error).toBeNull();
});
it('should return data on success', async () => {
const mockData = { id: 1, name: 'Alice' };
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(mockData), { status: 200 })
);
const { result } = renderHook(() => useFetch('/api/users'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
});
it('should handle HTTP error', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(null, { status: 500 })
);
const { result } = renderHook(() => useFetch('/api/users'));
await waitFor(() => {
expect(result.current.error).toBe('HTTP 500');
});
});
});
6.2 测试组件(render、fireEvent、screen)
// components/SearchBox.tsx
import React, { useState } from 'react';
interface SearchBoxProps {
onSearch: (keyword: string) => void;
placeholder?: string;
}
export const SearchBox: React.FC<SearchBoxProps> = ({
onSearch,
placeholder = '搜索...',
}) => {
const [keyword, setKeyword] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!keyword.trim()) return;
onSearch(keyword.trim());
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={placeholder}
aria-label="搜索关键词"
/>
<button type="submit" disabled={!keyword.trim()}>
搜索
</button>
</form>
);
};
// components/__tests__/SearchBox.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchBox } from '../SearchBox';
describe('SearchBox', () => {
it('should render input with placeholder', () => {
render(<SearchBox onSearch={vi.fn()} placeholder="输入关键词" />);
expect(screen.getByPlaceholderText('输入关键词')).toBeInTheDocument();
});
it('should disable submit button when input is empty', () => {
render(<SearchBox onSearch={vi.fn()} />);
expect(screen.getByRole('button', { name: '搜索' })).toBeDisabled();
});
it('should call onSearch with keyword on submit', async () => {
const onSearch = vi.fn();
const user = userEvent.setup();
render(<SearchBox onSearch={onSearch} />);
await user.type(screen.getByLabelText('搜索关键词'), '前端测试');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(onSearch).toHaveBeenCalledWith('前端测试');
});
it('should not call onSearch with empty string', async () => {
const onSearch = vi.fn();
const user = userEvent.setup();
render(<SearchBox onSearch={onSearch} />);
// 输入空格后提交
await user.type(screen.getByLabelText('搜索关键词'), ' ');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(onSearch).not.toHaveBeenCalled();
});
});
6.3 测试 Zustand Store
// stores/__tests__/userStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useUserStore } from '../userStore';
describe('useUserStore', () => {
beforeEach(() => {
// 重置 store 状态
useUserStore.setState({ users: [], loading: false, currentUser: null });
});
it('should initialize with default state', () => {
const state = useUserStore.getState();
expect(state.users).toEqual([]);
expect(state.loading).toBe(false);
expect(state.currentUser).toBeNull();
});
it('should add user', () => {
useUserStore.getState().addUser({ id: 1, name: 'Alice' });
const state = useUserStore.getState();
expect(state.users).toHaveLength(1);
expect(state.users[0].name).toBe('Alice');
});
it('should remove user by id', () => {
useUserStore.getState().addUser({ id: 1, name: 'Alice' });
useUserStore.getState().addUser({ id: 2, name: 'Bob' });
useUserStore.getState().removeUser(1);
const state = useUserStore.getState();
expect(state.users).toHaveLength(1);
expect(state.users[0].id).toBe(2);
});
it('should set current user', () => {
const user = { id: 1, name: 'Alice' };
useUserStore.getState().setCurrentUser(user);
expect(useUserStore.getState().currentUser).toEqual(user);
});
});
6.4 模拟 React Router
// 使用 MemoryRouter 包裹测试组件
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import UserDetail from './UserDetail';
describe('UserDetail', () => {
it('should render user based on route param', async () => {
// Arrange:使用 MemoryRouter 模拟路由
render(
<MemoryRouter initialEntries={['/users/42']}>
<Routes>
<Route path="/users/:id" element={<UserDetail />} />
</Routes>
</MemoryRouter>
);
// Assert
expect(screen.getByText('用户 42')).toBeInTheDocument();
});
it('should navigate back when back button is clicked', async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={['/users/42', '/users/10']} initialIndex={1}>
<Routes>
<Route path="/users/:id" element={<UserDetail />} />
<Route path="/users" element={<div>用户列表</div>} />
</Routes>
</MemoryRouter>
);
await user.click(screen.getByRole('button', { name: '返回列表' }));
expect(screen.getByText('用户列表')).toBeInTheDocument();
});
});
7. Mock 策略
7.1 API 请求 Mock
前端测试中,API Mock 是核心能力。推荐 MSW(Mock Service Worker),它在 Service Worker 层面拦截请求,无需修改应用代码。
MSW 的优势
| 维度 | MSW | vi.mock(fetch) | axios-mock-adapter |
|---|---|---|---|
| 拦截层 | Service Worker / 请求级别 | 模块级别 | Axios 实例级别 |
| 代码侵入性 | 无需修改 | 无需修改 | 需修改 Axios 使用方式 |
| E2E 共享 | 同一套 Mock 可在 E2E 中复用 | 不可复用 | 不可复用 |
| 浏览器兼容 | 浏览器中可直接使用 | 不可用 | 仅在 Axios 中 |
| 请求验证 | 原生支持 | 需额外断言 | 支持 |
| 学习成本 | 中等 | 低 | 低 |
| 推荐场景 | 强烈推荐 | 小型项目 | Axios 专属项目 |
MSW 使用示例
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
// 永远不要信任后端返回的数据 —— 模拟各种边界情况
export const handlers = [
// 正常返回
http.get('/api/users', () => {
return HttpResponse.json({
code: 0,
data: [
{ id: 1, name: 'Alice', email: 'alice@example.com', status: 'active' },
{ id: 2, name: 'Bob', email: null, status: 'inactive' }, // email 可能为 null
],
total: 2,
});
}),
// 分页查询
http.get('/api/users', ({ request }) => {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
return HttpResponse.json({
code: 0,
data: Array.from({ length: pageSize }, (_, i) => ({
id: (page - 1) * pageSize + i + 1,
name: `User ${(page - 1) * pageSize + i + 1}`,
})),
total: 100,
});
}),
// 创建用户 - 模拟后端返回不可靠字段
http.post('/api/users', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
if (!body.name) {
return HttpResponse.json({ code: 400, message: 'name is required' }, { status: 400 });
}
// 后端可能返回多余字段或字段名不一致
return HttpResponse.json({
code: 0,
data: { id: Math.floor(Math.random() * 10000), user_name: body.name, extra_field: null },
});
}),
// 模拟网络错误
http.get('/api/network-error', () => {
return HttpResponse.error();
}),
// 模拟超时
http.get('/api/timeout', async () => {
await new Promise((resolve) => setTimeout(resolve, 10000));
return HttpResponse.json({ code: 0 });
}),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
// 用于测试的服务端 MSW
export const server = setupServer(...handlers);
// setup.ts - 测试配置文件
import { beforeAll, afterAll, afterEach } from 'vitest';
import { server } from './mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// components/__tests__/UserList.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/mocks/server';
import { UserList } from '../UserList';
describe('UserList', () => {
it('should render user list from API', async () => {
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
it('should show empty state when no users', async () => {
// 覆盖 handler:返回空列表
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ code: 0, data: [], total: 0 });
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('暂无用户数据')).toBeInTheDocument();
});
});
it('should handle network error gracefully', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.error();
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('网络请求失败')).toBeInTheDocument();
});
});
});
7.2 定时器 Mock
测试中包含
setTimeout、setInterval、Date.now等时间相关逻辑时,必须使用 Fake Timers 来避免真实等待。
// utils/__tests__/debounce.spec.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { debounce } from '../debounce';
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllTimers();
});
it('should delay execution', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});
it('should cancel previous execution when called again', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
vi.advanceTimersByTime(200);
debounced();
vi.advanceTimersByTime(200);
// 第一次被取消,只执行第二次
expect(fn).toHaveBeenCalledTimes(1);
});
});
// 测试倒计时逻辑
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('countdown', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllTimers();
});
it('should count down from 60 to 59 after 1 second', () => {
const { result } = renderHook(() => useCountdown(60));
expect(result.current).toBe(60);
vi.advanceTimersByTime(1000);
expect(result.current).toBe(59);
});
});
7.3 Mock 策略总结
| Mock 对象 | 方法 | 场景 |
|---|---|---|
| API 请求 | MSW(推荐) / vi.mock(fetch) | 所有网络请求 |
| 定时器 | vi.useFakeTimers | setTimeout / setInterval / Date |
| 路由 | MemoryRouter(React)/ vi.mock(vue-router) | 路由参数/导航 |
| 随机值 | vi.spyOn(Math, 'random') | UUID 生成、随机排序 |
| 浏览器 API | vi.stubGlobal / jsdom mock | localStorage / IntersectionObserver |
| 组件 | vi.mock 或 stub | 子组件、第三方 UI 库 |
8. 测试覆盖率目标与常见陷阱
8.1 覆盖率目标
| 指标 | 工具类 / Utils | Composables / Hooks | 组件 | 页面 / Pages |
|---|---|---|---|---|
| 行覆盖率 | >= 90% | >= 85% | >= 70% | >= 60% |
| 分支覆盖率 | >= 85% | >= 80% | >= 65% | >= 50% |
| 函数覆盖率 | >= 95% | >= 90% | >= 75% | >= 65% |
重要:覆盖率是参考指标,不是目标。代码覆盖率 100% 不代表质量好,关键路径的断言质量远重要于覆盖率数字。
# Vitest 覆盖率配置(vitest.config.ts)
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
include: ['src/**/*.{ts,vue,tsx}'],
exclude: [
'src/**/*.d.ts',
'src/**/index.ts',
'src/vite-env.d.ts',
],
thresholds: {
statements: 70,
branches: 65,
functions: 75,
lines: 70,
},
},
},
});
8.2 常见陷阱与踩坑点
陷阱 1:过度 Mock 导致测试失效
❌ 反例:Mock 了全部 Store,测试通过但真实代码不工作。
// 过度 Mock —— 测试通过但实际不工作
vi.mock('@/stores/userStore', () => ({
useUserStore: () => ({
users: mockedUsers,
loading: false,
fetchUsers: vi.fn(),
}),
}));
✅ 正例:使用真实的 Store,只 Mock 网络层。
// 用真实 Store,Mock API 层
import { createPinia, setActivePinia } from 'pinia';
import { server } from '@/mocks/server';
beforeEach(() => {
setActivePinia(createPinia());
});
陷阱 2:测试组件内部方法而非用户行为
❌ 反例:直接调用组件方法。
// Vue
const wrapper = mount(LoginForm);
await wrapper.vm.handleLogin(); // 测试内部方法,脆弱
// React
render(<LoginForm />);
// 无法直接访问内部方法,但有人会设法 export
✅ 正例:通过用户操作触发。
// 模拟用户行为
await user.click(screen.getByRole('button', { name: '登录' }));
陷阱 3:异步测试中遗漏 await
❌ 反例:
it('should show data', () => {
render(<DataList />);
expect(screen.getByText('Alice')).toBeInTheDocument(); // 数据还没加载完
});
✅ 正例:
it('should show data', async () => {
render(<DataList />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
// 或使用 findByText(内置 waitFor)
expect(await screen.findByText('Alice')).toBeInTheDocument();
});
陷阱 4:DOM 清理不彻底导致测试间污染
// 正确做法:每个测试前重置
beforeEach(() => {
// React Testing Library 自动清理 DOM
// Vitest 需要手动清理 Mock
vi.clearAllMocks();
localStorage.clear();
});
陷阱 5:脆弱的选择器
❌ 总是使用 CSS class 选择器,一旦重构就挂:
wrapper.find('.user-name'); // 重构重命名 class 就挂
✅ 优先使用角色和文本:
// Vue
wrapper.find('[data-testid="user-name"]');
// React
screen.getByRole('heading', { name: /用户名/ });
陷阱 6:对第三方 UI 库组件执行深层断言
// 反例:依赖 Element Plus / Ant Design 内部 DOM 结构
expect(wrapper.find('.el-input__inner').exists()).toBe(true);
// 正例:只验证可见表现
expect(screen.getByRole('textbox', { name: '用户名' })).toBeInTheDocument();
9. 推荐配置速查
9.1 Vitest 配置(vue 项目)
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{spec,test}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
},
},
});
9.2 Vitest 配置(react 项目)
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{spec,test}.{ts,tsx}'],
},
});
9.3 测试 setup 文件
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { vi, beforeEach } from 'vitest';
// MSW 服务端启动
import { server } from '@/mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
beforeEach(() => {
// 清理所有 Mock 状态
vi.clearAllMocks();
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
10. 总结
| 维度 | 推荐方案 |
|---|---|
| 测试框架 | Vitest(Vite 项目首选) |
| 组件测试 | Vue Test Utils / React Testing Library |
| API Mock | MSW(Mock Service Worker) |
| E2E 测试 | Playwright(推荐) |
| 覆盖率工具 | Vitest 内置 v8 / istanbul |
| 断言库 | Vitest 内置 expect + @testing-library/jest-dom |
| 用户事件模拟 | @testing-library/user-event(比 fireEvent 更真实) |
核心原则回顾:
- 测试用户行为,而非实现细节 —— 这是前端测试的第一原则
- 隔离外部依赖 —— API / Router / Store 都需要被 Mock 或提供测试环境
- 覆盖率不是目标 —— 关键路径的正确性比 100% 覆盖率更重要
- 异步操作必须 await —— 遗漏 await 是前端测试中最常见的 bug
- 防御式思维 —— 永远不要信任后端返回的数据格式,在测试中覆盖边界情况
最后更新:2026/06/29