没有测试的代码是累赘,有测试的代码是资产。 前端测试的本质不是「找bug」,而是「防止回归」和「提供重构信心」。一套合理的测试策略应该在速度与信心之间找到平衡点。
一、测试金字塔
1.1 分层模型
/
/ \ E2E 测试(少而精)
/ \ → 用户场景、关键路径
/─────\
/ \ 集成测试(中等)
/ \ → 组件交互、API 集成
/───────────\
/ \ 单元测试(大量)
/ \ → 纯函数、工具类、组件行为
─────────────────
投入比例建议:单元 70% : 集成 20% : E2E 10%
1.2 各层关注点
| 层级 | 范围 | 速度 | 工具 | 成本 |
|---|---|---|---|---|
| 单元测试 | 函数、Hook、单组件 | < 1s | Vitest / Jest | 低 |
| 集成测试 | 组件树、API 调用 | 5-30s | Testing Library + MSW | 中 |
| E2E 测试 | 完整用户流程 | 分钟级 | Cypress / Playwright | 高 |
二、单元测试:Vitest
2.1 为什么选择 Vitest
| 特性 | Vitest | Jest |
|---|---|---|
| 速度 | 快(原生 ESM) | 中(需 babel 转译) |
| ESM 支持 | 原生 | 需配置 |
| TypeScript | 原生 | 需 ts-jest |
| Vite 生态 | 无缝 | 需适配 |
| API 兼容 | 与 Jest 基本一致 | 标准 |
| 推荐 | ✅ Vite 项目首选 | ⚠️ 存量项目 |
2.2 配置与实战
pnpm add -D vitest @vitest/ui @testing-library/jest-dom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
globals: true, // 全局 expect/desribe/it
environment: 'jsdom', // 浏览器环境模拟
include: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80
}
}
}
});
// utils/formatDate.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate, isValidDate } from './formatDate';
describe('formatDate', () => {
it('formats ISO date to display string', () => {
expect(formatDate('2024-01-15')).toBe('2024年1月15日');
});
it('handles invalid input gracefully', () => {
expect(formatDate('invalid')).toBe('—');
});
it('respects locale parameter', () => {
expect(formatDate('2024-01-15', 'en-US')).toBe('Jan 15, 2024');
});
});
describe('isValidDate', () => {
it.each([
['2024-01-15', true],
['2024-02-30', false], // 不存在日期
['not-a-date', false],
['', false],
])('isValidDate(%s) = %s', (input, expected) => {
expect(isValidDate(input)).toBe(expected);
});
});
三、组件测试
3.1 Vue 组件测试(Vue Test Utils)
// components/UserCard.spec.ts
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import UserCard from './UserCard.vue';
describe('UserCard', () => {
it('renders user info correctly', () => {
const wrapper = mount(UserCard, {
props: {
user: { id: '1', name: 'Alice', avatar: 'alice.jpg', role: 'admin' }
}
});
expect(wrapper.text()).toContain('Alice');
expect(wrapper.text()).toContain('admin');
expect(wrapper.find('img').attributes('src')).toBe('alice.jpg');
});
it('emits click event with user id', async () => {
const wrapper = mount(UserCard, {
props: { user: { id: '1', name: 'Alice', avatar: '', role: 'user' } }
});
await wrapper.find('button').trigger('click');
expect(wrapper.emitted('select')).toHaveLength(1);
expect(wrapper.emitted('select')![0]).toEqual(['1']);
});
it('shows fallback avatar when none provided', () => {
const wrapper = mount(UserCard, {
props: { user: { id: '1', name: 'Bob', avatar: '', role: 'user' } }
});
expect(wrapper.find('img').attributes('src')).toBe('/default-avatar.png');
});
});
3.2 React 组件测试(Testing Library)
// components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
import '@testing-library/jest-dom/vitest';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});
it('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when loading', () => {
render(<Button loading>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('applies correct variant styles', () => {
render(<Button variant="danger">Delete</Button>);
expect(screen.getByRole('button')).toHaveClass('btn-danger');
});
});
3.3 MSW:API Mock
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json({
data: [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' }
]
});
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '3', ...body }, { status: 201 });
}),
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
if (id === '999') {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json({ id, name: 'Test User' });
}),
];
// vitest.setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
四、E2E 测试
4.1 Cypress vs Playwright
| 维度 | Cypress | Playwright |
|---|---|---|
| 浏览器控制 | 自带 Electron,外部浏览器有限制 | Chromium/Firefox/WebKit 原生 |
| 并行执行 | 需 Cypress Cloud/商业版 | 原生支持(sharding) |
| 速度 | 较快 | 更快(多 worker) |
| 调试 | 极佳(时间旅行、截图) | 好(trace viewer) |
| 跨域 | 受限 | 原生支持 |
| API 测试 | 较弱 | 内置 request |
| CI 集成 | 简单 | 简单 |
| 组件测试 | 支持 | 实验性 |
| 推荐 | ✅ 传统选择 | ✅ 新项目/大并发 |
4.2 Playwright 实战
npm init playwright@latest
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('successful login redirects to dashboard', async ({ page }) => {
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-button"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Dashboard');
});
test('shows error for invalid credentials', async ({ page }) => {
await page.fill('[data-testid="email"]', 'wrong@example.com');
await page.fill('[data-testid="password"]', 'wrong');
await page.click('[data-testid="login-button"]');
await expect(page.locator('[data-testid="error-message"]'))
.toContainText('Invalid credentials');
});
test('persists session after refresh', async ({ page, context }) => {
// 登录
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-button"]');
// 刷新
await page.reload();
await expect(page).toHaveURL('/dashboard');
});
});
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
workers: process.env.CI ? 4 : undefined,
reporter: [['html', { open: 'never' }], ['json', { outputFile: 'test-results.json' }]],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
});
五、Storybook:文档 + 交互测试
5.1 Story 即测试
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: { variant: 'primary', children: 'Click me' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await expect(button).toHaveClass('btn-primary');
await userEvent.click(button);
}
};
export const Loading: Story = {
args: { loading: true, children: 'Submit' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('button')).toBeDisabled();
}
};
5.2 视觉回归测试(Chromatic)
npx chromatic --project-token=xxx
每次 Storybook 构建后自动对比截图,检测 UI 变化:
- 像素级差异标记
- 团队成员 Review & Approve
- 集成 CI,阻止未审批的 UI 变更合并
六、性能测试:Lighthouse CI
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install && npm run build
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli@0.14.x
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000/'],
startServerCommand: 'npm run preview',
},
assert: {
assertions: {
'categories:performance': ['warn', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.95 }],
'first-contentful-paint': ['warn', { maxNumericValue: 1800 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
}
}
}
};
七、CI 集成与门禁
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install
- run: pnpm test:unit --coverage
- uses: codecov/codecov-action@v5
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install
- run: pnpm build
- run: pnpm test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
八、测试策略总结
推荐测试投入比(按代码行数/用例数):
单元测试(70%) 组件测试(15%) E2E(10%) 视觉回归(5%)
├── 工具函数 ├── 交互行为 ├── 核心用户路径 ├── 关键页面截图
├── 状态逻辑 ├── 状态变化 ├── 登录/支付/提交 ├── 组件多状态截图
├── Hook/Composables ├── Props 传递 ├── 跨页面流程 ├── diff 自动检测
└── 纯函数业务逻辑 └── 事件处理 └── 响应式布局 └── CI 自动审批
不要测试:
❌ 第三方库内部实现
❌ 纯样式(交给视觉回归)
❌ 琐碎的 getters(如 return this.value)
参考与延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。