はじめに

テストは、ソフトウェアの品質を担保する上で欠かせない要素です。しかし、既存コードに対するテストの追加や、テストケースの網羅性向上は、開発者にとって時間のかかる作業です。特にレガシーコードへのテスト追加は、コードの理解から始める必要があり、工数がかさみがちです。

OpenAI Codexは、このテスト作成プロセスを大幅に効率化できます。コードベースを分析し、テストすべきケースを自動的に特定し、適切なテストコードを生成します。さらに、生成したテストを実行して検証し、失敗した場合は自動的に修正を試みます。

本記事では、Codexを活用してテストカバレッジを効率的に向上させ、コード品質を継続的に改善する方法を解説します。

前提条件

本記事の内容を実践するには、以下の準備が必要です。

要件 詳細
ChatGPTプラン Plus、Pro、Business、Edu、Enterpriseのいずれか
GitHub連携 Codexとの接続設定が完了していること
環境設定 対象リポジトリの環境が作成済みであること
テストフレームワーク Jest、Vitest、Pytestなどが導入済みであること

基本操作やAGENTS.mdの設定については、シリーズの前回までの記事を参照してください。

テストカバレッジ向上におけるCodexの活用

Codexがテスト作成で力を発揮するのは、以下のような場面です。

flowchart TD
    A[カバレッジ分析] --> B[テスト不足の特定]
    B --> C[テストケース設計]
    C --> D[テストコード生成]
    D --> E[テスト実行]
    E --> F{全テスト成功?}
    F -->|No| G[失敗原因分析]
    G --> H[テスト修正]
    H --> E
    F -->|Yes| I[カバレッジ確認]
    I --> J{目標達成?}
    J -->|No| B
    J -->|Yes| K[PR作成]

従来の手動テスト作成と比較した場合、Codexには以下のような優位性があります。

観点 手動テスト作成 Codex
実装理解 コードリーディングが必要 自動でコード分析
ケース設計 人間が網羅性を検討 境界値・異常系を自動特定
コード生成 手動で記述 パターンに沿って自動生成
検証 手動でテスト実行 自動実行・修正サイクル
保守 手動で更新 変更に追従したテスト更新

ユニットテストの自動生成

テスト対象の分析

Codexにユニットテストの生成を依頼する際は、まず対象コードの分析から始まります。

プロンプト例:特定ファイルへのテスト追加

src/services/OrderService.ts に対するユニットテストを作成してください。

要件:
- すべてのpublicメソッドをテスト対象とする
- 正常系・異常系・境界値のテストケースを含める
- 外部依存(リポジトリ、外部API)はモック化する
- カバレッジ80%以上を目標とする

Codexは対象ファイルを読み込み、以下の観点で分析します。

  • メソッドのシグネチャと戻り値
  • 条件分岐とエッジケース
  • 外部依存とモック対象
  • エラーハンドリングパターン

テストケースの自動特定

Codexは、コードの構造を分析して、必要なテストケースを自動的に特定します。

分析レポートの例

## OrderService.ts 分析結果

### createOrder メソッド
- 入力検証: orderId, userId, items が必須
- 条件分岐: 
  - items が空の場合 → ValidationError
  - 在庫不足の場合 → InsufficientStockError
  - 正常時 → Order オブジェクトを返却
- 外部依存:
  - InventoryRepository.checkStock()
  - OrderRepository.save()
  - PaymentService.processPayment()

### テストケース一覧
1. 正常系: 有効な注文データで注文作成
2. 異常系: items が空の場合にエラー
3. 異常系: 在庫不足の場合にエラー
4. 異常系: 決済失敗時のロールバック
5. 境界値: 注文上限数での動作確認

生成されるテストコードの例

Codexは分析結果に基づいて、以下のようなテストコードを生成します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// src/services/__tests__/OrderService.test.ts

import { OrderService } from '../OrderService';
import { InventoryRepository } from '../../repositories/InventoryRepository';
import { OrderRepository } from '../../repositories/OrderRepository';
import { PaymentService } from '../PaymentService';
import { ValidationError, InsufficientStockError } from '../../errors';

jest.mock('../../repositories/InventoryRepository');
jest.mock('../../repositories/OrderRepository');
jest.mock('../PaymentService');

describe('OrderService', () => {
  let orderService: OrderService;
  let mockInventoryRepo: jest.Mocked<InventoryRepository>;
  let mockOrderRepo: jest.Mocked<OrderRepository>;
  let mockPaymentService: jest.Mocked<PaymentService>;

  beforeEach(() => {
    mockInventoryRepo = new InventoryRepository() as jest.Mocked<InventoryRepository>;
    mockOrderRepo = new OrderRepository() as jest.Mocked<OrderRepository>;
    mockPaymentService = new PaymentService() as jest.Mocked<PaymentService>;
    
    orderService = new OrderService(
      mockInventoryRepo,
      mockOrderRepo,
      mockPaymentService
    );
  });

  describe('createOrder', () => {
    const validOrderData = {
      userId: 'user-123',
      items: [
        { productId: 'prod-1', quantity: 2 },
        { productId: 'prod-2', quantity: 1 }
      ]
    };

    describe('正常系', () => {
      it('有効なデータで注文を作成できる', async () => {
        mockInventoryRepo.checkStock.mockResolvedValue(true);
        mockPaymentService.processPayment.mockResolvedValue({ 
          transactionId: 'tx-123' 
        });
        mockOrderRepo.save.mockResolvedValue({ 
          id: 'order-123', 
          ...validOrderData 
        });

        const result = await orderService.createOrder(validOrderData);

        expect(result.id).toBe('order-123');
        expect(mockInventoryRepo.checkStock).toHaveBeenCalledWith(
          validOrderData.items
        );
        expect(mockPaymentService.processPayment).toHaveBeenCalled();
        expect(mockOrderRepo.save).toHaveBeenCalled();
      });
    });

    describe('異常系', () => {
      it('itemsが空の場合はValidationErrorをスローする', async () => {
        const emptyItemsData = { ...validOrderData, items: [] };

        await expect(
          orderService.createOrder(emptyItemsData)
        ).rejects.toThrow(ValidationError);
        
        expect(mockInventoryRepo.checkStock).not.toHaveBeenCalled();
      });

      it('在庫不足の場合はInsufficientStockErrorをスローする', async () => {
        mockInventoryRepo.checkStock.mockResolvedValue(false);

        await expect(
          orderService.createOrder(validOrderData)
        ).rejects.toThrow(InsufficientStockError);
        
        expect(mockPaymentService.processPayment).not.toHaveBeenCalled();
      });

      it('決済失敗時は注文を保存しない', async () => {
        mockInventoryRepo.checkStock.mockResolvedValue(true);
        mockPaymentService.processPayment.mockRejectedValue(
          new Error('Payment failed')
        );

        await expect(
          orderService.createOrder(validOrderData)
        ).rejects.toThrow('Payment failed');
        
        expect(mockOrderRepo.save).not.toHaveBeenCalled();
      });
    });

    describe('境界値', () => {
      it('注文上限数でも正常に処理される', async () => {
        const maxItems = Array(100).fill({ 
          productId: 'prod-1', 
          quantity: 1 
        });
        const maxOrderData = { ...validOrderData, items: maxItems };
        
        mockInventoryRepo.checkStock.mockResolvedValue(true);
        mockPaymentService.processPayment.mockResolvedValue({ 
          transactionId: 'tx-123' 
        });
        mockOrderRepo.save.mockResolvedValue({ 
          id: 'order-123', 
          ...maxOrderData 
        });

        const result = await orderService.createOrder(maxOrderData);

        expect(result.id).toBe('order-123');
      });

      it('注文上限数を超えるとエラーになる', async () => {
        const overMaxItems = Array(101).fill({ 
          productId: 'prod-1', 
          quantity: 1 
        });
        const overMaxData = { ...validOrderData, items: overMaxItems };

        await expect(
          orderService.createOrder(overMaxData)
        ).rejects.toThrow(ValidationError);
      });
    });
  });
});

テストケースの網羅性向上

カバレッジレポートの活用

既存のテストがある場合、Codexにカバレッジレポートを分析させて、テストが不足している箇所を特定できます。

プロンプト例:カバレッジギャップの特定

現在のテストカバレッジレポートを分析し、
カバレッジが低いファイル・関数を特定してください。

優先度の基準:
1. ビジネスロジックを含むサービス層
2. 複雑な条件分岐を持つ関数
3. エラーハンドリングが多い箇所

カバレッジが60%未満のファイルから順に
テストを追加してください。

AGENTS.mdでのテスト戦略定義

プロジェクト全体で一貫したテスト戦略を適用するために、AGENTS.mdにテスト要件を記述します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# AGENTS.md

## テスト戦略

### カバレッジ目標
| レイヤー | 最低カバレッジ | 推奨カバレッジ |
|---------|--------------|--------------|
| サービス層 | 80% | 90% |
| リポジトリ層 | 70% | 80% |
| コントローラー層 | 60% | 70% |
| ユーティリティ | 90% | 100% |

### 必須テストパターン
すべてのテストに以下のパターンを含めること:

1. **Happy Path**: 正常系の基本動作
2. **Validation**: 入力値検証のエッジケース
3. **Error Handling**: 例外発生時の動作
4. **Boundary**: 境界値での動作

### モック方針
- 外部API呼び出しは必ずモック化
- データベースアクセスはリポジトリ層でモック
- 時間依存の処理は `jest.useFakeTimers()` を使用

### テストデータ
- テストフィクスチャは `__fixtures__/` に配置
- ファクトリ関数で生成(例: `createTestUser()`- 本番データは絶対に使用しない

### 命名規則
- テストファイル: `*.test.ts` または `*.spec.ts`
- describe: テスト対象のクラス/関数名
- it: 日本語で期待動作を記述

網羅性を高めるプロンプト技法

Codexにより網羅的なテストを生成させるための効果的なプロンプト技法を紹介します。

技法1:ネガティブテストの明示的要求

以下のユーザー登録機能に対して、
特に異常系・エラーケースに焦点を当てたテストを作成してください。

対象: src/services/UserRegistrationService.ts

考慮すべき異常系:
- 無効なメールアドレス形式
- パスワード強度不足
- 既存ユーザーとの重複
- データベース接続エラー
- 外部認証サービスのタイムアウト
- 同時登録によるレースコンディション

技法2:プロパティベーステストの生成

src/utils/validation.ts の validateEmail 関数に対して、
プロパティベーステストを作成してください。

検証すべきプロパティ:
- 有効なメールは常に true を返す
- @ がない文字列は常に false を返す
- 空文字列は false を返す
- ドメイン部分がない場合は false を返す

fast-check ライブラリを使用してください。

生成例:プロパティベーステスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import * as fc from 'fast-check';
import { validateEmail } from '../validation';

describe('validateEmail - プロパティベーステスト', () => {
  it('有効なメールアドレス形式は常にtrueを返す', () => {
    const emailArbitrary = fc.tuple(
      fc.string({ minLength: 1, maxLength: 64 })
        .filter(s => /^[a-zA-Z0-9._%+-]+$/.test(s)),
      fc.string({ minLength: 1, maxLength: 253 })
        .filter(s => /^[a-zA-Z0-9.-]+$/.test(s))
    ).map(([local, domain]) => `${local}@${domain}.com`);

    fc.assert(
      fc.property(emailArbitrary, (email) => {
        return validateEmail(email) === true;
      })
    );
  });

  it('@を含まない文字列は常にfalseを返す', () => {
    fc.assert(
      fc.property(
        fc.string().filter(s => !s.includes('@')),
        (str) => validateEmail(str) === false
      )
    );
  });

  it('空文字列はfalseを返す', () => {
    expect(validateEmail('')).toBe(false);
  });
});

統合テストの作成支援

統合テストの設計方針

ユニットテストがモジュール単体の動作を検証するのに対し、統合テストは複数のモジュールが連携して正しく動作することを検証します。Codexは、コードベースの依存関係を分析し、適切な統合テストを生成できます。

プロンプト例:統合テストの作成

注文処理フローの統合テストを作成してください。

対象フロー:
1. POST /api/orders で注文作成
2. 在庫の確認と引き当て
3. 決済処理
4. 注文確認メールの送信
5. 注文ステータスの更新

要件:
- 実際のデータベースを使用(テスト用DB)
- 外部決済APIはモック化
- メール送信はモック化
- トランザクションの整合性を検証

生成される統合テストの例

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
// src/integration/__tests__/orderFlow.test.ts

import request from 'supertest';
import { app } from '../../app';
import { prisma } from '../../lib/prisma';
import { PaymentGateway } from '../../services/PaymentGateway';
import { EmailService } from '../../services/EmailService';

jest.mock('../../services/PaymentGateway');
jest.mock('../../services/EmailService');

describe('注文処理フロー統合テスト', () => {
  const mockPaymentGateway = PaymentGateway as jest.Mocked<typeof PaymentGateway>;
  const mockEmailService = EmailService as jest.Mocked<typeof EmailService>;

  beforeAll(async () => {
    await prisma.$connect();
  });

  afterAll(async () => {
    await prisma.$disconnect();
  });

  beforeEach(async () => {
    // テストデータのセットアップ
    await prisma.product.createMany({
      data: [
        { id: 'prod-1', name: 'テスト商品1', stock: 10, price: 1000 },
        { id: 'prod-2', name: 'テスト商品2', stock: 5, price: 2000 }
      ]
    });
    
    await prisma.user.create({
      data: { id: 'user-1', email: 'test@example.com', name: 'テストユーザー' }
    });

    jest.clearAllMocks();
  });

  afterEach(async () => {
    // テストデータのクリーンアップ
    await prisma.orderItem.deleteMany();
    await prisma.order.deleteMany();
    await prisma.product.deleteMany();
    await prisma.user.deleteMany();
  });

  describe('正常系フロー', () => {
    it('注文が正常に処理され、在庫が減少する', async () => {
      // 決済モックの設定
      mockPaymentGateway.processPayment.mockResolvedValue({
        transactionId: 'tx-123',
        status: 'completed'
      });
      mockEmailService.sendOrderConfirmation.mockResolvedValue(undefined);

      // 注文リクエスト
      const response = await request(app)
        .post('/api/orders')
        .set('Authorization', 'Bearer valid-token')
        .send({
          userId: 'user-1',
          items: [
            { productId: 'prod-1', quantity: 2 },
            { productId: 'prod-2', quantity: 1 }
          ]
        });

      // レスポンスの検証
      expect(response.status).toBe(201);
      expect(response.body.order).toMatchObject({
        userId: 'user-1',
        status: 'confirmed',
        totalAmount: 4000  // (1000 * 2) + (2000 * 1)
      });

      // 在庫の検証
      const product1 = await prisma.product.findUnique({ 
        where: { id: 'prod-1' } 
      });
      const product2 = await prisma.product.findUnique({ 
        where: { id: 'prod-2' } 
      });
      expect(product1?.stock).toBe(8);  // 10 - 2
      expect(product2?.stock).toBe(4);  // 5 - 1

      // 決済呼び出しの検証
      expect(mockPaymentGateway.processPayment).toHaveBeenCalledWith({
        amount: 4000,
        userId: 'user-1',
        orderId: expect.any(String)
      });

      // メール送信の検証
      expect(mockEmailService.sendOrderConfirmation).toHaveBeenCalledWith({
        email: 'test@example.com',
        orderId: expect.any(String),
        items: expect.any(Array)
      });
    });
  });

  describe('異常系フロー', () => {
    it('在庫不足時はロールバックされる', async () => {
      const response = await request(app)
        .post('/api/orders')
        .set('Authorization', 'Bearer valid-token')
        .send({
          userId: 'user-1',
          items: [
            { productId: 'prod-1', quantity: 20 }  // 在庫10に対して20を注文
          ]
        });

      expect(response.status).toBe(400);
      expect(response.body.error).toBe('Insufficient stock');

      // 在庫が変更されていないことを確認
      const product = await prisma.product.findUnique({ 
        where: { id: 'prod-1' } 
      });
      expect(product?.stock).toBe(10);

      // 注文が作成されていないことを確認
      const orders = await prisma.order.findMany({
        where: { userId: 'user-1' }
      });
      expect(orders).toHaveLength(0);
    });

    it('決済失敗時はトランザクションがロールバックされる', async () => {
      mockPaymentGateway.processPayment.mockRejectedValue(
        new Error('Payment declined')
      );

      const response = await request(app)
        .post('/api/orders')
        .set('Authorization', 'Bearer valid-token')
        .send({
          userId: 'user-1',
          items: [{ productId: 'prod-1', quantity: 1 }]
        });

      expect(response.status).toBe(500);

      // 在庫が変更されていないことを確認
      const product = await prisma.product.findUnique({ 
        where: { id: 'prod-1' } 
      });
      expect(product?.stock).toBe(10);

      // メールが送信されていないことを確認
      expect(mockEmailService.sendOrderConfirmation).not.toHaveBeenCalled();
    });
  });
});

E2Eテストの作成支援

E2Eテストの位置づけ

E2E(End-to-End)テストは、ユーザーの視点からアプリケーション全体の動作を検証します。Codexは、ユーザーストーリーやシナリオからE2Eテストを生成できます。

flowchart LR
    subgraph テストピラミッド
        A[E2Eテスト] --> B[統合テスト]
        B --> C[ユニットテスト]
    end
    
    subgraph 特性
        D[実行時間: 長い<br/>信頼性: 低い<br/>デバッグ: 難しい]
        E[実行時間: 中程度<br/>信頼性: 中程度<br/>デバッグ: 中程度]
        F[実行時間: 短い<br/>信頼性: 高い<br/>デバッグ: 容易]
    end
    
    A -.-> D
    B -.-> E
    C -.-> F

Playwrightを使用したE2Eテスト生成

プロンプト例:E2Eテストの作成

以下のユーザーストーリーに基づいて、
Playwrightを使用したE2Eテストを作成してください。

ユーザーストーリー:
「ユーザーとして、商品を検索し、カートに追加し、
決済を完了して注文を確定したい」

シナリオ:
1. トップページにアクセス
2. 検索バーに「ノートパソコン」と入力
3. 検索結果から最初の商品をクリック
4. 「カートに追加」ボタンをクリック
5. カートページに遷移し、数量を確認
6. 「購入手続きへ」をクリック
7. 配送先情報を入力
8. 決済情報を入力
9. 注文を確定
10. 注文完了ページが表示されることを確認

生成されるE2Eテストの例

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
// e2e/tests/purchase-flow.spec.ts

import { test, expect } from '@playwright/test';

test.describe('商品購入フロー', () => {
  test.beforeEach(async ({ page }) => {
    // テストユーザーでログイン
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'test@example.com');
    await page.fill('[data-testid="password"]', 'password123');
    await page.click('[data-testid="login-button"]');
    await expect(page).toHaveURL('/');
  });

  test('商品検索からチェックアウトまでの完全フロー', async ({ page }) => {
    // 1. トップページにアクセス(beforeEachで完了)
    await expect(page.locator('[data-testid="search-bar"]')).toBeVisible();

    // 2. 検索バーに入力
    await page.fill('[data-testid="search-bar"]', 'ノートパソコン');
    await page.click('[data-testid="search-button"]');

    // 3. 検索結果ページで最初の商品をクリック
    await expect(page.locator('[data-testid="search-results"]')).toBeVisible();
    const firstProduct = page.locator('[data-testid="product-card"]').first();
    const productName = await firstProduct.locator('.product-name').textContent();
    await firstProduct.click();

    // 4. 商品詳細ページでカートに追加
    await expect(page.locator('[data-testid="product-detail"]')).toBeVisible();
    await page.click('[data-testid="add-to-cart"]');
    
    // カート追加成功のトースト確認
    await expect(page.locator('[data-testid="toast-success"]')).toHaveText(
      'カートに追加しました'
    );

    // 5. カートページに遷移して確認
    await page.click('[data-testid="cart-icon"]');
    await expect(page).toHaveURL('/cart');
    await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(1);
    await expect(page.locator('[data-testid="cart-item-name"]')).toHaveText(
      productName!
    );

    // 6. 購入手続きへ進む
    await page.click('[data-testid="checkout-button"]');
    await expect(page).toHaveURL('/checkout');

    // 7. 配送先情報を入力
    await page.fill('[data-testid="shipping-name"]', 'テスト 太郎');
    await page.fill('[data-testid="shipping-postal"]', '100-0001');
    await page.fill('[data-testid="shipping-address"]', '東京都千代田区1-1-1');
    await page.fill('[data-testid="shipping-phone"]', '03-1234-5678');
    await page.click('[data-testid="next-to-payment"]');

    // 8. 決済情報を入力
    await expect(page.locator('[data-testid="payment-form"]')).toBeVisible();
    
    // Stripe Elementsのiframe内に入力(テスト用カード)
    const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]');
    await stripeFrame.locator('[placeholder="カード番号"]').fill('4242424242424242');
    await stripeFrame.locator('[placeholder="MM / YY"]').fill('12/30');
    await stripeFrame.locator('[placeholder="CVC"]').fill('123');
    
    await page.click('[data-testid="confirm-order"]');

    // 9-10. 注文完了ページの確認
    await expect(page).toHaveURL(/\/orders\/[a-zA-Z0-9-]+\/complete/);
    await expect(page.locator('[data-testid="order-complete-message"]')).toHaveText(
      'ご注文ありがとうございます'
    );
    await expect(page.locator('[data-testid="order-number"]')).toBeVisible();
  });

  test('在庫切れ商品の購入を試みた場合', async ({ page }) => {
    // 在庫切れ商品ページに直接アクセス
    await page.goto('/products/out-of-stock-item');
    
    // カートに追加ボタンが無効化されていることを確認
    const addButton = page.locator('[data-testid="add-to-cart"]');
    await expect(addButton).toBeDisabled();
    await expect(page.locator('[data-testid="stock-status"]')).toHaveText(
      '在庫切れ'
    );
  });

  test('決済エラー時のリカバリーフロー', async ({ page }) => {
    // カートに商品がある状態でチェックアウトへ
    await page.goto('/cart');
    await page.click('[data-testid="checkout-button"]');
    
    // 配送先入力をスキップ(既存データ使用)
    await page.click('[data-testid="use-saved-address"]');
    await page.click('[data-testid="next-to-payment"]');
    
    // 決済エラーを発生させるテストカード
    const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]');
    await stripeFrame.locator('[placeholder="カード番号"]').fill('4000000000000002');
    await stripeFrame.locator('[placeholder="MM / YY"]').fill('12/30');
    await stripeFrame.locator('[placeholder="CVC"]').fill('123');
    
    await page.click('[data-testid="confirm-order"]');
    
    // エラーメッセージの確認
    await expect(page.locator('[data-testid="payment-error"]')).toHaveText(
      'カードが拒否されました。別のカードをお試しください。'
    );
    
    // 別のカードで再試行
    await stripeFrame.locator('[placeholder="カード番号"]').clear();
    await stripeFrame.locator('[placeholder="カード番号"]').fill('4242424242424242');
    await page.click('[data-testid="confirm-order"]');
    
    // 成功を確認
    await expect(page).toHaveURL(/\/orders\/[a-zA-Z0-9-]+\/complete/);
  });
});

AGENTS.mdでのE2Eテスト設定

E2Eテストの一貫性を保つため、AGENTS.mdに設定を記述します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# AGENTS.md

## E2Eテスト設定

### フレームワーク
- Playwright を使用
- ブラウザ: chromium, firefox, webkit

### テストデータ
- テスト用ユーザー: test@example.com / password123
- テスト用決済: Stripeテストカード
- データベース: テスト用DBを使用(本番とは分離)

### セレクター規約
- data-testid 属性を使用
- 命名: `[機能]-[要素]` 形式(例: `cart-item`, `checkout-button`
### 待機戦略
- 明示的な待機を優先(expect + toBeVisible)
- 固定時間の wait() は使用禁止
- ネットワークリクエストの完了を待つ場合は waitForResponse

### スクリーンショット
- 失敗時は自動でスクリーンショットを保存
- 保存先: e2e/screenshots/

テスト失敗時の修正依頼フロー

自動修正サイクル

Codexは、テストが失敗した場合に自動的に原因を分析し、修正を試みます。

flowchart TD
    A[テスト実行] --> B{テスト結果}
    B -->|成功| C[完了]
    B -->|失敗| D[失敗原因分析]
    D --> E{原因の種類}
    E -->|テストコードの問題| F[テストを修正]
    E -->|実装コードの問題| G[実装を修正]
    E -->|環境の問題| H[環境設定を確認]
    F --> A
    G --> A
    H --> I[人間に報告]

失敗時のCodexの対応

テストが失敗した場合、Codexは以下のような分析レポートを生成します。

## テスト失敗レポート

### 失敗したテスト
OrderService.test.ts > createOrder > 正常系 > 有効なデータで注文を作成できる

### エラー内容

AssertionError: expect(received).toBe(expected) Expected: “order-123” Received: undefined


### 原因分析
`mockOrderRepo.save` のモック設定が不完全です。
モックが返すオブジェクトに `id` プロパティが含まれていますが、
実際の実装では非同期で `id` が生成されるため、
モックのタイミングが合っていません。

### 修正提案

【修正箇所】
~~~diff
- mockOrderRepo.save.mockResolvedValue({ 
-   id: 'order-123', 
-   ...validOrderData 
- });
+ mockOrderRepo.save.mockImplementation(async (orderData) => ({
+   id: 'order-123',
+   ...orderData,
+   createdAt: new Date()
+ }));

検証

修正を適用してテストを再実行します…

[テスト再実行] npm test – OrderService.test.ts 結果: PASS


### フォローアップタスクの依頼

テスト失敗の修正が複雑な場合や、人間の判断が必要な場合、Codexはフォローアップを提案します。

**プロンプト例:フォローアップ依頼**

テスト失敗の原因を分析しましたが、 以下の点について人間の判断が必要です:

  1. PaymentService のモックが実際の動作と異なる可能性があります。 本番環境での決済フローを確認してください。

  2. テストデータの前提条件が変更されている可能性があります。 最新の仕様書を確認してください。

確認後、以下のコマンドでテストを再実行できます: npm test – –testPathPattern=PaymentService


### CI/CDパイプラインとの連携

Codex GitHub Actionを使用して、CI/CDパイプライン内でテスト失敗時の自動修正を試みることができます。

~~~yaml
# .github/workflows/test-and-fix.yml

name: Test and Auto-Fix

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          
      - name: Install dependencies
        run: npm ci
        
      - name: Run tests
        id: test
        run: npm test -- --coverage --json --outputFile=test-results.json
        continue-on-error: true
        
      - name: Analyze test failures with Codex
        if: steps.test.outcome == 'failure'
        uses: openai/codex-action@v1
        with:
          prompt: |
            テストが失敗しました。
            test-results.json を分析し、
            修正可能な問題を自動的に修正してください。
            
            修正後はテストを再実行して検証してください。
          github-token: ${{ secrets.GITHUB_TOKEN }}
          codex-token: ${{ secrets.CODEX_TOKEN }}

カバレッジ目標の継続的な改善

カバレッジ監視ダッシュボード

プロジェクトのテストカバレッジを継続的に監視し、目標に向けて改善を進めます。

プロンプト例:カバレッジ改善計画

現在のテストカバレッジを分析し、
3か月以内に以下の目標を達成する計画を立ててください。

目標:
- 全体カバレッジ: 60% → 80%
- サービス層: 70% → 90%
- クリティカルパス: 80% → 95%

制約:
- 既存の開発スプリントに影響を与えない
- 週あたり最大10時間のテスト作成工数
- 優先度の高い機能から着手

段階的なカバレッジ向上

Codexを活用した段階的なカバレッジ向上のアプローチを紹介します。

フェーズ1:クリティカルパスのカバー(1-2週間)

以下のクリティカルパス(ビジネス上最重要な機能)に対して、
テストを追加してください。

クリティカルパス:
1. ユーザー認証フロー
2. 決済処理フロー
3. 注文管理フロー

各フローで最低3つのテストケースを作成:
- 正常系
- 主要な異常系
- 境界値

フェーズ2:レガシーコードのテスト追加(3-4週間)

以下のレガシーモジュールにテストを追加してください。

対象:
- src/legacy/billing/*.ts
- src/legacy/inventory/*.ts

注意事項:
- 既存の動作を変更しない
- リファクタリングは行わず、現状の動作をテストで固定する
- 将来のリファクタリングに備えた特性テストを作成

フェーズ3:カバレッジギャップの解消(5-8週間)

カバレッジレポートを分析し、
50%未満のファイルを優先的にカバーしてください。

除外対象:
- 生成されたコード(*.generated.ts)
- 型定義ファイル(*.d.ts)
- 設定ファイル(*.config.ts)

まとめ

本記事では、OpenAI Codexを活用してテストカバレッジを向上させる方法を解説しました。

Codexによるテスト自動化の主なメリット

観点 効果
時間短縮 テスト作成時間を50-70%削減
網羅性 人間が見落としがちなエッジケースを自動検出
一貫性 AGENTS.mdによる品質基準の統一
継続性 CI/CD連携による自動的なカバレッジ維持

実践のポイント

  1. AGENTS.mdにテスト戦略を明記する:カバレッジ目標、命名規則、モック方針を定義
  2. 段階的にカバレッジを向上させる:クリティカルパスから着手し、徐々に範囲を拡大
  3. 自動修正サイクルを活用する:テスト失敗時の自動分析と修正で効率化
  4. CI/CDと連携する:継続的なカバレッジ監視と品質維持

次回の記事では、Codexを活用した新機能の実装について、機能開発の非同期委任の実践方法を解説します。

参考リンク