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、生命周期钩子顺序
测试行为而非实现 关注"点击按钮后发生了什么",而非"点击按钮调用了哪个方法"
可访问性驱动 优先使用 getByRolegetByLabelText 等可访问性查询

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

测试中包含 setTimeoutsetIntervalDate.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