目次を表示する

仕様駆動開発(SDD)入門 ── AI時代の「正しい作り方」

実践ワークフロー ── 仕様→テスト→実装のループ

実践ワークフロー ── 仕様→テスト→実装のループ

この章では、SDDの実際のワークフローを手順ごとに追う。「仕様を書いてからAIに渡す」が具体的にどう機能するかを体験しよう。

SDDの全体フロー

flowchart TD
  A[仕様作成\nSPEC.md を書く] --> B[AIにSPEC.mdを渡す]
  B --> C[テスト生成\nAIがテストファイルを先に作る]
  C --> D[実装生成\nAIがテストをパスする実装を作る]
  D --> E{テスト実行}
  E -->|PASS| F[コードレビュー]
  E -->|FAIL| G{原因分析}
  G -->|仕様の問題| A
  G -->|実装の問題| D
  F --> H[マージ・デプロイ]

重要なのは「テスト生成を先に行う」点だ。テストが先に存在することで、実装が仕様を満たしているかどうかを機械的に検証できる。

ステップ1: SPEC.mdを書く

第5章のテンプレートを使い、SPEC.mdを作成する。

# プロジェクト構成
my-app/
├── CLAUDE.md          # プロジェクトコンテキスト(常駐)
├── specs/
   └── wishlist.md    # 今回のSPEC.md
├── src/
   ├── routes/
   ├── services/
   └── repositories/
└── tests/

ステップ2: AIにSPEC.mdを渡してテストを先に生成させる

Claude Codeを使った実際のやり取りの例:

User:
  @specs/wishlist.md を読んで、まずテストファイルだけ作って。
  実装はまだ書かないで。

  tests/wishlist.integration.test.ts に以下の統合テストを作成して:
  - 全エンドポイントの正常系
  - 異常系(401、404、409、バリデーションエラー)
  - パフォーマンステスト(100msチェック)

Claudeが生成するテストファイル(例):

// tests/wishlist.integration.test.ts
import request from 'supertest';
import app from '../src/app';
import { prisma } from '../src/lib/db';
import { generateToken } from '../src/utils/jwt';

describe('Wishlist API', () => {
  let authToken: string;
  let userId: string;

  beforeEach(async () => {
    // テスト用ユーザーとデータのセットアップ
    const user = await prisma.user.create({
      data: { email: '[email protected]', passwordHash: 'hash' },
    });
    userId = user.id;
    authToken = generateToken(user.id);
  });

  afterEach(async () => {
    await prisma.wishlistItem.deleteMany();
    await prisma.wishlist.deleteMany();
    await prisma.user.deleteMany();
  });

  // ✅ 正常系: 商品追加
  describe('POST /api/wishlist/items', () => {
    it('商品をウィッシュリストに追加できる', async () => {
      const product = await prisma.product.create({
        data: { name: 'テスト商品', price: 1000, imageUrl: 'https://example.com/img.jpg' },
      });

      const res = await request(app)
        .post('/api/wishlist/items')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ productId: product.id });

      expect(res.status).toBe(201);
      expect(res.body.data.productId).toBe(product.id);
    });

    it('未認証アクセスは401を返す', async () => {
      const res = await request(app)
        .post('/api/wishlist/items')
        .send({ productId: 'some-id' });

      expect(res.status).toBe(401);
      expect(res.body.error.code).toBe('UNAUTHORIZED');
    });

    it('100件超で409を返す', async () => {
      // 100件のアイテムを事前に作成
      const wishlist = await prisma.wishlist.create({ data: { userId } });
      await prisma.wishlistItem.createMany({
        data: Array.from({ length: 100 }, (_, i) => ({
          wishlistId: wishlist.id,
          productId: `product-${i}`,
        })),
      });

      const newProduct = await prisma.product.create({
        data: { name: '101番目', price: 100, imageUrl: 'https://example.com/img.jpg' },
      });

      const res = await request(app)
        .post('/api/wishlist/items')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ productId: newProduct.id });

      expect(res.status).toBe(409);
      expect(res.body.error.code).toBe('WISHLIST_LIMIT_EXCEEDED');
    });
  });

  // ✅ 正常系: ウィッシュリスト取得
  describe('GET /api/wishlist', () => {
    it('自分のウィッシュリストを取得できる', async () => {
      const start = Date.now();
      const res = await request(app)
        .get('/api/wishlist')
        .set('Authorization', `Bearer ${authToken}`);
      const elapsed = Date.now() - start;

      expect(res.status).toBe(200);
      expect(Array.isArray(res.body.data.items)).toBe(true);
      // パフォーマンス: 100ms以内
      expect(elapsed).toBeLessThan(100);
    });
  });

  // ✅ 共有URL
  describe('GET /api/wishlist/share/:token', () => {
    it('認証なしで共有ウィッシュリストを閲覧できる', async () => {
      const wishlist = await prisma.wishlist.create({
        data: { userId, isPublic: true, shareToken: 'test-token-123' },
      });

      const res = await request(app)
        .get(`/api/wishlist/share/test-token-123`);

      expect(res.status).toBe(200);
      expect(res.body.data.id).toBe(wishlist.id);
    });

    it('Privateリストは401を返す', async () => {
      await prisma.wishlist.create({
        data: { userId, isPublic: false, shareToken: 'private-token' },
      });

      const res = await request(app)
        .get(`/api/wishlist/share/private-token`);

      expect(res.status).toBe(403);
    });
  });
});

この時点でテストはすべてFAILする。それが正しい。実装がまだないからだ。

ステップ3: テストをパスする実装を生成させる

User:
  @specs/wishlist.md と tests/wishlist.integration.test.ts を読んで、
  テストが全てPASSするように実装して。

  生成するファイル:
  1. prisma/schema.prisma(wishlists, wishlist_items テーブル追加)
  2. src/routes/wishlist.ts
  3. src/services/wishlistService.ts
  4. src/repositories/wishlistRepository.ts

Claudeが生成する実装(一部抜粋):

// src/services/wishlistService.ts
import { WishlistRepository } from '../repositories/wishlistRepository';

const WISHLIST_LIMIT = 100;

export class WishlistService {
  constructor(private repo: WishlistRepository) {}

  async addItem(userId: string, productId: string) {
    const wishlist = await this.repo.findOrCreateByUserId(userId);

    const itemCount = await this.repo.countItems(wishlist.id);
    if (itemCount >= WISHLIST_LIMIT) {
      throw new WishlistLimitError('WISHLIST_LIMIT_EXCEEDED');
    }

    return this.repo.addItem(wishlist.id, productId);
  }

  async getWishlist(userId: string) {
    return this.repo.findByUserId(userId);
  }

  async getPublicWishlist(shareToken: string) {
    const wishlist = await this.repo.findByShareToken(shareToken);
    if (!wishlist) throw new NotFoundError('Wishlist not found');
    if (!wishlist.isPublic) throw new ForbiddenError('This wishlist is private');
    return wishlist;
  }
}

ステップ4: テストを実行して検証

npm test tests/wishlist.integration.test.ts

# 期待される出力:
# ✓ POST /api/wishlist/items > 商品をウィッシュリストに追加できる (45ms)
# ✓ POST /api/wishlist/items > 未認証アクセスは401を返す (12ms)
# ✓ POST /api/wishlist/items > 100件超で409を返す (89ms)
# ✓ GET /api/wishlist > 自分のウィッシュリストを取得できる (34ms)
# ✓ GET /api/wishlist/share/:token > 認証なしで共有ウィッシュリストを閲覧できる (28ms)
# ✓ GET /api/wishlist/share/:token > Privateリストは401を返す (15ms)
#
# Tests: 6 passed, 6 total
# Coverage: statements 87.3%, branches 82.1%, functions 91.2%, lines 86.8%

すべてPASSしたらSPEC.mdの Verification チェックボックスを確認する。

ステップ5: Verificationチェック

## Verification(更新後)
- [x] 統合テスト: 全エンドポイントの正常系・異常系がPASS ✓
- [x] 100件超で409エラーが返る ✓
- [x] 未認証アクセスで401が返る(共有URLを除く)✓
- [x] GET /api/wishlist のレスポンスタイムが100ms以内 ✓(34ms)
- [x] 共有URLから認証なしで閲覧できる ✓
- [x] カバレッジ: ビジネスロジック層 80%以上 ✓(87.3%)

全項目がチェックされたら、実装は完了だ。

FAILした時の対応

テストがFAILした場合の判断フローはこうだ。

flowchart TD
  F[テストFAIL] --> Q1{何が原因か?}
  Q1 -->|仕様の記述が曖昧だった| A[SPEC.mdを修正して再実行]
  Q1 -->|実装のバグ| B[AIに修正を依頼]
  Q1 -->|テスト自体の誤り| C[テストを修正]
  Q1 -->|仕様に書いてない前提条件がある| D[SPEC.mdに追記してAI再実行]

重要な原則:テストが失敗してもSPEC.mdを修正するのは「仕様に問題がある時だけ」だ。 「テストをパスさせるためにテストを甘くする」は仕様駆動開発の崩壊につながる。

BDDとの統合

より振る舞い重視の仕様を書く場合、SPEC.mdにGherkinシナリオを組み込める。

# Feature: ウィッシュリスト管理

## Scenarios

```gherkin
Feature: ウィッシュリスト管理

Scenario: 商品をウィッシュリストに追加する
  Given ログイン済みユーザーが存在する
  When 商品ID "prod-001" をウィッシュリストに追加する
  Then ウィッシュリストに商品が1件追加されている
  And レスポンスコードは201

Scenario: 上限を超えて追加しようとする
  Given ウィッシュリストに100件の商品がある
  When 101件目の商品を追加しようとする
  Then レスポンスコードは409
  And エラーコードは "WISHLIST_LIMIT_EXCEEDED"

これをCucumberと組み合わせると、Gherkinシナリオが直接テストになる。

## ループ回数の目安

仕様→テスト→実装のサイクルを何回回すか。

| 機能の複雑さ | 想定ループ回数 |
|------------|--------------|
| シンプルなCRUD | 1回(一発でPASS) |
| 複数テーブル参照 | 2〜3回 |
| 複雑なビジネスロジック | 3〜5回 |
| セキュリティ重要な機能 | 3〜5回(セキュリティレビュー込み) |

---

次の章では、SDDでよくやってしまう失敗パターンと、その脱出法を解説する。