testing-strategy
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseResources
资源
scripts/
validate-tests.sh
references/
testing-patterns.mdscripts/
validate-tests.sh
references/
testing-patterns.mdTesting Strategy
测试策略
This skill guides you through implementing comprehensive testing strategies using modern testing frameworks and GoodVibes precision tools. Use this workflow when adding tests to existing code, setting up test infrastructure, or achieving coverage goals.
本技能将引导你使用现代测试框架和GoodVibes精准工具实现全面的测试策略。当你需要为现有代码添加测试、搭建测试基础设施或达成覆盖率目标时,可使用此工作流。
When to Use This Skill
何时使用本技能
- Setting up test infrastructure (Vitest, Jest, Playwright)
- Writing unit tests for functions, hooks, and utilities
- Creating component tests with React Testing Library
- Implementing E2E tests with Playwright
- Adding API mocking with MSW
- Improving test coverage
- Configuring CI test pipelines
- Debugging flaky tests
- Creating test fixtures and factories
- 搭建测试基础设施(Vitest、Jest、Playwright)
- 为函数、Hook和工具类编写单元测试
- 使用React Testing Library创建组件测试
- 用Playwright实现E2E测试
- 通过MSW添加API模拟
- 提升测试覆盖率
- 配置CI测试流水线
- 调试不稳定的测试(Flaky Tests)
- 创建测试夹具和工厂函数
Test Organization
测试组织
File Naming Conventions
文件命名规范
Follow consistent naming patterns:
typescript
// 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遵循统一的命名模式:
typescript
// 同目录模式(推荐)
src/
components/
Button.tsx
Button.test.tsx // 组件测试
utils/
formatDate.ts
formatDate.test.ts // 单元测试
api/
users.ts
users.integration.test.ts // 集成测试
// 集中式模式(备选)
__tests__/
components/
Button.test.tsx
utils/
formatDate.test.tsTest Suite Structure
测试套件结构
Organize tests with clear describe blocks:
typescript
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');
});
});
});使用清晰的describe块组织测试:
typescript
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');
});
});
});Discovery: Finding Test Files
发现:查找测试文件
Use precision tools to discover existing test patterns:
yaml
undefined使用精准工具发现现有测试模式:
yaml
undefinedFind 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
undefineddiscover:
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
undefinedUnit Testing
单元测试
Vitest Setup (Recommended)
Vitest 配置(推荐)
typescript
// 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'),
},
},
});typescript
// 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'),
},
},
});Testing Pure Functions
测试纯函数
typescript
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');
});
});
});typescript
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');
});
});
});Testing React Hooks
测试React Hooks
typescript
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: 'test@example.com' },
isLoading: false,
});
const { result } = renderHook(() => useAuth());
expect(result.current.user).toEqual({
id: '1',
email: 'test@example.com',
});
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('test@example.com', 'password');
expect(refetch).toHaveBeenCalledOnce();
});
});typescript
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: 'test@example.com' },
isLoading: false,
});
const { result } = renderHook(() => useAuth());
expect(result.current.user).toEqual({
id: '1',
email: 'test@example.com',
});
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('test@example.com', 'password');
expect(refetch).toHaveBeenCalledOnce();
});
});Component Testing
组件测试
React Testing Library Patterns
React Testing Library 模式
typescript
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('');
});
});typescript
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('');
});
});Testing Async Components
测试异步组件
typescript
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: 'john@example.com',
});
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('john@example.com')).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();
});
});
});typescript
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: 'john@example.com',
});
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('john@example.com')).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();
});
});
});Integration Testing
集成测试
API Route Testing
API路由测试
typescript
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);
});
});typescript
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);
});
});Database Testing with Fixtures
数据库测试与夹具
typescript
// 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();
}typescript
// 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();
}E2E Testing
E2E测试
Playwright Setup
Playwright配置
typescript
// 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,
},
});typescript
// 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,
},
});Page Object Pattern
页面对象模式
typescript
// 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('test@example.com', '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('invalid@example.com', '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('test@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
});typescript
// 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('test@example.com', '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('invalid@example.com', '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('test@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
});Mocking Patterns
模拟模式
MSW for API Mocking
MSW用于API模拟
typescript
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/user', () => {
return HttpResponse.json({
id: '1',
email: 'test@example.com',
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());typescript
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/user', () => {
return HttpResponse.json({
id: '1',
email: 'test@example.com',
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());Module Mocking
模块模拟
typescript
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
};
});typescript
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
};
});Coverage Strategy
覆盖率策略
Target Thresholds
目标阈值
Aim for high coverage on new code:
- 100% on new code (enforced in CI)
- 80%+ on existing code (gradual improvement)
- 100% on critical paths (auth, payment, data integrity)
为新代码设定高覆盖率目标:
- 新代码100%覆盖率(CI中强制执行)
- 现有代码80%+覆盖率(逐步提升)
- 关键路径100%覆盖率(认证、支付、数据完整性)
Coverage Configuration
覆盖率配置
typescript
// 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,
},
},
},
});typescript
// 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,
},
},
},
});Identifying Coverage Gaps
识别覆盖率缺口
Use precision tools to find untested code:
yaml
undefined使用精准工具查找未测试代码:
yaml
undefinedFind files missing tests
Find files missing tests
precision_exec:
commands:
- cmd: "npm run test:coverage -- --reporter=json-summary"
expect:
exit_code: 0
precision_exec:
commands:
- cmd: "npm run test:coverage -- --reporter=json-summary"
expect:
exit_code: 0
Parse coverage report
Parse coverage report
precision_read:
files:
- path: "coverage/coverage-summary.json"
extract: content
verbosity: minimal
precision_read:
files:
- path: "coverage/coverage-summary.json"
extract: content
verbosity: minimal
Find source files without corresponding tests
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
undefineddiscover:
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
undefinedCI Integration
CI集成
GitHub Actions Example
GitHub Actions示例
yaml
undefinedyaml
undefined.github/workflows/test.yml
.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: 30undefinedname: 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: 30undefinedParallel Test Execution
并行测试执行
typescript
// 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,
});typescript
// 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,
});Precision Tool Integration
精准工具集成
Running Tests with Expectations
带预期的测试运行
yaml
undefinedyaml
undefinedRun tests and validate output
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
undefinedprecision_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
undefinedBatch Test Validation
批量测试验证
yaml
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: sequentialyaml
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: sequentialDebugging Flaky Tests
调试不稳定的测试
Common Causes
常见原因
- Race conditions: Use for async operations
waitFor - Time-dependent tests: Mock timers with
vi.useFakeTimers() - Test isolation: Ensure tests don't share state
- Network requests: Mock with MSW, don't rely on real APIs
- Random data: Use deterministic test data or seed random generators
- 竞态条件:对异步操作使用
waitFor - 时间相关测试:用模拟定时器
vi.useFakeTimers() - 测试隔离:确保测试不共享状态
- 网络请求:用MSW模拟,不要依赖真实API
- 随机数据:使用确定性测试数据或为随机生成器设置种子
Flaky Test Patterns
不稳定测试模式
typescript
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');
});typescript
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');
});Best Practices
最佳实践
- Write tests first (TDD) for new features
- Test behavior, not implementation - focus on what, not how
- One assertion per test when possible for clarity
- Use descriptive test names - "it does X when Y"
- Avoid mocking everything - integration tests need real collaborators
- Keep tests fast - unit tests <100ms, integration <1s
- Test edge cases - null, undefined, empty arrays, boundary values
- Don't test framework code - test your logic, not React/Vue/etc.
- Maintain test fixtures - keep test data realistic and up-to-date
- Review test coverage - 100% coverage doesn't mean bug-free
- 先写测试(TDD)用于新功能
- 测试行为而非实现 - 关注“做什么”,而非“怎么做”
- 尽可能每个测试一个断言以提升清晰度
- 使用描述性测试名称 - “当Y时,它会做X”
- 不要模拟所有内容 - 集成测试需要真实的协作对象
- 保持测试快速 - 单元测试<100ms,集成测试<1s
- 测试边缘情况 - null、undefined、空数组、边界值
- 不要测试框架代码 - 测试你的逻辑,而非React/Vue等
- 维护测试夹具 - 保持测试数据真实且最新
- 审查测试覆盖率 - 100%覆盖率不代表没有bug
Common Anti-Patterns
常见反模式
[X] Testing Implementation Details
[X] 测试实现细节
typescript
// 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();
});typescript
// 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] Overmocking
[X] 过度模拟
typescript
// 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 realtypescript
// 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] Brittle Selectors
[X] 脆弱的选择器
typescript
// BAD - breaks when styling changes
const button = container.querySelector('.btn-primary');
// GOOD - uses accessible queries
const button = screen.getByRole('button', { name: /submit/i });typescript
// BAD - breaks when styling changes
const button = container.querySelector('.btn-primary');
// GOOD - uses accessible queries
const button = screen.getByRole('button', { name: /submit/i });Summary
总结
Effective testing requires:
- Organized test files co-located with source code
- Comprehensive unit tests for business logic and utilities
- User-centric component tests with React Testing Library
- Realistic integration tests for API routes and database operations
- End-to-end tests for critical user flows
- API mocking with MSW for deterministic tests
- High coverage targets (100% on new code, 80%+ overall)
- CI integration with parallel execution and coverage reporting
- Flaky test prevention through proper mocking and isolation
Use precision tools to discover test gaps, run tests efficiently, and validate implementation quality.
有效的测试需要:
- 有组织的测试文件与源代码同目录存放
- 全面的单元测试用于业务逻辑和工具类
- 以用户为中心的组件测试使用React Testing Library
- 真实的集成测试用于API路由和数据库操作
- 端到端测试用于关键用户流程
- API模拟使用MSW实现确定性测试
- 高覆盖率目标(新代码100%,整体80%+)
- CI集成支持并行执行和覆盖率报告
- 不稳定测试预防通过正确的模拟和隔离
使用精准工具发现测试缺口、高效运行测试并验证实现质量。