最后更新于:2026年7月

前端测试指南
前端测试:质量保障的最后防线

不写测试的代码,就像不系安全带开车。 大多数时候没事,但一旦出事就是大事故。

根据 2025 年 State of JS 调查,超过 60% 的前端开发者会写单元测试,但只有不到 20% 的人写 E2E 测试。很多人觉得测试「太麻烦」「太花时间」「项目赶就跳过了」。

但实际上,合理的测试策略不仅不会拖慢开发,反而能提升开发速度——因为你会更自信地重构、更快速地发现问题、更少地回归 Bug。

本文从测试理论到实战代码,带你全面掌握前端测试。


一、测试基础理论

1.1 测试金字塔

PLAINTEXT
         ╱  E2E 测试  ╲          少量,慢,贵
        ╱  集成测试    ╲         适量,中等速度
       ╱   单元测试      ╲       大量,快,便宜
      ╱──────────────────╲
测试类型范围速度数量工具
单元测试单个函数/组件⚡ 毫秒Vitest, Jest
集成测试多个组件协作🔵 秒级RTL, Vitest
E2E 测试完整用户流程🐢 分钟级Playwright

1.2 为什么要写测试?

不写测试的代价:

写测试的收益:

1.3 测试策略原则

PLAINTEXT
1. 测试行为,不测实现
   → 关注「组件做了什么」,不是「怎么做的」

2. 从用户角度测试
   → 用户看到什么、点击什么、输入什么

3. 测试关键路径
   → 核心功能必须测试,边界情况酌情

4. 金字塔结构
   → 70% 单元测试 + 20% 集成测试 + 10% E2E 测试

5. 测试要快
   → 慢测试会被跳过,快测试才会被运行

二、Vitest 配置与基础

2.1 安装与配置

BASH
# 安装 Vitest
pnpm add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

# 安装 Playwright
pnpm add -D @playwright/test
TS
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    // 测试环境
    environment: 'jsdom',

    // 全局设置
    globals: true,

    // Setup 文件
    setupFiles: ['./src/test/setup.ts'],

    // CSS 模块处理
    css: true,

    // 覆盖率
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: [
        'src/types/**',
        'src/test/**',
        'src/**/*.d.ts',
        'src/main.tsx',
      ],
      thresholds: {
        branches: 70,
        functions: 70,
        lines: 70,
        statements: 70,
      },
    },

    // 路径别名
    resolve: {
      alias: {
        '@': path.resolve(__dirname, './src'),
      },
    },
  },
});
TS
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';

// 清理
afterEach(() => {
  // Vitest 自动清理,无需手动 cleanup
});

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: (query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: () => {},
    removeListener: () => {},
    addEventListener: () => {},
    removeEventListener: () => {},
    dispatchEvent: () => false,
  }),
});
JSON
// package.json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}

2.2 基础测试

TS
// src/lib/utils.ts
export function formatPrice(price: number, currency = 'CNY'): string {
  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency,
  }).format(price);
}

export function truncate(str: string, maxLength: number): string {
  if (str.length <= maxLength) return str;
  return str.slice(0, maxLength - 3) + '...';
}

export function debounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout>;
  return (...args: Parameters<T>) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}
TS
// src/lib/__tests__/utils.test.ts
import { describe, it, expect, vi } from 'vitest';
import { formatPrice, truncate, debounce } from '../utils';

describe('formatPrice', () => {
  it('格式化人民币价格', () => {
    expect(formatPrice(99.99)).toBe('¥99.99');
  });

  it('格式化美元价格', () => {
    expect(formatPrice(99.99, 'USD')).toBe('$99.99');
  });

  it('处理零', () => {
    expect(formatPrice(0)).toBe('¥0.00');
  });

  it('处理大数', () => {
    expect(formatPrice(1000000)).toBe('¥1,000,000.00');
  });
});

describe('truncate', () => {
  it('短字符串不变', () => {
    expect(truncate('hello', 10)).toBe('hello');
  });

  it('长字符串截断', () => {
    expect(truncate('hello world', 8)).toBe('hello...');
  });

  it('边界长度不变', () => {
    expect(truncate('hello', 5)).toBe('hello');
  });
});

describe('debounce', () => {
  it('延迟执行函数', () => {
    vi.useFakeTimers();
    const fn = vi.fn();
    const debouncedFn = debounce(fn, 100);

    debouncedFn();
    debouncedFn();
    debouncedFn();

    expect(fn).not.toHaveBeenCalled();

    vi.advanceTimersByTime(100);
    expect(fn).toHaveBeenCalledTimes(1);

    vi.useRealTimers();
  });
});

2.3 Mock 与 Spy

TS
// src/lib/__tests__/api.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUsers } from '../api';

// Mock 全局 fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;

beforeEach(() => {
  mockFetch.mockReset();
});

describe('fetchUsers', () => {
  it('成功获取用户列表', async () => {
    const mockUsers = [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ];

    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(mockUsers),
    });

    const users = await fetchUsers();
    expect(users).toEqual(mockUsers);
    expect(mockFetch).toHaveBeenCalledWith('/api/users');
  });

  it('处理请求失败', async () => {
    mockFetch.mockResolvedValueOnce({
      ok: false,
      status: 500,
      statusText: 'Internal Server Error',
    });

    await expect(fetchUsers()).rejects.toThrow('Failed to fetch users');
  });

  it('处理网络错误', async () => {
    mockFetch.mockRejectedValueOnce(new Error('Network error'));

    await expect(fetchUsers()).rejects.toThrow('Network error');
  });
});

三、React 组件测试

3.1 React Testing Library 原则

PLAINTEXT
核心原则:从用户角度测试

✅ 好:查找可见文本
  screen.getByText('提交')
  screen.getByRole('button', { name: '提交' })

❌ 坏:查找实现细节
  container.querySelector('.submit-btn')
  screen.getByTestId('submit')

为什么?
→ 用户看到的是文本和按钮,不是 class 和 testid
→ 文本和角色更稳定,重构后不易断

3.2 简单组件测试

TSX
// src/components/ui/button.tsx
interface ButtonProps {
  children: React.ReactNode;
  onClick?: () => void;
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
}

export function Button({ children, onClick, variant = 'primary', disabled }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant}`}
    >
      {children}
    </button>
  );
}
TS
// src/components/ui/__tests__/button.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '../button';

describe('Button', () => {
  it('渲染按钮文本', () => {
    render(<Button>点击我</Button>);
    expect(screen.getByRole('button', { name: '点击我' })).toBeInTheDocument();
  });

  it('点击触发回调', async () => {
    const handleClick = vi.fn();
    const user = userEvent.setup();

    render(<Button onClick={handleClick}>提交</Button>);

    await user.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('禁用状态不可点击', async () => {
    const handleClick = vi.fn();
    const user = userEvent.setup();

    render(<Button onClick={handleClick} disabled>提交</Button>);

    expect(screen.getByRole('button')).toBeDisabled();
    await user.click(screen.getByRole('button'));
    expect(handleClick).not.toHaveBeenCalled();
  });
});

3.3 表单组件测试

TSX
// src/components/login-form.tsx
'use client';

import { useState } from 'react';

interface LoginFormProps {
  onSubmit: (email: string, password: string) => Promise<void>;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setIsLoading(true);

    try {
      await onSubmit(email, password);
    } catch (err) {
      setError(err instanceof Error ? err.message : '登录失败');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">邮箱</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </div>
      <div>
        <label htmlFor="password">密码</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
          minLength={6}
        />
      </div>
      {error && <p role="alert">{error}</p>}
      <button type="submit" disabled={isLoading}>
        {isLoading ? '登录中...' : '登录'}
      </button>
    </form>
  );
}
TS
// src/components/__tests__/login-form.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from '../login-form';

describe('LoginForm', () => {
  it('成功提交表单', async () => {
    const handleSubmit = vi.fn().mockResolvedValue(undefined);
    const user = userEvent.setup();

    render(<LoginForm onSubmit={handleSubmit} />);

    await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
    await user.type(screen.getByLabelText('密码'), 'password123');
    await user.click(screen.getByRole('button', { name: '登录' }));

    expect(handleSubmit).toHaveBeenCalledWith('test@example.com', 'password123');
  });

  it('显示错误信息', async () => {
    const handleSubmit = vi.fn().mockRejectedValue(new Error('密码错误'));
    const user = userEvent.setup();

    render(<LoginForm onSubmit={handleSubmit} />);

    await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
    await user.type(screen.getByLabelText('密码'), 'wrong');
    await user.click(screen.getByRole('button', { name: '登录' }));

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('密码错误');
    });
  });

  it('提交时显示加载状态', async () => {
    let resolveSubmit: () => void;
    const handleSubmit = vi.fn().mockImplementation(
      () => new Promise<void>((resolve) => { resolveSubmit = resolve; })
    );
    const user = userEvent.setup();

    render(<LoginForm onSubmit={handleSubmit} />);

    await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
    await user.type(screen.getByLabelText('密码'), 'password123');
    await user.click(screen.getByRole('button', { name: '登录' }));

    expect(screen.getByRole('button')).toHaveTextContent('登录中...');
    expect(screen.getByRole('button')).toBeDisabled();

    resolveSubmit!();

    await waitFor(() => {
      expect(screen.getByRole('button')).toHaveTextContent('登录');
    });
  });
});

3.4 列表与异步组件测试

TSX
// src/components/user-list.tsx
'use client';

import { useState, useEffect } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

export function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState('');

  useEffect(() => {
    fetch('/api/users')
      .then((res) => {
        if (!res.ok) throw new Error('获取失败');
        return res.json();
      })
      .then(setUsers)
      .catch((err) => setError(err.message))
      .finally(() => setIsLoading(false));
  }, []);

  if (isLoading) return <div>加载中...</div>;
  if (error) return <div role="alert">{error}</div>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          {user.name} ({user.email})
        </li>
      ))}
    </ul>
  );
}
TS
// src/components/__tests__/user-list.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UserList } from '../user-list';

const mockUsers = [
  { id: 1, name: 'Alice', email: 'alice@test.com' },
  { id: 2, name: 'Bob', email: 'bob@test.com' },
];

beforeEach(() => {
  vi.restoreAllMocks();
});

describe('UserList', () => {
  it('显示加载状态', () => {
    vi.spyOn(global, 'fetch').mockImplementation(() => new Promise(() => {}));
    render(<UserList />);
    expect(screen.getByText('加载中...')).toBeInTheDocument();
  });

  it('显示用户列表', async () => {
    vi.spyOn(global, 'fetch').mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockUsers),
    } as Response);

    render(<UserList />);

    expect(screen.getByText('加载中...')).toBeInTheDocument();

    // 等待数据加载
    const aliceItem = await screen.findByText(/Alice/);
    expect(aliceItem).toBeInTheDocument();
    expect(screen.getByText(/Bob/)).toBeInTheDocument();
  });

  it('显示错误信息', async () => {
    vi.spyOn(global, 'fetch').mockResolvedValue({
      ok: false,
      status: 500,
    } as Response);

    render(<UserList />);

    const error = await screen.findByRole('alert');
    expect(error).toHaveTextContent('获取失败');
  });
});

3.5 自定义 Hook 测试

TS
// src/hooks/use-counter.ts
import { useState, useCallback } from 'react';

export function useCounter(initialValue = 0, step = 1) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => setCount((c) => c + step), [step]);
  const decrement = useCallback(() => setCount((c) => c - step), [step]);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}
TS
// src/hooks/__tests__/use-counter.test.ts
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from '../use-counter';

describe('useCounter', () => {
  it('初始值默认为 0', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('支持自定义初始值', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('递增', () => {
    const { result } = renderHook(() => useCounter(0, 2));

    act(() => result.current.increment());
    expect(result.current.count).toBe(2);

    act(() => result.current.increment());
    expect(result.current.count).toBe(4);
  });

  it('递减', () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => result.current.decrement());
    expect(result.current.count).toBe(9);
  });

  it('重置', () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => result.current.increment());
    act(() => result.current.increment());
    expect(result.current.count).toBe(11);

    act(() => result.current.reset());
    expect(result.current.count).toBe(10);
  });
});

四、Playwright E2E 测试

4.1 配置

TS
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'mobile', use: { ...devices['iPhone 14'] } },
  ],

  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

4.2 基础 E2E 测试

TS
// e2e/home.spec.ts
import { test, expect } from '@playwright/test';

test.describe('首页', () => {
  test('显示首页标题', async ({ page }) => {
    await page.goto('/');
    await expect(page.getByRole('heading', { name: '欢迎' })).toBeVisible();
  });

  test('导航到博客页面', async ({ page }) => {
    await page.goto('/');
    await page.getByRole('link', { name: '博客' }).click();
    await expect(page).toHaveURL('/blog');
    await expect(page.getByRole('heading', { name: '博客' })).toBeVisible();
  });
});

4.3 完整用户流程测试

TS
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('用户认证流程', () => {
  test('注册 → 登录 → 访问受保护页面', async ({ page }) => {
    // 1. 注册
    await page.goto('/register');
    await page.getByLabel('用户名').fill('testuser');
    await page.getByLabel('邮箱').fill('test@example.com');
    await page.getByLabel('密码').fill('password123');
    await page.getByRole('button', { name: '注册' }).click();

    // 验证注册成功
    await expect(page.getByText('注册成功')).toBeVisible();

    // 2. 登录
    await page.goto('/login');
    await page.getByLabel('邮箱').fill('test@example.com');
    await page.getByLabel('密码').fill('password123');
    await page.getByRole('button', { name: '登录' }).click();

    // 验证登录成功,跳转到仪表盘
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('欢迎回来')).toBeVisible();

    // 3. 访问受保护页面
    await page.getByRole('link', { name: '设置' }).click();
    await expect(page).toHaveURL('/dashboard/settings');

    // 4. 登出
    await page.getByRole('button', { name: '登出' }).click();
    await expect(page).toHaveURL('/');
  });

  test('未登录不能访问受保护页面', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page).toHaveURL('/login');
  });
});

4.4 API Mock(拦截网络请求)

TS
// e2e/blog.spec.ts
import { test, expect } from '@playwright/test';

test.describe('博客页面', () => {
  test('显示文章列表', async ({ page }) => {
    // Mock API 响应
    await page.route('**/api/posts', (route) =>
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify([
          { id: 1, title: '测试文章1', slug: 'test-1' },
          { id: 2, title: '测试文章2', slug: 'test-2' },
        ]),
      })
    );

    await page.goto('/blog');

    await expect(page.getByText('测试文章1')).toBeVisible();
    await expect(page.getByText('测试文章2')).toBeVisible();
  });

  test('处理 API 错误', async ({ page }) => {
    await page.route('**/api/posts', (route) =>
      route.fulfill({ status: 500, body: 'Server Error' })
    );

    await page.goto('/blog');
    await expect(page.getByText(/出错/)).toBeVisible();
  });
});

4.5 视觉回归测试

TS
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test';

test.describe('视觉回归测试', () => {
  test('首页截图对比', async ({ page }) => {
    await page.goto('/');
    await expect(page).toHaveScreenshot('home.png', {
      maxDiffPixelRatio: 0.01, // 允许 1% 像素差异
    });
  });

  test('博客页面截图对比', async ({ page }) => {
    await page.goto('/blog');
    await expect(page).toHaveScreenshot('blog.png');
  });

  test('移动端首页截图', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/');
    await expect(page).toHaveScreenshot('home-mobile.png');
  });
});

五、测试最佳实践

5.1 AAA 模式

TS
// Arrange(准备)→ Act(执行)→ Assert(断言)

it('用户登录后显示欢迎消息', async () => {
  // Arrange:准备测试数据
  const user = userEvent.setup();
  const onSubmit = vi.fn().mockResolvedValue(undefined);
  render(<LoginForm onSubmit={onSubmit} />);

  // Act:执行操作
  await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
  await user.type(screen.getByLabelText('密码'), 'password123');
  await user.click(screen.getByRole('button', { name: '登录' }));

  // Assert:验证结果
  expect(onSubmit).toHaveBeenCalledWith('test@example.com', 'password123');
});

5.2 测试命名规范

TS
// ❌ 不好的命名
it('works', () => {});
it('test1', () => {});
it('button', () => {});

// ✅ 好的命名(描述行为和预期)
it('禁用状态下点击不触发回调', () => {});
it('输入无效邮箱时显示错误提示', () => {});
it('加载完成后显示用户列表', () => {});

5.3 查询优先级

PLAINTEXT
1. getByRole        → 最推荐(模拟用户视角)
2. getByLabelText   → 表单元素
3. getByPlaceholder → 输入框
4. getByText        → 非交互元素
5. getByTestId      → 最后手段

避免使用:
❌ container.querySelector
❌ 直接用 className 查找

5.4 测试文件组织

PLAINTEXT
src/
├── components/
│   ├── button.tsx
│   └── __tests__/
│       └── button.test.tsx
│
├── hooks/
│   ├── use-counter.ts
│   └── __tests__/
│       └── use-counter.test.ts
│
├── lib/
│   ├── utils.ts
│   └── __tests__/
│       └── utils.test.ts
│
└── test/
    ├── setup.ts        # 全局设置
    ├── utils.tsx        # 测试工具(自定义 render 等)
    └── mocks/           # Mock 数据
        ├── handlers.ts  # MSW handlers
        └── data.ts      # 测试数据

5.5 自定义 Render 函数

TSX
// src/test/utils.tsx
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';

// 如果你的 App 有全局 Provider(如 ThemeProvider, QueryClientProvider)
// 可以创建自定义 render 函数

export function renderWithProviders(
  ui: ReactElement,
  options?: { router?: { initialEntries: string[] } }
) {
  return render(ui, {
    // 在这里包裹你的 Provider
    wrapper: ({ children }) => (
      <QueryClientProvider client={queryClient}>
        <ThemeProvider>{children}</ThemeProvider>
      </QueryClientProvider>
    ),
    ...options,
  });
}

// 使用
// renderWithProviders(<MyComponent />)

六、CI 集成

6.1 GitHub Actions 配置

YAML
# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: pnpm test:run
      - run: pnpm test:coverage

      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  e2e-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps

      - run: pnpm build
      - run: pnpm test:e2e

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

七、总结

测试策略清单

学习路径

PLAINTEXT
入门(第1周):
  → Vitest 基础配置
  → 编写简单单元测试
  → 理解 AAA 模式

进阶(第2-3周):
  → React Testing Library
  → 组件测试
  → Mock 和 Spy

高级(第1-2月):
  → Playwright E2E
  → 视觉回归测试
  → 自定义 Hook 测试

专家(第3月+):
  → TDD 开发流程
  → 测试策略设计
  → CI/CD 集成
  → 性能测试


测试不是负担,而是保障。 从今天开始,给关键代码加上测试,你会发现开发速度不降反升。🚀

版权声明

作者: 易邦

链接: https://blog.e8k.net/posts/frontend-testing-guide-2026/

许可证: 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。