关于 testing-strategy
面向需要新增或改进应用测试的开发者,涵盖单元、组件、集成和端到端测试,并提供常见框架示例及覆盖率、CI、模拟和不稳定测试指导。
主要功能
- 01单元与组件测试模式
- 02集成及端到端测试指导
- 03Vitest、Jest 和 Playwright 示例
- 04使用 MSW 模拟 API
- 05覆盖率与 CI 实践
- 06排查不稳定测试
适用场景
- 01搭建测试基础设施
- 02为 React 组件和 Hooks 添加测试
- 03测试 API 路由与数据库操作
- 04发现覆盖率缺口和不稳定测试
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ mgd34msu/ goodvibes-plugin --skill testing-strategy安装后在 Claude Code 中输入/testing-strategy确认已加载。
附带文件references/testing-patterns.mdscripts/validate-tests.sh
详细介绍
资源
scripts/
validate-tests.sh
references/
testing-patterns.md
测试策略
本技能将指导你使用现代测试框架和 GoodVibes 精准工具,实施全面的测试策略。在现有代码中添加测试、搭建测试基础设施或实现覆盖率目标时,请使用此工作流。
何时使用此技能
- 搭建测试基础设施(Vitest、Jest、Playwright)
- 为函数、钩子和实用工具编写单元测试
- 使用 React Testing Library 创建组件测试
- 使用 Playwright 实施端到端测试
- 使用 MSW 添加 API 模拟
- 提高测试覆盖率
- 配置 CI 测试流水线
- 调试不稳定的测试
- 创建测试固件和工厂函数
测试组织
文件命名约定
遵循一致的命名模式:
// Co-located pattern (recommended)
src/
components/
Button.tsx
Button.test.tsx // Component tests
utils/
formatDate.ts
formatDate.test.ts // Unit tests
api/
users.ts
users.integration.test.ts // Integration tests
// Centralized pattern (alternative)
__tests__/
components/
Button.test.tsx
utils/
formatDate.test.ts
测试套件结构
使用清晰的 describe 块组织测试:
import { describe, it, expect, beforeEach } from 'vitest';
import { formatDate } from './formatDate';
describe('formatDate', () => {
describe('with valid dates', () => {
it('formats ISO dates to MM/DD/YYYY', () => {
expect(formatDate('2024-01-15')).toBe('01/15/2024');
});
it('handles Date objects', () => {
const date = new Date('2024-01-15');
expect(formatDate(date)).toBe('01/15/2024');
});
});
describe('with invalid dates', () => {
it('throws for invalid strings', () => {
expect(() => formatDate('invalid')).toThrow('Invalid date');
});
it('throws for null', () => {
expect(() => formatDate(null)).toThrow('Invalid date');
});
});
});
发现:查找测试文件
使用精准工具发现现有的测试模式:
# Find all test files and analyze patterns
discover:
queries:
- id: test-files
type: glob
patterns:
- "**/*.test.{ts,tsx,js,jsx}"
- "**/*.spec.{ts,tsx,js,jsx}"
- "e2e/**/*.spec.ts"
exclude:
- "**/node_modules/**"
- "**/dist/**"
- id: test-config
type: glob
patterns:
- "vitest.config.ts"
- "jest.config.js"
- "playwright.config.ts"
- id: skipped-tests
type: grep
pattern: "\\.skip|it\\.only|describe\\.only"
glob: "**/*.test.{ts,tsx}"
output_mode: files_only
单元测试
Vitest 设置(推荐)
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**',
],
all: true,
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
测试纯函数
import { describe, it, expect } from 'vitest';
import { calculateTotal, discountPrice } from './pricing';
describe('pricing utilities', () => {
describe('calculateTotal', () => {
it('sums item prices', () => {
const items = [
{ price: 10.00, quantity: 2 },
{ price: 5.50, quantity: 1 },
];
expect(calculateTotal(items)).toBe(25.50);
});
it('returns 0 for empty array', () => {
expect(calculateTotal([])).toBe(0);
});
it('handles quantity multipliers', () => {
const items = [{ price: 10, quantity: 3 }];
expect(calculateTotal(items)).toBe(30);
});
});
describe('discountPrice', () => {
it('applies percentage discount', () => {
expect(discountPrice(100, 0.2)).toBe(80);
});
it('rounds to 2 decimal places', () => {
expect(discountPrice(10.99, 0.15)).toBe(9.34);
});
it('throws for invalid discounts', () => {
expect(() => discountPrice(100, -0.1)).toThrow('Invalid discount');
expect(() => discountPrice(100, 1.5)).toThrow('Invalid discount');
});
});
});
测试 React 钩子
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAuth } from './useAuth';
// Mock the auth context
vi.mock('@/contexts/AuthContext', () => ({
useAuthContext: vi.fn(),
}));
describe('useAuth', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns user when authenticated', () => {
vi.mocked(useAuthContext).mockReturnValue({
user: { id: '1', email: '[email protected]' },
isLoading: false,
});
const { result } = renderHook(() => useAuth());
expect(result.current.user).toEqual({
id: '1',
email: '[email protected]',
});
expect(result.current.isAuthenticated).toBe(true);
});
it('handles loading state', () => {
vi.mocked(useAuthContext).mockReturnValue({
user: null,
isLoading: true,
});
const { result } = renderHook(() => useAuth());
expect(result.current.isLoading).toBe(true);
expect(result.current.isAuthenticated).toBe(false);
});
it('refetches user on login', async () => {
const refetch = vi.fn().mockResolvedValue({ id: '1' });
vi.mocked(useAuthContext).mockReturnValue({
user: null,
refetch,
});
const { result } = renderHook(() => useAuth());
await result.current.login('[email protected]', 'password');
expect(refetch).toHaveBeenCalledOnce();
});
});
组件测试
React Testing Library 模式
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
import { SearchInput } from './SearchInput';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledOnce();
});
it('disables button when loading', () => {
render(<Button isLoading>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('shows spinner when loading', () => {
render(<Button isLoading>Submit</Button>);
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
});
it('applies variant styles', () => {
const { container } = render(<Button variant="primary">Primary</Button>);
expect(container.firstChild).toHaveClass('bg-blue-600');
});
});
describe('SearchInput', () => {
it('debounces search input', async () => {
const onSearch = vi.fn();
const user = userEvent.setup();
render(<SearchInput onSearch={onSearch} debounce={300} />);
const input = screen.getByRole('searchbox');
await user.type(input, 'test query');
// Should not call immediately
expect(onSearch).not.toHaveBeenCalled();
// Should call after debounce
await waitFor(
() => expect(onSearch).toHaveBeenCalledWith('test query'),
{ timeout: 500 }
);
});
it('clears input on clear button click', async () => {
const user = userEvent.setup();
render(<SearchInput />);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await user.type(input, 'test');
expect(input.value).toBe('test');
await user.click(screen.getByRole('button', { name: /clear/i }));
expect(input.value).toBe('');
});
});
测试异步组件
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserProfile } from './UserProfile';
import * as api from '@/api/users';
vi.mock('@/api/users');
describe('UserProfile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows loading state initially', () => {
vi.mocked(api.getUser).mockReturnValue(new Promise(() => {}));
render(<UserProfile userId="1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays user data when loaded', async () => {
vi.mocked(api.getUser).mockResolvedValue({
id: '1',
name: 'John Doe',
email: '[email protected]',
});
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
it('shows error message on fetch failure', async () => {
vi.mocked(api.getUser).mockRejectedValue(new Error('Failed to fetch'));
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
});
});
集成测试
API 路由测试
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createMocks } from 'node-mocks-http';
import { POST } from '@/app/api/posts/route';
import { prisma } from '@/lib/prisma';
import { createTestUser, cleanupDatabase } from '@/test/helpers';
describe('POST /api/posts', () => {
let testUser: { id: string; email: string };
beforeAll(async () => {
testUser = await createTestUser();
});
afterAll(async () => {
await cleanupDatabase();
});
it('creates a new post', async () => {
const { req } = createMocks({
method: 'POST',
body: {
title: 'Test Post',
content: 'Test content',
},
headers: {
authorization: `Bearer ${testUser.token}`,
},
});
const response = await POST(req);
const data = await response.json();
expect(response.status).toBe(201);
expect(data).toMatchObject({
title: 'Test Post',
content: 'Test content',
authorId: testUser.id,
});
// Verify in database
const post = await prisma.post.findUnique({
where: { id: data.id },
});
expect(post).toBeTruthy();
});
it('validates required fields', async () => {
const { req } = createMocks({
method: 'POST',
body: { title: '' }, // Missing content
headers: { authorization: `Bearer ${testUser.token}` },
});
const response = await POST(req);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toMatchObject({
fieldErrors: {
title: expect.arrayContaining([expect.any(String)]),
content: expect.arrayContaining([expect.any(String)]),
},
});
});
it('requires authentication', async () => {
const { req } = createMocks({
method: 'POST',
body: { title: 'Test', content: 'Test' },
// No authorization header
});
const response = await POST(req);
expect(response.status).toBe(401);
});
});
使用固件进行数据库测试
// test/helpers.ts
import { PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';
export const prisma = new PrismaClient();
export async function createTestUser(overrides = {}) {
const hashedPassword = await hash('password123', 10);
return prisma.user.create({
data: {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
password: hashedPassword,
...overrides,
},
});
}
export async function createTestPost(userId: string, overrides = {}) {
return prisma.post.create({
data: {
title: 'Test Post',
content: 'Test content',
authorId: userId,
published: false,
...overrides,
},
});
}
export async function cleanupDatabase() {
// Delete in correct order to respect foreign keys
await prisma.comment.deleteMany();
await prisma.post.deleteMany();
await prisma.user.deleteMany();
}
端到端测试
Playwright 设置
// 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',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
页面对象模式
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
// Note: expect is imported from @playwright/test
await expect(this.errorMessage).toContainText(message);
}
}
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test.describe('Authentication', () => {
test('successful login redirects to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'password123');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'wrong');
await loginPage.expectError('Invalid email or password');
});
test('preserves redirect after login', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login\?redirect=%2Fdashboard/);
const loginPage = new LoginPage(page);
await loginPage.login('[email protected]', 'password123');
await expect(page).toHaveURL('/dashboard');
});
});
模拟模式
使用 MSW 模拟 API
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/user', () => {
return HttpResponse.json({
id: '1',
email: '[email protected]',
name: 'Test User',
});
}),
http.post('/api/posts', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{
id: '1',
...body,
createdAt: new Date().toISOString(),
},
{ status: 201 }
);
}),
http.get('/api/posts/:id', ({ params }) => {
const { id } = params;
if (id === '404') {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json({
id,
title: 'Test Post',
content: 'Test content',
});
}),
];
// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
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());
模块模拟
import { vi } from 'vitest';
import type * as PrismaModule from '@/lib/prisma';
// Mock entire module
vi.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
post: {
findMany: vi.fn(),
create: vi.fn(),
},
},
}));
// Mock with implementation
vi.mock('@/lib/auth', () => ({
hashPassword: vi.fn((password: string) => `hashed_${password}`),
verifyPassword: vi.fn((password: string, hash: string) => {
return hash === `hashed_${password}`;
}),
}));
// Partial mock
vi.mock('@/lib/email', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/email')>();
return {
...actual,
sendEmail: vi.fn(), // Mock only sendEmail
};
});
覆盖率策略
目标阈值
力争让新代码达到高覆盖率:
- 新代码达到 100%(在 CI 中强制执行)
- 现有代码达到 80% 以上(逐步提升)
- 关键路径达到 100%(身份验证、支付、数据完整性)
覆盖率配置
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**',
'**/*.stories.tsx', // Storybook
'**/types/**',
],
all: true,
lines: 80,
functions: 80,
branches: 80,
statements: 80,
// Fail CI if coverage drops
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
找出覆盖率缺口
使用精准工具查找未经测试的代码:
# Find files missing tests
precision_exec:
commands:
- cmd: "npm run test:coverage -- --reporter=json-summary"
expect:
exit_code: 0
# Parse coverage report
precision_read:
files:
- path: "coverage/coverage-summary.json"
extract: content
verbosity: minimal
# Find source files without corresponding tests
discover:
queries:
- id: source-files
type: glob
patterns: ["src/**/*.{ts,tsx}"]
exclude: ["**/*.test.*", "**/*.spec.*"]
- id: test-files
type: glob
patterns: ["**/*.test.{ts,tsx}"]
output_mode: files_only
CI 集成
GitHub Actions 示例
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run unit tests
run: npm run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/coverage-final.json
- name: Run E2E tests
run: npx playwright test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
并行执行测试
// vitest.config.ts - parallel by default
export default defineConfig({
test: {
pool: 'threads',
poolOptions: {
threads: {
singleThread: false,
maxThreads: 8,
},
},
},
});
// playwright.config.ts
export default defineConfig({
workers: process.env.CI ? 1 : 4, // Limit parallelism in CI
fullyParallel: true,
});
精准工具集成
根据预期运行测试
# Run tests and validate output
precision_exec:
commands:
- cmd: "npm run test -- --run"
expect:
exit_code: 0
- cmd: "npm run typecheck"
expect:
exit_code: 0
- cmd: "npm run test:coverage -- --run"
expect:
exit_code: 0
stdout_contains: "All files"
verbosity: minimal
批量测试验证
batch:
id: validate-tests
operations:
query:
- id: find-skipped
type: grep
pattern: "\\.skip|it\\.only|describe\\.only"
glob: "**/*.test.ts"
output:
format: count_only
exec:
- id: run-tests
type: command
commands:
- cmd: "npm run test -- --run"
expect: { exit_code: 0 }
- id: check-coverage
type: command
commands:
- cmd: "npm run test:coverage -- --run"
expect:
exit_code: 0
stdout_contains: "All files"
config:
execution:
mode: sequential
调试不稳定的测试
常见原因
- 竞态条件:对异步操作使用
waitFor - 依赖时间的测试:使用
vi.useFakeTimers()模拟计时器 - 测试隔离:确保测试之间不共享状态
- 网络请求:使用 MSW 模拟,不要依赖真实 API
- 随机数据:使用确定性测试数据,或为随机数生成器设置种子
不稳定测试模式
import { vi, beforeEach, afterEach } from 'vitest';
// Mock timers for time-dependent code
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('debounces function calls', async () => {
const callback = vi.fn();
const debounced = debounce(callback, 1000);
debounced('test');
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledWith('test');
});
// Ensure test isolation
beforeEach(async () => {
await cleanupDatabase();
vi.clearAllMocks();
});
// Use deterministic data
it('sorts users by creation date', () => {
const users = [
{ id: '1', createdAt: new Date('2024-01-01') },
{ id: '2', createdAt: new Date('2024-01-02') },
];
const sorted = sortByDate(users);
expect(sorted[0].id).toBe('2');
});
最佳实践
- 先编写测试(TDD),再开发新功能
- 测试行为,而非实现——关注做什么,而非怎么做
- 尽可能让每个测试只有一个断言,以便于理解
- 使用描述性测试名称——“当 Y 时,它会执行 X”
- 避免模拟所有内容——集成测试需要真实的协作对象
- 保持测试快速——单元测试 <100ms,集成测试 <1s
- 测试边界情况——null、undefined、空数组、边界值
- 不要测试框架代码——测试自己的逻辑,而不是 React/Vue 等
- 维护测试固件——确保测试数据真实且及时更新
- 检查测试覆盖率——100% 覆盖率不代表没有缺陷
常见反模式
[X] 测试实现细节
// BAD - tests internal state
it('sets loading to true', () => {
const { result } = renderHook(() => useUsers());
expect(result.current.loading).toBe(true);
});
// GOOD - tests user-visible behavior
it('shows loading spinner while fetching', () => {
render(<UserList />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
[X] 过度模拟
// BAD - mocks everything, tests nothing
vi.mock('./api');
vi.mock('./utils');
vi.mock('./hooks');
// GOOD - mock only external dependencies
vi.mock('axios'); // Mock HTTP client
// Let your code run for real
[X] 脆弱的选择器
// BAD - breaks when styling changes
const button = container.querySelector('.btn-primary');
// GOOD - uses accessible queries
const button = screen.getByRole('button', { name: /submit/i });
总结
有效的测试需要:
- 组织良好的测试文件,与源代码放在一起
- 全面的单元测试,覆盖业务逻辑和实用工具
- 以用户为中心的组件测试,使用 React Testing Library
- 真实的集成测试,覆盖 API 路由和数据库操作
- 端到端测试,覆盖关键用户流程
- 使用 MSW 模拟 API,确保测试具有确定性
- 高覆盖率目标(新代码达到 100%,整体达到 80% 以上)
- CI 集成,支持并行执行和覆盖率报告
- 通过适当的模拟和隔离,防止测试不稳定
使用精准工具发现测试缺口、高效运行测试,并验证实现质量。
评论
支持 Markdown