CC 咖啡猫的工作空间 Coding Space

前端测试实践

前端测试是保障 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 更真实)

核心原则回顾:

  1. 测试用户行为,而非实现细节 —— 这是前端测试的第一原则
  2. 隔离外部依赖 —— API / Router / Store 都需要被 Mock 或提供测试环境
  3. 覆盖率不是目标 —— 关键路径的正确性比 100% 覆盖率更重要
  4. 异步操作必须 await —— 遗漏 await 是前端测试中最常见的 bug
  5. 防御式思维 —— 永远不要信任后端返回的数据格式,在测试中覆盖边界情况

最后更新:2026/06/29