testing-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTesting Patterns & Strategies
测试模式与策略
Comprehensive guide to testing JavaScript/TypeScript applications. Complements the skill (which focuses on workflow) by providing concrete patterns, strategies, and anti-patterns.
tdd这是一份JavaScript/TypeScript应用测试的全面指南,可作为专注于工作流的技能的补充,提供具体的测试模式、策略以及反模式说明。
tddWhen to Use This Skill
何时使用本技能
Activate when the user:
- Wants to write tests for existing or new code
- Needs to establish a testing strategy for a project
- Wants to improve test coverage or reliability
- Has flaky or brittle tests
- Asks about mocking, stubbing, or test doubles
- Needs guidance on unit vs integration vs E2E tests
当用户出现以下需求时启用本技能:
- 需要为现有代码或新代码编写测试
- 需要为项目制定测试策略
- 需要提升测试覆盖率或稳定性
- 遇到不稳定或脆弱的测试
- 询问mocking、stubbing或测试替身相关问题
- 需要了解单元测试、集成测试、E2E测试的差异
Testing Pyramid
测试金字塔
/ E2E \ Few, slow, high confidence
/----------\
/ Integration \ Moderate count, medium speed
/----------------\
/ Unit \ Many, fast, focused
/____________________\Rule of thumb: ~70% unit, ~20% integration, ~10% E2E. Adjust per project — legacy codebases often benefit from more integration tests initially.
/ E2E \ 数量少、运行慢、置信度高
/----------\
/ Integration \ 数量中等、运行速度中等
/----------------\
/ Unit \ 数量多、运行快、针对性强
/____________________\经验法则: 约70%单元测试,约20%集成测试,约10% E2E测试。可根据项目调整——遗留代码库通常在初期更适合多写集成测试。
1. Unit Testing Patterns
1. 单元测试模式
Test Structure: AAA (Arrange, Act, Assert)
测试结构:AAA(安排、执行、断言)
typescript
describe('calculateDiscount', () => {
it('should apply 10% discount for orders above 100€', () => {
// Arrange
const order = { total: 150, items: ['item1', 'item2'] }
// Act
const result = calculateDiscount(order)
// Assert
expect(result).toBe(135)
})
})typescript
describe('calculateDiscount', () => {
it('should apply 10% discount for orders above 100€', () => {
// Arrange
const order = { total: 150, items: ['item1', 'item2'] }
// Act
const result = calculateDiscount(order)
// Assert
expect(result).toBe(135)
})
})Naming Convention
命名规范
Use descriptive names that read as specifications:
typescript
// GOOD — describes behavior
it('should return 401 when token is expired')
it('should retry 3 times before failing')
it('should trim whitespace from patient name')
// BAD — describes implementation
it('should call validateToken')
it('should set retryCount to 3')
it('should use .trim()')使用描述性的名称,可读性和规范说明一致:
typescript
// 优秀 — 描述行为
it('should return 401 when token is expired')
it('should retry 3 times before failing')
it('should trim whitespace from patient name')
// 糟糕 — 描述实现细节
it('should call validateToken')
it('should set retryCount to 3')
it('should use .trim()')Parameterized Tests (Table-Driven)
参数化测试(表驱动测试)
typescript
describe('parseHL7Date', () => {
it.each([
['20260315', new Date(2026, 2, 15)],
['20260315120000', new Date(2026, 2, 15, 12, 0, 0)],
['', null],
['invalid', null],
])('should parse "%s" to %s', (input, expected) => {
expect(parseHL7Date(input)).toEqual(expected)
})
})typescript
describe('parseHL7Date', () => {
it.each([
['20260315', new Date(2026, 2, 15)],
['20260315120000', new Date(2026, 2, 15, 12, 0, 0)],
['', null],
['invalid', null],
])('should parse "%s" to %s', (input, expected) => {
expect(parseHL7Date(input)).toEqual(expected)
})
})Testing Error Cases
错误场景测试
Always test the unhappy path:
typescript
describe('PatientService.findById', () => {
it('should throw NotFoundError for unknown patient ID', async () => {
await expect(service.findById('UNKNOWN'))
.rejects.toThrow(NotFoundError)
})
it('should throw ValidationError for empty ID', async () => {
await expect(service.findById(''))
.rejects.toThrow(ValidationError)
})
})始终测试非预期路径:
typescript
describe('PatientService.findById', () => {
it('should throw NotFoundError for unknown patient ID', async () => {
await expect(service.findById('UNKNOWN'))
.rejects.toThrow(NotFoundError)
})
it('should throw ValidationError for empty ID', async () => {
await expect(service.findById(''))
.rejects.toThrow(ValidationError)
})
})Testing Async Code
异步代码测试
typescript
// Promises
it('should resolve with patient data', async () => {
const patient = await fetchPatient('PAT123')
expect(patient.name).toBe('DUPONT')
})
// Event emitters
it('should emit "transfer" event on unit change', (done) => {
emitter.on('transfer', (data) => {
expect(data.unit).toBe('CARDIO')
done()
})
service.transferPatient('PAT123', 'CARDIO')
})typescript
// Promise写法
it('should resolve with patient data', async () => {
const patient = await fetchPatient('PAT123')
expect(patient.name).toBe('DUPONT')
})
// 事件发射器写法
it('should emit "transfer" event on unit change', (done) => {
emitter.on('transfer', (data) => {
expect(data.unit).toBe('CARDIO')
done()
})
service.transferPatient('PAT123', 'CARDIO')
})2. Integration Testing Patterns
2. 集成测试模式
Integration tests verify that components work together correctly.
集成测试用于验证多个组件是否可以正确协同工作。
Database Integration Tests
数据库集成测试
typescript
describe('PatientRepository', () => {
let db: TestDatabase
beforeAll(async () => {
db = await TestDatabase.create() // Real DB, not mock
await db.migrate()
})
afterEach(async () => {
await db.truncate() // Clean between tests
})
afterAll(async () => {
await db.destroy()
})
it('should persist and retrieve a patient', async () => {
const repo = new PatientRepository(db.connection)
await repo.create({
id: 'PAT123',
name: 'DUPONT',
birthDate: '1975-03-15',
})
const found = await repo.findById('PAT123')
expect(found).toMatchObject({
id: 'PAT123',
name: 'DUPONT',
})
})
})typescript
describe('PatientRepository', () => {
let db: TestDatabase
beforeAll(async () => {
db = await TestDatabase.create() // 真实数据库,非mock
await db.migrate()
})
afterEach(async () => {
await db.truncate() // 测试间清空数据
})
afterAll(async () => {
await db.destroy()
})
it('should persist and retrieve a patient', async () => {
const repo = new PatientRepository(db.connection)
await repo.create({
id: 'PAT123',
name: 'DUPONT',
birthDate: '1975-03-15',
})
const found = await repo.findById('PAT123')
expect(found).toMatchObject({
id: 'PAT123',
name: 'DUPONT',
})
})
})API Integration Tests
API集成测试
typescript
describe('POST /api/patients', () => {
it('should create a patient and return 201', async () => {
const response = await request(app)
.post('/api/patients')
.send({ name: 'DUPONT', birthDate: '1975-03-15' })
.set('Authorization', `Bearer ${validToken}`)
expect(response.status).toBe(201)
expect(response.body.data.id).toBeDefined()
})
it('should return 400 for missing required fields', async () => {
const response = await request(app)
.post('/api/patients')
.send({}) // Missing name
.set('Authorization', `Bearer ${validToken}`)
expect(response.status).toBe(400)
expect(response.body.errors).toContainEqual(
expect.objectContaining({ field: 'name' })
)
})
it('should return 401 without authentication', async () => {
const response = await request(app)
.post('/api/patients')
.send({ name: 'DUPONT' })
expect(response.status).toBe(401)
})
})typescript
describe('POST /api/patients', () => {
it('should create a patient and return 201', async () => {
const response = await request(app)
.post('/api/patients')
.send({ name: 'DUPONT', birthDate: '1975-03-15' })
.set('Authorization', `Bearer ${validToken}`)
expect(response.status).toBe(201)
expect(response.body.data.id).toBeDefined()
})
it('should return 400 for missing required fields', async () => {
const response = await request(app)
.post('/api/patients')
.send({}) // 缺少name字段
.set('Authorization', `Bearer ${validToken}`)
expect(response.status).toBe(400)
expect(response.body.errors).toContainEqual(
expect.objectContaining({ field: 'name' })
)
})
it('should return 401 without authentication', async () => {
const response = await request(app)
.post('/api/patients')
.send({ name: 'DUPONT' })
expect(response.status).toBe(401)
})
})Message Processing Integration Tests
消息处理集成测试
Particularly relevant for healthcare message processing (HPK, HL7):
typescript
describe('HPK Message Pipeline', () => {
it('should parse, validate, and transform an ID|M1 message', async () => {
const rawMessage = 'ID|M1|C|HEXAGONE|20260122120000|USER001|PAT123|DUPONT|JEAN|19750315|M'
const result = await pipeline.process(rawMessage)
expect(result.type).toBe('ID')
expect(result.code).toBe('M1')
expect(result.patient.name).toBe('DUPONT')
expect(result.valid).toBe(true)
})
})尤其适用于医疗消息处理场景(HPK、HL7):
typescript
describe('HPK Message Pipeline', () => {
it('should parse, validate, and transform an ID|M1 message', async () => {
const rawMessage = 'ID|M1|C|HEXAGONE|20260122120000|USER001|PAT123|DUPONT|JEAN|19750315|M'
const result = await pipeline.process(rawMessage)
expect(result.type).toBe('ID')
expect(result.code).toBe('M1')
expect(result.patient.name).toBe('DUPONT')
expect(result.valid).toBe(true)
})
})3. E2E Testing Patterns
3. E2E测试模式
E2E tests validate complete user workflows through the real application.
E2E测试通过真实应用验证完整的用户工作流。
Page Object Pattern
页面对象模式
typescript
class PatientListPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/patients')
}
async search(query: string) {
await this.page.fill('[data-testid="search-input"]', query)
await this.page.click('[data-testid="search-button"]')
}
async getResults() {
return this.page.locator('[data-testid="patient-row"]').allTextContents()
}
async clickPatient(name: string) {
await this.page.click(`text=${name}`)
}
}
// Usage in test
test('should find patient by name', async ({ page }) => {
const patientList = new PatientListPage(page)
await patientList.goto()
await patientList.search('DUPONT')
const results = await patientList.getResults()
expect(results).toContain('DUPONT JEAN')
})typescript
class PatientListPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/patients')
}
async search(query: string) {
await this.page.fill('[data-testid="search-input"]', query)
await this.page.click('[data-testid="search-button"]')
}
async getResults() {
return this.page.locator('[data-testid="patient-row"]').allTextContents()
}
async clickPatient(name: string) {
await this.page.click(`text=${name}`)
}
}
// 测试中的使用方式
test('should find patient by name', async ({ page }) => {
const patientList = new PatientListPage(page)
await patientList.goto()
await patientList.search('DUPONT')
const results = await patientList.getResults()
expect(results).toContain('DUPONT JEAN')
})Data-testid Convention
Data-testid规范
Use attributes for test selectors, never CSS classes or element structure:
data-testidtypescript
// GOOD — stable selector
await page.click('[data-testid="submit-admission"]')
// BAD — brittle, breaks on style/structure changes
await page.click('.btn.btn-primary.submit')
await page.click('form > div:nth-child(3) > button')使用属性作为测试选择器,永远不要使用CSS类或元素结构作为选择器:
data-testidtypescript
// 优秀 — 稳定的选择器
await page.click('[data-testid="submit-admission"]')
// 糟糕 — 脆弱,样式/结构变更就会失效
await page.click('.btn.btn-primary.submit')
await page.click('form > div:nth-child(3) > button')Visual Regression Testing
视觉回归测试
typescript
test('patient dashboard matches snapshot', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixelRatio: 0.01,
})
})typescript
test('patient dashboard matches snapshot', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixelRatio: 0.01,
})
})4. Mocking Strategies
4. Mocking策略
When to Mock
何时使用Mock
| Mock | Don't Mock |
|---|---|
| External HTTP APIs | Your own database (use real test DB) |
| Time/Date (for determinism) | Internal collaborators between modules |
| File system (when impractical) | Simple utility functions |
| Third-party services (payment, email) | Data transformations |
| Environment-specific features | Business logic |
| 需要Mock的场景 | 不需要Mock的场景 |
|---|---|
| 外部HTTP API | 自有数据库(使用真实测试数据库) |
| 时间/日期(保证测试确定性) | 模块内部的协作组件 |
| 文件系统(当实际调用不现实时) | 简单的工具函数 |
| 第三方服务(支付、邮件) | 数据转换逻辑 |
| 环境特定功能 | 业务逻辑 |
Mock vs Stub vs Spy
Mock vs Stub vs Spy
typescript
// STUB — returns a fixed value
const getPatient = vi.fn().mockResolvedValue({ id: 'PAT123', name: 'DUPONT' })
// SPY — observes calls without changing behavior
const logSpy = vi.spyOn(logger, 'info')
await service.admitPatient(data)
expect(logSpy).toHaveBeenCalledWith('Patient admitted', { id: 'PAT123' })
// MOCK — replaces the entire module
vi.mock('./emailService', () => ({
sendAdmissionNotification: vi.fn().mockResolvedValue(true),
}))typescript
// STUB — 返回固定值
const getPatient = vi.fn().mockResolvedValue({ id: 'PAT123', name: 'DUPONT' })
// SPY — 观察调用情况,不改变原有行为
const logSpy = vi.spyOn(logger, 'info')
await service.admitPatient(data)
expect(logSpy).toHaveBeenCalledWith('Patient admitted', { id: 'PAT123' })
// MOCK — 替换整个模块
vi.mock('./emailService', () => ({
sendAdmissionNotification: vi.fn().mockResolvedValue(true),
}))MSW for HTTP Mocking
使用MSW进行HTTP Mock
Prefer MSW (Mock Service Worker) over manual fetch mocking:
typescript
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
http.get('/api/patients/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'DUPONT',
birthDate: '1975-03-15',
})
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())优先使用MSW(Mock Service Worker)而非手动mock fetch请求:
typescript
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
http.get('/api/patients/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'DUPONT',
birthDate: '1975-03-15',
})
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())Time Mocking
时间Mock
typescript
describe('token expiration', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should reject expired tokens', () => {
const token = createToken({ expiresIn: '1h' })
vi.advanceTimersByTime(2 * 60 * 60 * 1000) // 2 hours later
expect(() => validateToken(token)).toThrow('Token expired')
})
})typescript
describe('token expiration', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should reject expired tokens', () => {
const token = createToken({ expiresIn: '1h' })
vi.advanceTimersByTime(2 * 60 * 60 * 1000) // 2小时后
expect(() => validateToken(token)).toThrow('Token expired')
})
})5. Test Organization
5. 测试组织
File Structure — Colocation
文件结构 — 同位置放置
src/
├── patients/
│ ├── PatientService.ts
│ ├── PatientService.test.ts # Unit tests next to source
│ ├── PatientRepository.ts
│ └── PatientRepository.test.ts
├── __tests__/
│ └── integration/
│ ├── patient-admission.test.ts # Integration tests
│ └── hl7-message-flow.test.ts
└── e2e/
├── patient-workflow.spec.ts # E2E tests
└── pages/
└── PatientListPage.ts # Page objectssrc/
├── patients/
│ ├── PatientService.ts
│ ├── PatientService.test.ts # 单元测试和源码放在一起
│ ├── PatientRepository.ts
│ └── PatientRepository.test.ts
├── __tests__/
│ └── integration/
│ ├── patient-admission.test.ts # 集成测试
│ └── hl7-message-flow.test.ts
└── e2e/
├── patient-workflow.spec.ts # E2E测试
└── pages/
└── PatientListPage.ts # 页面对象Test Utilities — Factories
测试工具 — 工厂函数
Avoid copy-pasting test data. Use factories:
typescript
// test/factories/patient.ts
export function buildPatient(overrides: Partial<Patient> = {}): Patient {
return {
id: `PAT${Math.random().toString(36).slice(2, 8)}`,
name: 'DUPONT',
firstName: 'JEAN',
birthDate: '1975-03-15',
sex: 'M',
...overrides,
}
}
// Usage
it('should flag underage patients', () => {
const minor = buildPatient({ birthDate: '2015-06-01' })
expect(isMinor(minor)).toBe(true)
})避免复制粘贴测试数据,使用工厂函数:
typescript
// test/factories/patient.ts
export function buildPatient(overrides: Partial<Patient> = {}): Patient {
return {
id: `PAT${Math.random().toString(36).slice(2, 8)}`,
name: 'DUPONT',
firstName: 'JEAN',
birthDate: '1975-03-15',
sex: 'M',
...overrides,
}
}
// 使用示例
it('should flag underage patients', () => {
const minor = buildPatient({ birthDate: '2015-06-01' })
expect(isMinor(minor)).toBe(true)
})6. Common Anti-Patterns
6. 常见反模式
Testing Implementation Details
测试实现细节
typescript
// BAD — tests internal state
it('should set isLoading to true', () => {
component.fetchData()
expect(component.state.isLoading).toBe(true)
})
// GOOD — tests observable behavior
it('should show loading spinner while fetching', async () => {
render(<PatientList />)
expect(screen.getByTestId('spinner')).toBeVisible()
await waitForElementToBeRemoved(screen.queryByTestId('spinner'))
})typescript
// 糟糕 — 测试内部状态
it('should set isLoading to true', () => {
component.fetchData()
expect(component.state.isLoading).toBe(true)
})
// 优秀 — 测试可观测的行为
it('should show loading spinner while fetching', async () => {
render(<PatientList />)
expect(screen.getByTestId('spinner')).toBeVisible()
await waitForElementToBeRemoved(screen.queryByTestId('spinner'))
})Excessive Mocking
过度Mock
typescript
// BAD — mocking everything, testing nothing real
it('should create patient', async () => {
vi.mock('./db')
vi.mock('./validator')
vi.mock('./logger')
// ... what are you even testing?
})
// GOOD — use real implementations, mock only boundaries
it('should create patient in database', async () => {
const repo = new PatientRepository(testDb)
const service = new PatientService(repo) // Real repo, real DB
const patient = await service.create({ name: 'DUPONT' })
expect(patient.id).toBeDefined()
})typescript
// 糟糕 — Mock所有内容,没有测试任何真实逻辑
it('should create patient', async () => {
vi.mock('./db')
vi.mock('./validator')
vi.mock('./logger')
// ... 你到底在测试什么?
})
// 优秀 — 使用真实实现,仅Mock边界依赖
it('should create patient in database', async () => {
const repo = new PatientRepository(testDb)
const service = new PatientService(repo) // 真实的repo,真实的数据库
const patient = await service.create({ name: 'DUPONT' })
expect(patient.id).toBeDefined()
})Flaky Test Patterns
不稳定测试模式
Common causes and fixes:
| Cause | Fix |
|---|---|
| Shared mutable state | Use |
| Time-dependent tests | Use |
| Race conditions in async tests | Use |
| Order-dependent tests | Each test must be independent |
| Network calls in unit tests | Mock HTTP with MSW |
| Hardcoded ports | Use dynamic port assignment |
常见原因和解决方案:
| 原因 | 解决方案 |
|---|---|
| 共享可变状态 | 使用 |
| 依赖时间的测试 | 使用 |
| 异步测试中的竞态条件 | 使用 |
| 依赖执行顺序的测试 | 每个测试必须完全独立 |
| 单元测试中的网络请求 | 使用MSW Mock HTTP请求 |
| 硬编码端口 | 使用动态端口分配 |
Snapshot Overuse
过度使用快照
typescript
// BAD — meaningless snapshot of entire component
it('should render', () => {
const { container } = render(<PatientCard patient={patient} />)
expect(container).toMatchSnapshot() // 200 lines of HTML nobody reads
})
// GOOD — targeted assertions
it('should display patient name and birthdate', () => {
render(<PatientCard patient={patient} />)
expect(screen.getByText('DUPONT JEAN')).toBeVisible()
expect(screen.getByText('15/03/1975')).toBeVisible()
})typescript
// 糟糕 — 无意义的整个组件快照
it('should render', () => {
const { container } = render(<PatientCard patient={patient} />)
expect(container).toMatchSnapshot() // 200行没人读的HTML
})
// 优秀 — 针对性断言
it('should display patient name and birthdate', () => {
render(<PatientCard patient={patient} />)
expect(screen.getByText('DUPONT JEAN')).toBeVisible()
expect(screen.getByText('15/03/1975')).toBeVisible()
})7. Testing Checklist
7. 测试检查清单
Before submitting a PR, verify:
[ ] New feature has tests covering happy path and error cases
[ ] Tests are independent — can run in any order
[ ] No flaky tests introduced (run suite 3x to verify)
[ ] Test names describe behavior, not implementation
[ ] Mocks are limited to external boundaries
[ ] No hardcoded test data — using factories
[ ] Test utilities are shared, not duplicated
[ ] Integration tests clean up after themselves
[ ] E2E tests use data-testid selectors
[ ] Coverage hasn't decreased提交PR前,请确认:
[ ] 新功能有覆盖正常路径和错误场景的测试
[ ] 测试是独立的 — 可以按任意顺序运行
[ ] 没有引入不稳定测试(运行测试套件3次验证)
[ ] 测试名称描述行为,而非实现细节
[ ] Mock仅用于外部边界依赖
[ ] 没有硬编码测试数据 — 使用工厂函数
[ ] 测试工具是共享的,没有重复定义
[ ] 集成测试会自行清理产生的数据
[ ] E2E测试使用data-testid选择器
[ ] 测试覆盖率没有下降Framework Quick Reference
框架快速参考
| Framework | Best For | Command |
|---|---|---|
| Vitest | Unit + integration (Vite projects) | |
| Jest | Unit + integration (legacy, CRA) | |
| Playwright | E2E, visual regression | |
| Cypress | E2E, component testing | |
| Testing Library | DOM testing (React, Vue) | Used with Vitest/Jest |
| MSW | HTTP mocking | Used with any runner |
| 框架 | 最佳适用场景 | 命令 |
|---|---|---|
| Vitest | 单元+集成测试(Vite项目) | |
| Jest | 单元+集成测试(遗留项目、CRA) | |
| Playwright | E2E、视觉回归测试 | |
| Cypress | E2E、组件测试 | |
| Testing Library | DOM测试(React、Vue) | 和Vitest/Jest搭配使用 |
| MSW | HTTP Mock | 可搭配任意测试运行器使用 |