testing-qa
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTesting & QA Skill
测试与QA技能
Overview
概述
This skill helps you write comprehensive tests for Next.js applications using Playwright for E2E tests, Jest for unit tests, and React Testing Library for component tests.
本技能可帮助你为Next.js应用编写全面的测试,其中使用Playwright进行端到端(E2E)测试,Jest进行单元测试,React Testing Library进行组件测试。
Testing Philosophy
测试理念
Testing Pyramid
测试金字塔
- E2E Tests (10%): Critical user journeys
- Integration Tests (30%): Component interactions
- Unit Tests (60%): Individual functions and utilities
- 端到端测试(10%):关键用户流程
- 集成测试(30%):组件交互
- 单元测试(60%):独立函数与工具
What to Test
测试要点
- DO: Test behavior, not implementation
- DO: Test user interactions and outcomes
- DO: Test error states and edge cases
- DO: Test accessibility
- DON'T: Test internal implementation details
- DON'T: Test third-party libraries
- DON'T: Over-test simple presentational components
- 要做:测试行为,而非实现细节
- 要做:测试用户交互与结果
- 要做:测试错误状态与边缘情况
- 要做:测试可访问性
- 不要:测试内部实现细节
- 不要:测试第三方库
- 不要:过度测试简单展示型组件
Playwright E2E Tests
Playwright 端到端测试
Setup
配置
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: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
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: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})Basic E2E Test
基础端到端测试
typescript
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('should sign up new user', async ({ page }) => {
await page.goto('/signup')
// Fill form
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
await page.fill('input[name="confirmPassword"]', 'password123')
// Submit
await page.click('button[type="submit"]')
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard')
// Verify welcome message
await expect(page.getByText('Welcome')).toBeVisible()
})
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'wrong@example.com')
await page.fill('input[name="password"]', 'wrongpassword')
await page.click('button[type="submit"]')
// Verify error message
await expect(page.getByText('Invalid credentials')).toBeVisible()
})
})typescript
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('should sign up new user', async ({ page }) => {
await page.goto('/signup')
// Fill form
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
await page.fill('input[name="confirmPassword"]', 'password123')
// Submit
await page.click('button[type="submit"]')
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard')
// Verify welcome message
await expect(page.getByText('Welcome')).toBeVisible()
})
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'wrong@example.com')
await page.fill('input[name="password"]', 'wrongpassword')
await page.click('button[type="submit"]')
// Verify error message
await expect(page.getByText('Invalid credentials')).toBeVisible()
})
})Advanced Playwright Patterns
Playwright 进阶模式
typescript
// Page Object Model
// e2e/pages/login.page.ts
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.page.fill('input[name="email"]', email)
await this.page.fill('input[name="password"]', password)
await this.page.click('button[type="submit"]')
}
async getErrorMessage() {
return await this.page.locator('[role="alert"]').textContent()
}
}
// Usage
test('login with page object', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('test@example.com', 'password')
await expect(page).toHaveURL('/dashboard')
})
// Fixtures for authenticated state
// e2e/fixtures.ts
export const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
// Login
await page.goto('/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password')
await page.click('button[type="submit"]')
await page.waitForURL('/dashboard')
await use(page)
},
})
// Usage
test('dashboard test', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')
// Test authenticated functionality
})typescript
// Page Object Model
// e2e/pages/login.page.ts
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.page.fill('input[name="email"]', email)
await this.page.fill('input[name="password"]', password)
await this.page.click('button[type="submit"]')
}
async getErrorMessage() {
return await this.page.locator('[role="alert"]').textContent()
}
}
// Usage
test('login with page object', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('test@example.com', 'password')
await expect(page).toHaveURL('/dashboard')
})
// Fixtures for authenticated state
// e2e/fixtures.ts
export const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
// Login
await page.goto('/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password')
await page.click('button[type="submit"]')
await page.waitForURL('/dashboard')
await use(page)
},
})
// Usage
test('dashboard test', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')
// Test authenticated functionality
})Common Playwright Patterns
Playwright 常用模式
typescript
// Wait for network
await page.waitForResponse(resp => resp.url().includes('/api/items'))
// Test file upload
await page.setInputFiles('input[type="file"]', 'path/to/file.jpg')
// Test download
const downloadPromise = page.waitForEvent('download')
await page.click('button:has-text("Download")')
const download = await downloadPromise
await download.saveAs('/path/to/save')
// Mock API responses
await page.route('**/api/items', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ items: [] }),
})
})
// Screenshot for debugging
await page.screenshot({ path: 'debug.png', fullPage: true })
// Test responsive design
await page.setViewportSize({ width: 375, height: 667 }) // iPhone size
// Test accessibility
const accessibilityScanResults = await new AxeBuilder({ page }).analyze()
expect(accessibilityScanResults.violations).toEqual([])typescript
// Wait for network
await page.waitForResponse(resp => resp.url().includes('/api/items'))
// Test file upload
await page.setInputFiles('input[type="file"]', 'path/to/file.jpg')
// Test download
const downloadPromise = page.waitForEvent('download')
await page.click('button:has-text("Download")')
const download = await downloadPromise
await download.saveAs('/path/to/save')
// Mock API responses
await page.route('**/api/items', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ items: [] }),
})
})
// Screenshot for debugging
await page.screenshot({ path: 'debug.png', fullPage: true })
// Test responsive design
await page.setViewportSize({ width: 375, height: 667 }) // iPhone size
// Test accessibility
const accessibilityScanResults = await new AxeBuilder({ page }).analyze()
expect(accessibilityScanResults.violations).toEqual([])Component Testing
组件测试
Setup React Testing Library
React Testing Library 配置
typescript
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
}
// jest.setup.js
import '@testing-library/jest-dom'typescript
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
}
// jest.setup.js
import '@testing-library/jest-dom'Basic Component Test
基础组件测试
typescript
// components/button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './button'
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByText('Click me')).toBeDisabled()
})
it('applies variant styles', () => {
render(<Button variant="destructive">Delete</Button>)
const button = screen.getByText('Delete')
expect(button).toHaveClass('bg-red-600')
})
})typescript
// components/button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './button'
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByText('Click me')).toBeDisabled()
})
it('applies variant styles', () => {
render(<Button variant="destructive">Delete</Button>)
const button = screen.getByText('Delete')
expect(button).toHaveClass('bg-red-600')
})
})Testing Async Components
异步组件测试
typescript
// components/user-profile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { UserProfile } from './user-profile'
// Mock fetch
global.fetch = jest.fn()
describe('UserProfile', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('displays loading state initially', () => {
(fetch as jest.Mock).mockImplementation(() =>
new Promise(() => {}) // Never resolves
)
render(<UserProfile userId="123" />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('displays user data when loaded', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ name: 'John Doe', email: 'john@example.com' }),
})
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
})
it('displays error message when fetch fails', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Failed to fetch'))
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('Error loading user')).toBeInTheDocument()
})
})
})typescript
// components/user-profile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { UserProfile } from './user-profile'
// Mock fetch
global.fetch = jest.fn()
describe('UserProfile', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('displays loading state initially', () => {
(fetch as jest.Mock).mockImplementation(() =>
new Promise(() => {}) // Never resolves
)
render(<UserProfile userId="123" />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('displays user data when loaded', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ name: 'John Doe', email: 'john@example.com' }),
})
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
})
it('displays error message when fetch fails', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Failed to fetch'))
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('Error loading user')).toBeInTheDocument()
})
})
})Testing Forms
表单测试
typescript
// components/contact-form.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactForm } from './contact-form'
describe('ContactForm', () => {
it('validates required fields', async () => {
render(<ContactForm />)
const submitButton = screen.getByText('Submit')
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText('Email is required')).toBeInTheDocument()
expect(screen.getByText('Message is required')).toBeInTheDocument()
})
})
it('submits form with valid data', async () => {
const user = userEvent.setup()
const onSubmit = jest.fn()
render(<ContactForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText('Email'), 'test@example.com')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByText('Submit'))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
message: 'Test message',
})
})
})
it('disables submit button while submitting', async () => {
const user = userEvent.setup()
render(<ContactForm />)
await user.type(screen.getByLabelText('Email'), 'test@example.com')
await user.type(screen.getByLabelText('Message'), 'Test message')
const submitButton = screen.getByText('Submit')
await user.click(submitButton)
expect(submitButton).toBeDisabled()
})
})typescript
// components/contact-form.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactForm } from './contact-form'
describe('ContactForm', () => {
it('validates required fields', async () => {
render(<ContactForm />)
const submitButton = screen.getByText('Submit')
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText('Email is required')).toBeInTheDocument()
expect(screen.getByText('Message is required')).toBeInTheDocument()
})
})
it('submits form with valid data', async () => {
const user = userEvent.setup()
const onSubmit = jest.fn()
render(<ContactForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText('Email'), 'test@example.com')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByText('Submit'))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
message: 'Test message',
})
})
})
it('disables submit button while submitting', async () => {
const user = userEvent.setup()
render(<ContactForm />)
await user.type(screen.getByLabelText('Email'), 'test@example.com')
await user.type(screen.getByLabelText('Message'), 'Test message')
const submitButton = screen.getByText('Submit')
await user.click(submitButton)
expect(submitButton).toBeDisabled()
})
})Testing Hooks
Hooks 测试
typescript
// hooks/use-counter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './use-counter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increments count', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
it('resets count', () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.reset()
})
expect(result.current.count).toBe(10)
})
})typescript
// hooks/use-counter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './use-counter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increments count', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
it('resets count', () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.reset()
})
expect(result.current.count).toBe(10)
})
})Unit Testing
单元测试
Testing Utilities
工具函数测试
typescript
// lib/utils.test.ts
import { formatDate, slugify, truncate } from './utils'
describe('formatDate', () => {
it('formats date correctly', () => {
const date = new Date('2024-01-15')
expect(formatDate(date)).toBe('January 15, 2024')
})
it('handles invalid date', () => {
expect(formatDate(new Date('invalid'))).toBe('Invalid Date')
})
})
describe('slugify', () => {
it('converts string to slug', () => {
expect(slugify('Hello World')).toBe('hello-world')
expect(slugify('Next.js App!')).toBe('next-js-app')
})
it('removes special characters', () => {
expect(slugify('Test@#$%')).toBe('test')
})
})
describe('truncate', () => {
it('truncates long strings', () => {
const text = 'This is a very long text'
expect(truncate(text, 10)).toBe('This is a...')
})
it('does not truncate short strings', () => {
const text = 'Short'
expect(truncate(text, 10)).toBe('Short')
})
})typescript
// lib/utils.test.ts
import { formatDate, slugify, truncate } from './utils'
describe('formatDate', () => {
it('formats date correctly', () => {
const date = new Date('2024-01-15')
expect(formatDate(date)).toBe('January 15, 2024')
})
it('handles invalid date', () => {
expect(formatDate(new Date('invalid'))).toBe('Invalid Date')
})
})
describe('slugify', () => {
it('converts string to slug', () => {
expect(slugify('Hello World')).toBe('hello-world')
expect(slugify('Next.js App!')).toBe('next-js-app')
})
it('removes special characters', () => {
expect(slugify('Test@#$%')).toBe('test')
})
})
describe('truncate', () => {
it('truncates long strings', () => {
const text = 'This is a very long text'
expect(truncate(text, 10)).toBe('This is a...')
})
it('does not truncate short strings', () => {
const text = 'Short'
expect(truncate(text, 10)).toBe('Short')
})
})Testing API Routes
API 路由测试
typescript
// app/api/items/route.test.ts
import { GET, POST } from './route'
import { NextRequest } from 'next/server'
describe('/api/items', () => {
describe('GET', () => {
it('returns items', async () => {
const request = new NextRequest('http://localhost:3000/api/items')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.items).toBeDefined()
expect(Array.isArray(data.items)).toBe(true)
})
})
describe('POST', () => {
it('creates new item', async () => {
const request = new NextRequest('http://localhost:3000/api/items', {
method: 'POST',
body: JSON.stringify({ title: 'Test Item' }),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.item).toBeDefined()
expect(data.item.title).toBe('Test Item')
})
it('validates required fields', async () => {
const request = new NextRequest('http://localhost:3000/api/items', {
method: 'POST',
body: JSON.stringify({}),
})
const response = await POST(request)
expect(response.status).toBe(400)
})
})
})typescript
// app/api/items/route.test.ts
import { GET, POST } from './route'
import { NextRequest } from 'next/server'
describe('/api/items', () => {
describe('GET', () => {
it('returns items', async () => {
const request = new NextRequest('http://localhost:3000/api/items')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.items).toBeDefined()
expect(Array.isArray(data.items)).toBe(true)
})
})
describe('POST', () => {
it('creates new item', async () => {
const request = new NextRequest('http://localhost:3000/api/items', {
method: 'POST',
body: JSON.stringify({ title: 'Test Item' }),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.item).toBeDefined()
expect(data.item.title).toBe('Test Item')
})
it('validates required fields', async () => {
const request = new NextRequest('http://localhost:3000/api/items', {
method: 'POST',
body: JSON.stringify({}),
})
const response = await POST(request)
expect(response.status).toBe(400)
})
})
})Mocking
模拟(Mocking)
Mock Supabase
模拟 Supabase
typescript
// __mocks__/supabase.ts
export const createClient = jest.fn(() => ({
from: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
eq: jest.fn().mockReturnThis(),
single: jest.fn().mockResolvedValue({
data: { id: '1', title: 'Test' },
error: null,
}),
})),
auth: {
getUser: jest.fn().mockResolvedValue({
data: { user: { id: '123', email: 'test@example.com' } },
error: null,
}),
},
}))typescript
// __mocks__/supabase.ts
export const createClient = jest.fn(() => ({
from: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
eq: jest.fn().mockReturnThis(),
single: jest.fn().mockResolvedValue({
data: { id: '1', title: 'Test' },
error: null,
}),
})),
auth: {
getUser: jest.fn().mockResolvedValue({
data: { user: { id: '123', email: 'test@example.com' } },
error: null,
}),
},
}))Mock Next.js Router
模拟 Next.js 路由
typescript
// Mock useRouter
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
prefetch: jest.fn(),
}),
usePathname: () => '/test-path',
useSearchParams: () => new URLSearchParams(),
}))typescript
// Mock useRouter
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
prefetch: jest.fn(),
}),
usePathname: () => '/test-path',
useSearchParams: () => new URLSearchParams(),
}))Mock External API
模拟外部 API
typescript
// Use MSW (Mock Service Worker)
import { rest } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
rest.get('/api/items', (req, res, ctx) => {
return res(
ctx.json({
items: [
{ id: '1', title: 'Item 1' },
{ id: '2', title: 'Item 2' },
],
})
)
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())typescript
// Use MSW (Mock Service Worker)
import { rest } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
rest.get('/api/items', (req, res, ctx) => {
return res(
ctx.json({
items: [
{ id: '1', title: 'Item 1' },
{ id: '2', title: 'Item 2' },
],
})
)
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())Testing Checklist
测试检查清单
E2E Tests
端到端测试
- Test critical user journeys (signup, login, checkout)
- Test on multiple browsers
- Test on mobile viewport
- Test error states
- Test with slow network
- Test with disabled JavaScript (where applicable)
- Test accessibility
- 测试关键用户流程(注册、登录、结账)
- 在多浏览器上测试
- 在移动端视窗测试
- 测试错误状态
- 在慢速网络下测试
- 在禁用JavaScript的环境下测试(如适用)
- 测试可访问性
Component Tests
组件测试
- Test rendering
- Test user interactions
- Test props
- Test conditional rendering
- Test error states
- Test loading states
- Test accessibility
- 测试渲染效果
- 测试用户交互
- 测试属性(props)
- 测试条件渲染
- 测试错误状态
- 测试加载状态
- 测试可访问性
Unit Tests
单元测试
- Test pure functions
- Test edge cases
- Test error handling
- Test with various inputs
- Test boundary conditions
- 测试纯函数
- 测试边缘情况
- 测试错误处理
- 测试多种输入
- 测试边界条件
Common Testing Patterns
常用测试模式
Test IDs for Reliable Selection
使用测试ID实现可靠元素选择
typescript
// Component
<button data-testid="submit-button">Submit</button>
// Test
const button = screen.getByTestId('submit-button')typescript
// Component
<button data-testid="submit-button">Submit</button>
// Test
const button = screen.getByTestId('submit-button')Accessible Queries (Preferred)
可访问性查询(推荐)
typescript
// By role (best)
screen.getByRole('button', { name: /submit/i })
// By label
screen.getByLabelText('Email')
// By text
screen.getByText('Welcome')
// By placeholder
screen.getByPlaceholderText('Enter email')typescript
// By role (best)
screen.getByRole('button', { name: /submit/i })
// By label
screen.getByLabelText('Email')
// By text
screen.getByText('Welcome')
// By placeholder
screen.getByPlaceholderText('Enter email')Testing Loading States
测试加载状态
typescript
it('shows loading then content', async () => {
render(<Component />)
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument()
// Wait for content
await waitFor(() => {
expect(screen.getByText('Content')).toBeInTheDocument()
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
})
})typescript
it('shows loading then content', async () => {
render(<Component />)
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument()
// Wait for content
await waitFor(() => {
expect(screen.getByText('Content')).toBeInTheDocument()
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
})
})Testing Error Boundaries
测试错误边界
typescript
it('renders error boundary on error', () => {
const spy = jest.spyOn(console, 'error').mockImplementation()
render(
<ErrorBoundary>
<ComponentThatThrows />
</ErrorBoundary>
)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
spy.mockRestore()
})typescript
it('renders error boundary on error', () => {
const spy = jest.spyOn(console, 'error').mockImplementation()
render(
<ErrorBoundary>
<ComponentThatThrows />
</ErrorBoundary>
)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
spy.mockRestore()
})Debugging Tests
调试测试
Debug Output
调试输出
typescript
import { screen, render } from '@testing-library/react'
// Print component tree
render(<Component />)
screen.debug()
// Print specific element
screen.debug(screen.getByRole('button'))typescript
import { screen, render } from '@testing-library/react'
// Print component tree
render(<Component />)
screen.debug()
// Print specific element
screen.debug(screen.getByRole('button'))Playwright Debug
Playwright 调试
bash
undefinedbash
undefinedRun in debug mode with browser
Run in debug mode with browser
npx playwright test --debug
npx playwright test --debug
Run specific test
Run specific test
npx playwright test auth.spec.ts --debug
npx playwright test auth.spec.ts --debug
Run with headed browser
Run with headed browser
npx playwright test --headed
undefinednpx playwright test --headed
undefinedCommon Issues
常见问题
Element not found:
- Check if element exists: vs
screen.getByTextscreen.queryByText - Use for async elements:
findByscreen.findByText - Check accessibility tree:
screen.debug()
Timing issues:
- Use for async updates
waitFor - Use queries (built-in wait)
findBy - Increase timeout if needed
State updates not reflected:
- Wrap in if updating state manually
act() - Use instead of
userEventfor more realistic eventsfireEvent
元素未找到:
- 检查元素是否存在:vs
screen.getByTextscreen.queryByText - 异步元素使用:
findByscreen.findByText - 检查可访问性树:
screen.debug()
时序问题:
- 使用处理异步更新
waitFor - 使用查询(内置等待机制)
findBy - 必要时增加超时时间
状态更新未反映:
- 手动更新状态时包裹在中
act() - 使用替代
userEvent以模拟更真实的事件fireEvent
Performance Testing
性能测试
typescript
// Lighthouse CI
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000/'],
numberOfRuns: 3,
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
},
},
},
}typescript
// Lighthouse CI
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000/'],
numberOfRuns: 3,
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
},
},
},
}When to Use This Skill
何时使用本技能
Invoke this skill when:
- Writing new tests
- Debugging test failures
- Setting up test infrastructure
- Testing specific scenarios (forms, async, auth)
- Implementing E2E tests
- Testing accessibility
- Mocking dependencies
- Improving test coverage
在以下场景调用本技能:
- 编写新测试时
- 调试测试失败时
- 搭建测试基础设施时
- 测试特定场景(表单、异步、认证)时
- 实现端到端测试时
- 测试可访问性时
- 模拟依赖项时
- 提升测试覆盖率时