Portfolio logo
🌳

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. ディレクトリ構造規約

基本原則: ミラー配置

ルート構造 = テスト構造とすることで、探索性と保守性を向上させます。

命名規則

  1. HTTPメソッドごとにファイル分割: get.spec.ts, post.spec.ts, put.spec.ts, delete.spec.ts
  2. パス変数はディレクトリ名に: {id}_id/, {date}_date/
  3. サブリソースはネスト: /entries/{id}/commentsentries/_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テストでやること

  1. ルーティングの確認

    • パスとHTTPメソッドが正しく機能する
  2. ステータスコードの確認

    • 成功時: 200, 201, 204など
    • エラー時: 409(Conflict), 404(Not Found)など主要パターン
  3. 入出力の形式確認

    • 必須フィールドの存在
    • 型の正しさ(文字列、数値、boolean)
    • 日時フォーマット(ISO8601など)
  4. データの整合性確認

    • PUT/POST → GET で作成/更新したデータが取得できる
    • フィールドの値が期待通り

❌ APIテストでやらないこと

  1. ドメインロジックの網羅的テスト

    • 「この条件では409、別の条件では422」などの細かい分岐
    • → サーバ側のユニットテストで担保
  2. 全エッジケースの探索

    • バリデーションの全パターン
    • ビジネスルールの境界値テスト
    • → ドメイン層のテストで担保
  3. 認証フローの詳細

    • トークンの生成・検証ロジック
    • → 認証モジュールのユニットテストで担保

判定基準

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. まとめ

重要ポイント

  1. ミラー配置: エンドポイント構造 = テスト構造
  2. HTTPメソッドごとファイル分割: get.spec.ts, post.spec.ts
  3. 契約テストに徹する: ドメインロジックはサーバ側で
  4. テストユーザー事前準備: 高速化と可読性の向上
  5. 固定値を使用: 日付・リソース名は固定で再現性を確保
  6. 作成→取得の確認: PUT/POST後はGETで整合性確認

次のステップ

このルールをベースに、プロジェクトに合わせてカスタマイズしてください:

  • 認証方式に応じたヘルパー関数の調整
  • エラーレスポンスのスキーマ検証
  • グローバルセットアップ/ティアダウンの実装
  • CI/CDパイプラインへの統合

参考資料: