testing-patterns について
JavaScript と TypeScript の開発者向けテストパターンのスキルです。ユニット、統合、E2E の戦略に加え、モック、テスト整理、不安定なテストの防止方法を扱います。
主な機能
- 01ユニット・統合・E2Eテスト戦略
- 02AAA構成と説明的なテスト名
- 03モック・スタブ・スパイ
- 04テストデータファクトリーと整理
- 05不安定なテストの原因と対処
活用シーン
- 01プロジェクトのテスト戦略策定
- 02カバレッジと信頼性の向上
- 03モック対象の選定
- 04不安定または脆いテストの調査
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ dedalus-erp-pas/ foundation-skills --skill testing-patternsインストール後、Claude Code で/testing-patternsを入力して読み込まれたか確認します。
詳細
テストのパターンと戦略
JavaScript/TypeScript アプリケーションのテストに関する包括的なガイドです。ワークフローに重点を置く tdd スキルを補完し、具体的なパターン、戦略、アンチパターンを紹介します。
このスキルを使う場面
次のような場合に有効にしてください。
- 既存のコードや新しいコードのテストを書きたい
- プロジェクトのテスト戦略を確立したい
- テストカバレッジや信頼性を向上させたい
- 不安定なテストや壊れやすいテストがある
- モック、スタブ、テストダブルについて質問がある
- 単体テストと統合テスト、E2E テストについてガイダンスが必要
テストピラミッド
/ E2E \ Few, slow, high confidence
/----------\
/ Integration \ Moderate count, medium speed
/----------------\
/ Unit \ Many, fast, focused
/____________________\
目安: 単体テスト約70%、統合テスト約20%、E2E テスト約10%です。プロジェクトに合わせて調整してください。レガシーなコードベースでは、まず統合テストを増やすと効果的な場合があります。
1. 単体テストのパターン
テストの構成:AAA(Arrange、Act、Assert)
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)
})
})
命名規則
仕様として読める、説明的な名前を付けます。
// 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()')
パラメーター化テスト(テーブル駆動)
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)
})
})
エラーケースのテスト
失敗するケースも必ずテストします。
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)
})
})
非同期コードのテスト
// 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')
})
2. 統合テストのパターン
統合テストでは、コンポーネントが正しく連携することを確認します。
データベースの統合テスト
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',
})
})
})
API の統合テスト
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)
})
})
メッセージ処理の統合テスト
医療メッセージの処理(HPK、HL7)で特に重要です。
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 テストのパターン
E2E テストでは、実際のアプリケーションを通じたユーザーワークフロー全体を検証します。
ページオブジェクトパターン
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')
})
data-testid の規則
テストセレクターには data-testid 属性を使い、CSS クラスや要素構造は使わないでください。
// 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')
ビジュアルリグレッションテスト
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. モック戦略
モックを使う場面
| モックするもの | モックしないもの |
|---|---|
| 外部 HTTP API | 独自のデータベース(実際のテスト用 DB を使う) |
| 時刻/日付(決定性のため) | モジュール間の内部コラボレーター |
| ファイルシステム(実際に使うのが現実的でない場合) | 単純なユーティリティ関数 |
| サードパーティーサービス(決済、メール) | データ変換 |
| 環境固有の機能 | ビジネスロジック |
モックとスタブとスパイ
// 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),
}))
HTTP のモックには MSW を使う
手動での fetch のモックより、MSW(Mock Service Worker)を推奨します。
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())
時刻のモック
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')
})
})
5. テストの整理
ファイル構成 — 同じ場所に配置
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 objects
テストユーティリティ — ファクトリー
テストデータのコピー&ペーストは避け、ファクトリーを使います。
// 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)
})
6. よくあるアンチパターン
実装の詳細をテストする
// 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'))
})
過剰なモック
// 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()
})
不安定なテストのパターン
よくある原因と対処法:
| 原因 | 対処法 |
|---|---|
| 共有された変更可能な状態 | beforeEach を使って状態をリセットする |
| 時刻に依存するテスト | vi.useFakeTimers() を使う |
| 非同期テストでの競合状態 | getBy ではなく waitFor / findBy を使う |
| 実行順序に依存するテスト | テストごとに独立させる |
| 単体テストでのネットワーク呼び出し | MSW で HTTP をモックする |
| ポートのハードコード | 動的なポート割り当てを使う |
スナップショットの過剰な使用
// 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()
})
7. テストのチェックリスト
PR を提出する前に、次の点を確認してください。
[ ] 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
フレームワークのクイックリファレンス
| フレームワーク | 最適な用途 | コマンド |
|---|---|---|
| Vitest | 単体テスト+統合テスト(Vite プロジェクト) | vitest run |
| Jest | 単体テスト+統合テスト(レガシー、CRA) | jest --coverage |
| Playwright | E2E、ビジュアルリグレッション | playwright test |
| Cypress | E2E、コンポーネントテスト | cypress run |
| Testing Library | DOM テスト(React、Vue) | Vitest/Jest と併用 |
| MSW | HTTP のモック | 任意のテストランナーと併用 |
コメント
Markdown 対応