前端测试入门

2026-06-22 · 6 阅读 · 541字
JavaScriptTypeScript测试

前端测试入门

为什么需要测试

测试不是为了找到所有 Bug,而是为了建立对代码行为的信心。完善的测试让你在重构时敢于修改代码,新功能上线时更有把握。

测试金字塔

        ╱ ╲
       ╱E2E╲          ← 少量(端到端)
      ╱─────╲
     ╱集成测试╲       ← 适量(组件/API)
    ╱─────────╲
   ╱ 单元测试   ╲    ← 大量(函数/工具)
  ╱─────────────╲

单元测试

测试最小的代码单元(函数、组件)。速度快,覆盖率高。

集成测试

测试多个单元之间的交互。验证组件渲染、用户交互、API 调用等。

E2E 测试

模拟真实用户操作。覆盖关键用户流程,但速度慢且脆弱。

测试工具选型

类型 工具 说明
测试框架 Vitest / Jest 运行测试、断言、覆盖率
组件测试 Testing Library 以用户视角测试组件
E2E 测试 Playwright / Cypress 浏览器自动化
Mock MSW (Mock Service Worker) 模拟 API 请求

单元测试(Vitest)

// utils/math.ts
export function add(a: number, b: number): number {
  return a + b;
}

export function isEven(n: number): boolean {
  return n % 2 === 0;
}

// utils/__tests__/math.test.ts
import { describe, it, expect } from 'vitest';
import { add, isEven } from '../math';

describe('add', () => {
  it('应该正确计算两个正数之和', () => {
    expect(add(1, 2)).toBe(3);
  });

  it('应该正确处理负数', () => {
    expect(add(-1, 1)).toBe(0);
    expect(add(-1, -2)).toBe(-3);
  });
});

describe('isEven', () => {
  it('应该正确判断偶数', () => {
    expect(isEven(2)).toBe(true);
    expect(isEven(0)).toBe(true);
  });

  it('应该正确判断奇数', () => {
    expect(isEven(1)).toBe(false);
    expect(isEven(3)).toBe(false);
  });
});

组件测试(React Testing Library)

// components/Counter.tsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>计数: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>增加</button>
    </div>
  );
}

// components/__tests__/Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Counter } from '../Counter';

describe('Counter', () => {
  it('应该渲染初始计数', () => {
    render(<Counter />);
    expect(screen.getByText('计数: 0')).toBeDefined();
  });

  it('点击按钮应该增加计数', () => {
    render(<Counter />);
    fireEvent.click(screen.getByText('增加'));
    expect(screen.getByText('计数: 1')).toBeDefined();
  });
});

测试原则(Testing Library 哲学)

// ✅ 推荐:通过用户可见的内容查找
screen.getByText('提交');
screen.getByRole('button', { name: '提交' });
screen.getByLabelText('用户名');
screen.getByPlaceholderText('请输入密码');

// ❌ 避免:通过实现细节查找
screen.getByTestId('submit-button'); // 仅作为最后手段
wrapper.find('.btn-submit');         // 依赖 CSS 类名

API Mock(MSW)

// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: '张三' },
      { id: 2, name: '李四' },
    ]);
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 3, ...body }, { status: 201 });
  }),
];

// 测试中使用 MSW
import { setupServer } from 'msw/node';
import { handlers } from '../mocks/handlers';

const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it('应该获取用户列表', async () => {
  render(<UserList />);
  expect(await screen.findByText('张三')).toBeDefined();
  expect(await screen.findByText('李四')).toBeDefined();
});

E2E 测试(Playwright)

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

test('用户应该能成功登录', async ({ page }) => {
  await page.goto('/login');

  // 填写表单
  await page.fill('[name="username"]', 'admin');
  await page.fill('[name="password"]', 'password123');

  // 提交
  await page.click('button[type="submit"]');

  // 验证跳转到首页
  await expect(page).toHaveURL('/dashboard');
  await expect(page.locator('.welcome')).toContainText('欢迎回来');
});

test('显示登录错误信息', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[name="username"]', 'wrong');
  await page.fill('[name="password"]', 'wrong');
  await page.click('button[type="submit"]');

  // 验证错误提示
  await expect(page.locator('.error-message')).toBeVisible();
  await expect(page.locator('.error-message')).toContainText('用户名或密码错误');
});

覆盖率

npx vitest --coverage

覆盖率指标:

  • 行覆盖率:被执行的代码行数比例
  • 分支覆盖率:if/else、三元运算符等分支的执行比例
  • 函数覆盖率:被调用的函数比例

注意:高覆盖率不等于高质量测试。测试的目的是验证行为,而非凑数字。

测试驱动开发(TDD)流程

  1. 红灯:先写一个会失败的测试
  2. 绿灯:写最少的代码让测试通过
  3. 重构:优化代码,确保测试依然通过
// 1. 先写测试
describe('validateEmail', () => {
  it('应该验证有效的邮箱', () => {
    expect(validateEmail('test@example.com')).toBe(true);
  });
  it('应该拒绝无效的邮箱', () => {
    expect(validateEmail('not-an-email')).toBe(false);
  });
});

// 2. 再写实现
export function validateEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

总结

前端测试的最佳实践:

  1. 测试行为,而非实现:关注用户看到什么、操作什么
  2. 优先集成测试:验证组件和功能正常工作
  3. 合理的测试金字塔:多单元测试、适量集成测试、少量 E2E
  4. 将测试集成到 CI:每次提交自动运行
  5. 避免过度 Mock:减少对实现细节的依赖

从 Vitest + Testing Library 开始,逐步引入 MSW 和 Playwright,建立起适合团队和项目的测试体系。