🌳
APIテストの実践ルール - Playwright + TypeScript
はじめに
この記事では、Playwright + TypeScriptを使ったAPIテストの実践的なルールを紹介します。実際のプロジェクトで採用しているパターンをベースに、APIテストの設計思想から具体的な実装まで解説します。
1. 目的と前提
テストの目的
- HTTP境界での契約を担保する: ルーティング・認証・入出力・ステータスコードの正しさを確認。
- ビジネスロジックはサーバ側でテスト: ドメイン層・ユースケース層の詳細なロジックはユニットテストで担保。
- TDDの足場として機能: APIテストは開発を回すための最小限の確認に留める。
テスト哲学
APIテストは「このHTTPリクエストに対して、このレスポンスが返る」という契約の確認に特化します。
- ✅ やること: エンドポイントが正しく動作するかの確認
- ❌ やらないこと: ドメインロジックの全パターン網羅
2. ツールと基本方針(Playwright + TypeScript)
なぜPlaywrightか
- 実装非依存: フレームワークやORMに依存せず、純粋なHTTP通信をテスト。
- APIRequestContext: UIテスト機能を使わず、API専用の機能で完結。
- TypeScript完全対応: 型安全なテストコードを書ける。
設定例
playwright.config.ts(実践例)
import { defineConfig } from '@playwright/test'
import { config } from 'dotenv'
config()
export default defineConfig({
testDir: 'tests',
forbidOnly: !!process.env.CI,
fullyParallel: !!process.env.PARALLEL, // 環境変数で並列制御
retries: process.env.CI ? 2 : 0,
timeout: 15000, // 15秒(API通信を考慮)
reporter: process.env.CI ? 'html' : 'line',
use: {
trace: process.env.CI ? 'on-first-retry' : 'on',
baseURL: process.env.API_BASE_URL ?? 'http://localhost:8787',
},
})
ポイント:
fullyParallel
: 環境変数で制御(ローカルではシーケンシャル、CIでは並列など)timeout
: API通信を考慮して15秒に設定baseURL
: 環境変数で切り替え可能
3. ディレクトリ構造規約
基本原則: ミラー配置
ルート構造 = テスト構造とすることで、探索性と保守性を向上させます。
命名規則
- HTTPメソッドごとにファイル分割:
get.spec.ts
,post.spec.ts
,put.spec.ts
,delete.spec.ts
- パス変数はディレクトリ名に:
{id}
→_id/
,{date}
→_date/
- サブリソースはネスト:
/entries/{id}/comments
→entries/_id/comments/
マッピング例
GET /ping → tests/ping/get.spec.ts
GET /api/admin/v1/journal-entries/{date} → tests/api/admin/v1/journal-entries/_date/get.spec.ts
PUT /api/admin/v1/journal-entries/{date} → tests/api/admin/v1/journal-entries/_date/put.spec.ts
POST /api/admin/v1/lexical-item-requests → tests/api/admin/v1/lexical-item-requests/post.spec.ts
実際のディレクトリ構造
tests/
api/
admin/
v1/
journal-entries/
_date/
get.spec.ts # GET /api/admin/v1/journal-entries/{date}
put.spec.ts # PUT /api/admin/v1/journal-entries/{date}
feedback/
get.spec.ts # GET /api/admin/v1/journal-entries/{date}/feedback
post.spec.ts # POST /api/admin/v1/journal-entries/{date}/feedback
lexical-item-requests/
get.spec.ts # GET /api/admin/v1/lexical-item-requests
post.spec.ts # POST /api/admin/v1/lexical-item-requests
me/
lexical-items/
get.spec.ts # GET /api/admin/v1/me/lexical-items
practice-settings/
get.spec.ts # GET /api/admin/v1/me/practice-settings
ping/
get.spec.ts # GET /ping
メリット:
- エンドポイントからテストファイルが一意に特定できる
- APIルーティングの変更に強い(リファクタリング追従が容易)
- 新規エンドポイント追加時の配置が自明
4. スコープ設計:何をテストするか
✅ APIテストでやること
-
ルーティングの確認
- パスとHTTPメソッドが正しく機能する
-
ステータスコードの確認
- 成功時: 200, 201, 204など
- エラー時: 409(Conflict), 404(Not Found)など主要パターン
-
入出力の形式確認
- 必須フィールドの存在
- 型の正しさ(文字列、数値、boolean)
- 日時フォーマット(ISO8601など)
-
データの整合性確認
- PUT/POST → GET で作成/更新したデータが取得できる
- フィールドの値が期待通り
❌ APIテストでやらないこと
-
ドメインロジックの網羅的テスト
- 「この条件では409、別の条件では422」などの細かい分岐
- → サーバ側のユニットテストで担保
-
全エッジケースの探索
- バリデーションの全パターン
- ビジネスルールの境界値テスト
- → ドメイン層のテストで担保
-
認証フローの詳細
- トークンの生成・検証ロジック
- → 認証モジュールのユニットテストで担保
判定基準
APIテストに含めるべきか迷ったら:
- 「HTTPリクエスト/レスポンスの契約として重要か?」
- 「クライアント開発者が知るべき挙動か?」
→ YES なら APIテスト、NO ならサーバ側テスト
5. データ運用戦略
5.1 テストユーザーの管理
プロジェクトによって2つのアプローチがあります。
A. 事前準備型(推奨・本プロジェクト採用)
テストユーザーを事前にDBに登録し、テストごとに使い分けます。
命名規則:
{feature}_{operation}@example.com
例:
journal_entry_get@example.com
→ Journal Entry取得テスト用journal_entry_put_create@example.com
→ Journal Entry作成テスト用lexical_item_requests_post_exist@example.com
→ 既存単語リクエストテスト用
認証ヘルパー実装:
// scripts/api.ts
import type { APIRequestContext } from '@playwright/test'
export const getAuth = async (
request: APIRequestContext,
email: string,
sub: string, // UUIDv7: テスト用の固定ID
name?: string
): Promise<void> => {
const loginRes = await request.post('/api/test/login', {
headers: { 'Content-Type': 'application/json' },
data: { email, sub, name }
})
if (!loginRes.ok()) {
throw new Error(`Login failed: ${loginRes.status()}`)
}
}
テストでの使用例:
test('Success', async ({ request }) => {
await getAuth(
request,
'journal_entry_get@example.com',
'0198e4ca-4f39-7465-af56-d8554b60b9ba',
'JournalEntryGet'
)
const res = await request.get('/api/admin/v1/journal-entries/2025-08-15')
expect(res.status()).toBe(200)
})
メリット:
- テストの高速化(ユーザー作成不要)
- 認証フローの簡潔化
- ログでのトレーサビリティ(固定UUIDで追跡)
B. 動的生成型
テストごとにcrypto.randomUUID()
でユーザーを生成。
import crypto from 'node:crypto'
const userId = crypto.randomUUID()
const email = `e2e+${userId}@example.test`
const createRes = await request.post('/users', {
data: { id: userId, email, name: 'E2E User' }
})
メリット:
- 完全な独立性
- データ衝突の心配なし
デメリット:
- テスト実行時間が増加
- サーバ側でIDを受け入れる仕組みが必要
5.2 テストデータの原則
独立性
- テスト間でデータを共有しない
- テストの実行順序に依存しない設計
- 各テスト内で「作成→検証」を完結
固定値の使用
日付:
const date = '2025-08-15' // ✅ 固定の将来日付
// const date = new Date().toISOString() ❌ 現在時刻依存
リソース名:
data: { lexicalItem: 'exist' } // ✅ 意図が明確
data: { lexicalItem: 'requested' } // ✅ テストシナリオが自明
最小フィールド
テストに必要な項目のみ設定。
// ✅ Good: 必要な項目のみ
data: {
title: 'First title',
contentJa: 'はじめての日本語',
contentEn: 'My first English',
revision: 0
}
// ❌ Avoid: 不要なデフォルト値
data: {
title: 'First title',
contentJa: 'はじめての日本語',
contentEn: 'My first English',
revision: 0,
tags: [], // 不要
metadata: {}, // 不要
createdBy: 'system' // 不要
}
5.3 並列実行への対応
- グローバル状態への依存を避ける
- 固定シードに依存しない
- 各テストで専用のユーザー・データを使用
- クリーンアップは基本不要(一意性で衝突回避)
5.4 日時フィールドの検証
ISO8601形式の形式チェックのみを行う。
const json = await res.json()
expect(json.journalEntry.updatedAt).toBe(expect.any(String))
// ISO8601形式の簡易検証
const updatedAt = json.journalEntry.updatedAt as string
expect(new Date(updatedAt).toString()).not.toBe('Invalid Date')
やらないこと:
- 具体的な時刻の検証(タイムゾーン・ミリ秒単位の検証は不安定)
- 時刻の前後関係(createdAt < updatedAt など)
6. 実践パターン集
6.1 シンプルなGETテスト
import { expect, test } from '@playwright/test'
test.describe('/ping GET', () => {
test('Pong', async ({ request }) => {
const res = await request.get('/ping')
expect(res.status()).toBe(200)
expect(await res.text()).toEqual('pong')
})
})
6.2 認証が必要なGETテスト
import { expect, test } from '@playwright/test'
import { getAuth } from '~/scripts/api'
test.describe('/api/admin/v1/journal-entries/{date} GET', () => {
test('Success', async ({ request }) => {
await getAuth(
request,
'journal_entry_get@example.com',
'0198e4ca-4f39-7465-af56-d8554b60b9ba',
'JournalEntryGet'
)
const date = '2025-08-15'
const res = await request.get(`/api/admin/v1/journal-entries/${date}`)
expect(res.status()).toBe(200)
const json = await res.json()
expect(json).toEqual({
journalEntry: {
date: '2025-08-15',
title: 'A day to remember',
contentJa: '今日はとても良い一日でした。',
contentEn: 'It was a really good day today.',
revision: 1,
updatedAt: expect.any(String),
feedbackStatus: null
}
})
// ISO8601形式の確認
const updatedAt = json.journalEntry.updatedAt as string
expect(new Date(updatedAt).toString()).not.toBe('Invalid Date')
})
test('Success without title', async ({ request }) => {
await getAuth(
request,
'journal_entry_get@example.com',
'0198e4ca-4f39-7465-af56-d8554b60b9ba',
'JournalEntryGet'
)
const date = '2025-08-14'
const res = await request.get(`/api/admin/v1/journal-entries/${date}`)
expect(res.status()).toBe(200)
const json = await res.json()
expect(json.journalEntry.title).toBeNull()
})
})
6.3 PUT → GET の整合性確認
import { expect, test } from '@playwright/test'
import { getAuth } from '~/scripts/api'
test.describe('/api/admin/v1/journal-entries/{date} PUT', () => {
test('Create new when no entry exists', async ({ request }) => {
await getAuth(
request,
'journal_entry_put_create@example.com',
'0198e517-a2f7-746d-8bff-42947e78f270',
'JournalEntryPutCreate'
)
const date = '2025-08-21'
// PUT: 新規作成
const putRes = await request.put(`/api/admin/v1/journal-entries/${date}`, {
headers: { 'Content-Type': 'application/json' },
data: {
title: 'First title',
contentJa: 'はじめての日本語',
contentEn: 'My first English',
revision: 0
}
})
expect(putRes.status()).toBe(201)
// GET: 作成したデータを取得
const getRes = await request.get(`/api/admin/v1/journal-entries/${date}`)
expect(getRes.status()).toBe(200)
const json = await getRes.json()
expect(json).toEqual({
journalEntry: {
date: '2025-08-21',
title: 'First title',
contentJa: 'はじめての日本語',
contentEn: 'My first English',
revision: 0,
updatedAt: expect.any(String),
feedbackStatus: null
}
})
})
test('Update existing entry', async ({ request }) => {
await getAuth(
request,
'journal_entry_put_update@example.com',
'0198e517-cc53-710a-a3b4-c9ee95f8b4f6',
'JournalEntryPutUpdate'
)
const date = '2025-08-20'
// PUT: 更新
const putRes = await request.put(`/api/admin/v1/journal-entries/${date}`, {
headers: { 'Content-Type': 'application/json' },
data: {
title: 'Updated title',
contentJa: '更新された日本語',
contentEn: 'Updated English',
revision: 2
}
})
expect(putRes.status()).toBe(201)
// GET: 更新後のデータを取得
const getRes = await request.get(`/api/admin/v1/journal-entries/${date}`)
expect(getRes.status()).toBe(200)
const json = await getRes.json()
expect(json.journalEntry.title).toBe('Updated title')
expect(json.journalEntry.revision).toBe(2)
})
})
6.4 エラーケースのテスト
import { expect, test } from '@playwright/test'
import { getAuth } from '~/scripts/api'
test.describe('/api/admin/v1/lexical-item-requests POST', () => {
test('Success', async ({ request }) => {
await getAuth(
request,
'lexical_item_requests_post_exist@example.com',
'0198c48d-6d16-7210-9e5f-77db051d069a',
'LexicalItemRequestsPostExist'
)
const res = await request.post('/api/admin/v1/lexical-item-requests', {
headers: { 'Content-Type': 'application/json' },
data: { lexicalItem: 'exist' }
})
expect(res.status()).toBe(204)
})
test('Already requested - returns 409', async ({ request }) => {
await getAuth(
request,
'lexical_item_requests_post_requested@example.com',
'0198c65f-6b72-749e-bd0a-fdc2d597e5f7',
'LexicalItemRequestsPostRequested'
)
const res = await request.post('/api/admin/v1/lexical-item-requests', {
headers: { 'Content-Type': 'application/json' },
data: { lexicalItem: 'requested' }
})
expect(res.status()).toBe(409)
})
})
7. アンチパターン
❌ 1. ビジネスロジックの詳細テスト
// ❌ Avoid: APIテストでビジネスルールを網羅
test('Validation: title must be 1-100 chars', async ({ request }) => {
const res1 = await request.post('/api/entries', { data: { title: '' } })
expect(res1.status()).toBe(422)
const res2 = await request.post('/api/entries', { data: { title: 'a'.repeat(101) } })
expect(res2.status()).toBe(422)
})
// ✅ Good: サーバ側のユニットテストで検証
// tests/domain/journalEntry.spec.ts
test('Title validation', () => {
expect(() => JournalTitle.create('')).toThrow()
expect(() => JournalTitle.create('a'.repeat(101))).toThrow()
})
❌ 2. サーバ生成IDへの依存
// ❌ Avoid: サーバが生成したIDは特定できない
test('Create user', async ({ request }) => {
const res = await request.post('/users', { data: { email: 'test@example.com' } })
const json = await res.json()
// json.id は不明なので後続テストで使えない
})
// ✅ Good: テスト側でIDを生成(事前準備型)
test('Create user', async ({ request }) => {
await getAuth(request, 'user_create@example.com', '0198e4ca-...', 'UserCreate')
// 固定IDで後続検証も可能
})
// ✅ Good: テスト側でIDを生成(動的生成型)
test('Create user', async ({ request }) => {
const userId = crypto.randomUUID()
const res = await request.post('/users', {
data: { id: userId, email: `test+${userId}@example.com` }
})
// userIdで後続検証が可能
})
❌ 3. 現在時刻への依存
// ❌ Avoid: 現在時刻は不安定
const today = new Date().toISOString().split('T')[0]
const res = await request.get(`/api/entries/${today}`)
// ✅ Good: 固定日付を使用
const date = '2025-08-15'
const res = await request.get(`/api/entries/${date}`)
❌ 4. テスト間のデータ共有
// ❌ Avoid: 他のテストの作成物に依存
test('Get entry', async ({ request }) => {
// 別のテストが作成したデータを期待
const res = await request.get('/api/entries/2025-08-15')
expect(res.status()).toBe(200)
})
// ✅ Good: 各テスト内で作成→検証を完結
test('Get entry', async ({ request }) => {
// 事前準備されたテストユーザーのデータを使用
await getAuth(request, 'entry_get@example.com', '0198e4ca-...', 'EntryGet')
const res = await request.get('/api/entries/2025-08-15')
expect(res.status()).toBe(200)
})
8. まとめ
重要ポイント
- ミラー配置: エンドポイント構造 = テスト構造
- HTTPメソッドごとファイル分割:
get.spec.ts
,post.spec.ts
- 契約テストに徹する: ドメインロジックはサーバ側で
- テストユーザー事前準備: 高速化と可読性の向上
- 固定値を使用: 日付・リソース名は固定で再現性を確保
- 作成→取得の確認: PUT/POST後はGETで整合性確認
次のステップ
このルールをベースに、プロジェクトに合わせてカスタマイズしてください:
- 認証方式に応じたヘルパー関数の調整
- エラーレスポンスのスキーマ検証
- グローバルセットアップ/ティアダウンの実装
- CI/CDパイプラインへの統合
参考資料: