AI開発 ·

AIに渡すSpecで決めること — 作らないもの・受け入れ条件・既存の形

  • AI駆動開発
  • 開発プロセス
  • Laravel

この記事で扱う問題

私は個人開発で、Spec → Plan → 人のレビュー → AI の実装、という流れで AI を使っています(チームの標準として導入した実績は、まだありません)。この流れの最初にある Spec が、一番手を抜きやすい段階です。

例えば、Spec に「未確定の注文をキャンセルできるようにする」とだけ書いて渡したとします。AI は動くコードを書きますが、書かれていない部分は自分で埋めます。

  • 確定済みの注文や、キャンセル済みの注文をもう一度キャンセルしたときにどうするかを、AI が決める
  • ついでにキャンセル理由の入力欄や、通知メールを足してくる
  • 既存の ConfirmOrder の形ではなく、一般的な Laravel の書き方(UseCase で Eloquent を直接使う)で書く

どれも、AI の側から見れば「書かれていないことを、もっともらしく補った」結果です。そして、実装の後のレビューでテストを受け入れ条件と対応させようとしても、Spec に受け入れ条件がなければ、照らし合わせる相手がありません。

問題は Spec の長さではなく、空白を誰が埋めるかです。人が決めるべき空白が残っていると、AI が決めます。この記事では、Spec で人が先に決めておくものと、Plan に任せてよいものの分け方をまとめます。

判断

Spec では、次の4つを決めます。

  1. 作らないもの:今回の範囲の外にあるもの。AI が気を利かせて足しそうなものほど書く
  2. 受け入れ条件:外から観察できる振る舞いとして。正常系より、異常系・境界・再送を厚く
  3. 従う既存の形:今回の変更が真似すべき既存のコードを名指しする
  4. 決めていないこと:まだ決めていない点と、変えてはいけないもの。ここに当たったら AI は止まって人に戻す

目的は1〜2文で足ります。どのファイルをどう変えるか、どのクラスを作るかは書きません。それは Plan で AI に出させ、人が確かめます。

例:注文のキャンセルの Spec

上の例を、4つを決めた Spec に書き直すと、次のようになります。

# 未確定の注文をキャンセルできるようにする

## 目的
利用者が、確定する前の自分の注文を取り消せるようにする。

## 作らないもの
- 確定済みの注文の取り消し(返品)。別の Spec で扱う
- キャンセル理由の入力と保存
- 管理者による代理のキャンセル
- キャンセルを知らせるメール

## 受け入れ条件
1. 未確定の注文をキャンセルすると 200 を返し、状態が cancelled になる
2. 確定済みの注文はキャンセルできない。409 を返し、状態は confirmed のまま
3. キャンセル済みの注文をもう一度キャンセルすると 409 を返す(再送でも成功として扱わない)
4. 他人の注文はキャンセルできない。403 を返し、状態は変わらない
5. 同じ注文へのほかの更新(明細の追加など)と同時に来ても、未確定ならキャンセルは成功する

## 従う既存の形
- 確定(ConfirmOrder と ConfirmOrderController)と同じ形にする
- どの状態からキャンセルできるかは、確定(OrderStatus::canBeConfirmed() と Order::confirm())と同じく OrderStatus と Order に置く
- 認可は FormRequest の authorize() から OrderPolicy を呼ぶ
- ドメインの例外は UseCase で catch しない(例外ハンドラで 409 に変換している)
- 保存の衝突は、確定と同じく Controller で RetryOnConcurrentModification に包む

## 決めていないこと・変えてはいけないもの
- キャンセルした日時を記録するかは未定。記録しない前提で作り、記録が要るかは Plan で人に確かめる
- マイグレーション、composer.json、既存のテストの期待値は変えない。必要になったら実装を止めて相談する
- 確定の API のレスポンスの形は変えない
- 上の条件で振る舞いが決まらない場合が見つかったら、推測で実装せず、Plan に「未決事項」として書いて止まる

受け入れ条件の 3 と 5 は、AI に任せた実装のレビューや、AI のレビューの指摘を通じて、後から見つかりがちな条件です。3 は仕様の決め忘れ、5 は既存の形(衝突したらやり直す)からの抜けです。どちらも、実装の前に人が決められることでした。

受け入れ条件は、テストの名前に写せる粒度で書く

受け入れ条件は、1つの条件が1つのテストになるくらいの粒度で書きます。条件が多い変更では、テストの名前だけを人が先に書いて、Spec と一緒に渡す方法もあります。

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class CancelOrderTest extends TestCase
{
    use RefreshDatabase;

    public function test_未確定の注文をキャンセルできる(): void
    {
        $this->markTestIncomplete('受け入れ条件 1');
    }

    public function test_確定済みの注文はキャンセルできない(): void
    {
        $this->markTestIncomplete('受け入れ条件 2');
    }

    public function test_キャンセル済みの注文をもう一度キャンセルすると409を返す(): void
    {
        $this->markTestIncomplete('受け入れ条件 3');
    }

    public function test_他人の注文はキャンセルできない(): void
    {
        $this->markTestIncomplete('受け入れ条件 4');
    }
}

その場合、AI には中身を書くことと、テストを足すことは任せ、この名前を変えたり消したりはさせない、と Spec に書きます。消えていればレビューで差分から分かります。

ただし、PHPUnit の既定の設定では、incomplete のテストは失敗になりません。AI が中身を書かずに残しても CI は通るので、phpunit.xml に failOnIncomplete="true" を指定するか(コマンドなら --fail-on-incomplete)、レビューで incomplete が残っていないかを確かめます。

条件 5 のテストは、ここに入れていません。同時に来た更新の衝突は、Feature Test で安定して再現するのが難しいからです。テストで確かめにくい条件は、Spec に「どう確かめるか」を添えます。この例なら「Controller が UseCase を RetryOnConcurrentModification で包んでいることを、レビューで確かめる」です。確かめ方のない受け入れ条件は、誰も確かめないまま通ります。

なぜそう判断したか

詳しい説明ではなく「空白」を埋める理由

Spec を長く書けば AI の出力がよくなる、というわけではありません。目的や背景をいくら丁寧に書いても、「キャンセル済みの注文をもう一度キャンセルしたら」が書かれていなければ、そこは AI が決めます。しかも AI は、自分が決めた振る舞いに合わせてテストも書くので、実装とテストはそろって通ります。

人が決めるべきなのは、AI が推測で埋めると困るところです。仕様の分かれ目(受け入れ条件)、範囲の境目(作らないもの)、設計の前提(既存の形)の3つは、コードの周りを読んでも AI には決めようがありません。そこに Spec の分量を使います。

作らないものを書く理由

「作らないもの」を書いていないと、AI が足してきた機能が、範囲の外なのか、私が書き忘れただけなのかを、レビューで判断できません。実装の後のレビューでは、差分の範囲を Plan と突き合わせますが、その Plan を確かめる段階でも、基準になるのは Spec に書いた範囲です。

それに、範囲の外を書くことで、私自身が「今回はここまで」と決めることになります。返品を別の Spec にすると書いた時点で、確定済みの注文の扱いを今回は考えなくてよいと分かります。

既存の方針を Spec に全部書かない理由

「UseCase から Eloquent を使わない」「ドメインの例外は例外ハンドラで変換する」のような方針を、Spec のたびに書き写すと、Spec が長くなり、写し間違いや古い写しも生まれます。全体に効く方針は、リポジトリの文書や静的解析のルールとして1か所に置き、Spec には「今回の変更が真似すべき既存のコード」だけを名指しします。

名指しが効くのは、AI が手元のコードからパターンを読み取ることもあれば、一般的な書き方で書くこともあるからです。「確定と同じ形」と書いておけば、どちらに転ぶかを運に任せずに済み、レビューでも比べる相手がはっきりします。

ただし、今回の変更で外れやすいと分かっている方針は別です。上の例の「ドメインの例外は UseCase で catch しない」がそれで、AI のレビューの指摘を扱った記事でも、キャンセルの PR に「例外を catch して false を返してはどうか」という指摘が付く例を取り上げました。こうした方針だけは、名指しに加えて1行で書いておきます。

決めていないことを、AI に決めさせない理由

Spec を書く段階で、すべてを決めきれるとは限りません。決めていないことがあるのは問題ではなく、それを決めていないと書かずに渡すことが問題です。書かれていなければ、AI はもっともらしい答えを選んで先に進み、その選択は実装の中に紛れます。

「振る舞いが決まらない場合は、Plan に未決事項として書いて止まる」と書いておくと、決め忘れが Plan のレビューの段階で表に出ます。実装の後に気づくより、Plan で気づくほうがずっと安く済みます。

どこから設計過剰になるか

  • 小さな変更:typo の修正や、既存のパターンをなぞるだけの変更に、4つをそろえた Spec は要りません。変更の大きさとリスクで省略します
  • 捨てる前提の PoC:作らないものと、確かめたい失敗条件だけを書けば足ります。既存の形に合わせることは、本番に持ち込む段階で考えます
  • 受け入れ条件で実装を書く:「Order に cancelledAt を持たせる」のような内部の作りは、受け入れ条件ではありません。外から観察できる振る舞いだけを書き、作りは Plan に任せます。Spec でクラス設計まで決めると、Plan を確かめる意味がなくなります
  • 起こり得る場合をすべて並べる:受け入れ条件は、仕様が分かれる点と、壊れると困る点に絞ります。網羅しようとすると、Spec を書くこと自体が実装になります。決めきれない細部は「決めていないこと」に回します

まとめ

  • Spec で大事なのは分量ではなく、AI が推測で埋めると困る空白を、人が先に埋めておくこと
  • 作らないもの・受け入れ条件・従う既存の形・決めていないことの4つを決め、どう作るかは Plan に任せる
  • 受け入れ条件はテストの名前に写せる粒度で書き、テストにしにくい条件には確かめ方を添える

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。