Codexの「テストはすべて成功」を受け入れる前に、事後テストと差分で、テストが決めていない契約まで確かめる

執筆者:

カテゴリ:

Codexに関数の追加を頼むと、作業の最後に「テストはすべて成功しました」と返ってきます。採用するかどうかを決めるのは自分ですが、その一行が示しているのは「渡したテストに通った」ことだけです。テストに書いていない入力でどう振る舞うか、頼んでいないファイルを変えていないかは、報告からは分かりません。

在庫予約の関数をCodexに1回実装させ、事前に渡した15件のテスト、実装後に足した5件のテスト、差分の3つで点検すると、すべて合格でした。それでも、仕様が何も言っていない入力が残っていました。数量として「intを継承したクラスの値」(intのサブクラス)を渡すと、この実装は受け入れます。同じ仕様・同じテストで別のセッションが書いた実装は、同じ値を拒否し、こちらも20件すべてに合格していました。合格の件数からは、この違いは見えません。

この記事では、合格報告を4つに分けて読む手順を、この実装で順に示します。読み終えたら、Codexの合格報告を受けたときに、何を足して確かめ、どこを仕様として決めてから採用するかを判断できます。題材のコードとテストは全文を載せるので、同じ点検を手元で再現できます。

合格報告を4つに分けて読む

「テストはすべて成功」を、次の4つの問いに分けます。上の2つはテストで、3つ目は差分で、4つ目はコードを読んで確かめます。

確かめること 見るもの 今回の実装
渡した条件を満たしたか 事前に渡したテストの結果 15件すべて合格
渡していない境界でも崩れないか 実装後に足したテストの結果 5件すべて合格
頼んでいない場所を変えていないか git diff と git status Codexの変更は inventory.py だけ
仕様が決めていない入力をどう扱うか 入力を判定している行と、そこへ値を渡した結果 intのサブクラスを受け入れる

上の3行が合格なら、採用してよい実装の候補です。4行目は合否ではなく、採用の前に「その扱いでよいか」を決める項目です。今回の4行目は、実装の書き方しだいで逆の結果になりました。

合格報告を、事前テスト、事後テスト、差分、テストが決めていない契約の4段に分けて確かめる図
4段目は合否ではなく、採用の前に扱いを決める項目です。

題材:在庫予約の関数と8つの条件

既存の inventory.py には、在庫を参照する available() だけがあります。ここに、在庫の辞書と予約の一覧を受け取り、予約後の新しい在庫の辞書を返す reserve(stock, requests) を追加させました。条件は次の8つで、英語の仕様書(SPEC.md)として渡しています。

  1. 入力を書き換えず新しい辞書を返し、既存の available() を維持する。

  2. 在庫と重複判定をテナント(利用企業)ごとに分け、他テナントの在庫を使わない。

  3. 数量は正のintとし、bool・0・負数・floatは ValueError で拒否する。

  4. 全件を検証してから反映し、在庫がない・足りないときは ValueError にする。

  5. 同じバッチ内で、テナントとIDが同じで商品・数量も同じ予約は、一度だけ消費する。

  6. IDが同じで内容が違えば拒否し、別の呼び出しとは重複の状態を共有しない。

  7. IDが違う予約は合計して消費し、空の一覧でも新しい同値の辞書を返す。

  8. 外部依存・ネットワーク・永続化・並行実行・新しいCLI・シグネチャの変更は対象外。

条件3は、Pythonの bool が int のサブクラスであることを踏まえて、True を数量として通さないための一文です。一方で、bool以外のintのサブクラスをどう扱うかは、8つの条件のどこにも書いていません。この空白が、手順4で問題になります。

手順1:事前テストを固定してから実装を頼む

空のGitリポジトリに、既存の関数だけを置いた inventory.py を作ります。

def available(stock, tenant, sku):
    return stock.get((tenant, sku), 0)

上の8条件を SPEC.md に書き、次のテストを test_contract.py として保存して、3つを同じコミットに入れます。在庫のキーは (tenant, sku)、予約は tenant・sku・quantity・request_id を持つ辞書です。

import unittest
from inventory import available, reserve
def req(t="a", sku="pen", q=1, id="x"):
    return dict(tenant=t, sku=sku, quantity=q, request_id=id)
class Contracts(unittest.TestCase):
    def setUp(self): self.s={("a","pen"):5,("b","pen"):8,("a","book"):3}
    def test_01_legacy(self): self.assertEqual(available(self.s,"a","pen"),5)
    def test_02_happy(self): self.assertEqual(reserve(self.s,[req(q=2)])[("a","pen")],3)
    def test_03_no_mutation(self):
        r=[req()]; before=r[0].copy(); out=reserve(self.s,r)
        self.assertEqual(self.s[("a","pen")],5); self.assertEqual(r,[before]); self.assertIsNot(out,self.s)
    def test_04_tenant(self):
        out=reserve(self.s,[req(),req(t="b")]); self.assertEqual((out[("a","pen")],out[("b","pen")]),(4,7))
    def test_05_bool(self):
        with self.assertRaises(ValueError): reserve(self.s,[req(q=True)])
    def test_06_zero(self):
        with self.assertRaises(ValueError): reserve(self.s,[req(q=0)])
    def test_07_negative(self):
        with self.assertRaises(ValueError): reserve(self.s,[req(q=-1)])
    def test_08_float(self):
        with self.assertRaises(ValueError): reserve(self.s,[req(q=1.0)])
    def test_09_atomic(self):
        with self.assertRaises(ValueError): reserve(self.s,[req(),req(sku="missing",id="y")])
        self.assertEqual(self.s[("a","pen")],5)
    def test_10_shortage(self):
        with self.assertRaises(ValueError): reserve(self.s,[req(q=6)])
    def test_11_same_duplicate(self): self.assertEqual(reserve(self.s,[req(),req()])[("a","pen")],4)
    def test_12_conflict(self):
        with self.assertRaises(ValueError): reserve(self.s,[req(),req(q=2)])
        with self.assertRaises(ValueError): reserve(self.s,[req(),req(sku="book")])
    def test_13_cumulative(self):
        with self.assertRaises(ValueError): reserve(self.s,[req(q=3),req(q=3,id="y")])
    def test_14_empty(self):
        out=reserve(self.s,[]); self.assertEqual(out,self.s); self.assertIsNot(out,self.s)
    def test_15_no_global_cache(self): self.assertEqual(reserve(self.s,[req()]),reserve(self.s,[req()]))

このコミットから作業用のコピーを作り、Codexに「SPEC.mdを実装し、既存テストは変えず、構文検査とテストを実行して報告する」と頼みます。実装前は reserve のimportで失敗しますが、それで正常です。

Codex CLIの新しいセッションに、この依頼を1回送りました(2026年9月21日、Python 3.13)。Codexは inventory.py に reserve を追加し、「15件すべて成功」「変更ファイルは inventory.py のみ」と報告しました。報告を受け取ったあとに修正を頼み直した回数は0回です(Codexの内部で何回書き直したかとは別の数です)。

点検に使う完成コード

今回Codexが返した inventory.py の全文です。同じ結果を確かめる場合は、最初の available() だけのファイルを次の内容に置き換え、python3 -m unittest -v test_contract で事前テスト15件を実行します。自分でCodexに実装を頼む場合は、その返却コードを点検対象にします。

def available(stock, tenant, sku):
    return stock.get((tenant, sku), 0)


def reserve(stock, requests):
    """Return a stock snapshot with an atomic batch of reservations applied."""
    reservations = {}
    required = {}

    for request in requests:
        tenant = request["tenant"]
        sku = request["sku"]
        quantity = request["quantity"]
        request_id = request["request_id"]

        if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0:
            raise ValueError("quantity must be a positive integer")

        reservation_key = (tenant, request_id)
        reservation = (sku, quantity)
        if reservation_key in reservations:
            if reservations[reservation_key] != reservation:
                raise ValueError("conflicting duplicate request")
            continue

        reservations[reservation_key] = reservation
        stock_key = (tenant, sku)
        if stock_key not in stock:
            raise ValueError("stock item does not exist")

        total = required.get(stock_key, 0) + quantity
        if total > stock[stock_key]:
            raise ValueError("insufficient stock")
        required[stock_key] = total

    result = stock.copy()
    for stock_key, quantity in required.items():
        result[stock_key] -= quantity
    return result

手順2:実装後に境界テストを足す

事前の15件は、Codexが実装中に読める場所に置きました。読めるテストに合わせた実装でも通るので、実装を受け取ってから、別の観点のテストを足します。今回足したのは次の5件です。test_after.py として同じフォルダに保存し、python3 -m unittest -v test_after で実行します。

import unittest

from inventory import reserve


def req(t="a", sku="pen", q=1, id="x"):
    return dict(tenant=t, sku=sku, quantity=q, request_id=id)


class AfterImplementation(unittest.TestCase):
    def test_same_id_in_other_tenant(self):
        stock = {("a", "pen"): 2, ("b", "pen"): 2}
        out = reserve(stock, [req(), req(t="b")])
        self.assertEqual(out, {("a", "pen"): 1, ("b", "pen"): 1})

    def test_resend_after_other_request(self):
        out = reserve({("a", "pen"): 5}, [req(), req(id="y"), req()])
        self.assertEqual(out, {("a", "pen"): 3})

    def test_cumulative_success(self):
        out = reserve({("a", "pen"): 5}, [req(q=2), req(q=3, id="y")])
        self.assertEqual(out, {("a", "pen"): 0})

    def test_other_stock_is_kept(self):
        out = reserve({("a", "pen"): 2, ("b", "book"): 9}, [req()])
        self.assertEqual(out, {("a", "pen"): 1, ("b", "book"): 9})

    def test_requests_unchanged_on_failure(self):
        requests = [req(), req(q=99, id="y")]
        saved = [dict(r) for r in requests]
        with self.assertRaises(ValueError):
            reserve({("a", "pen"): 2}, requests)
        self.assertEqual(requests, saved)


if __name__ == "__main__":
    unittest.main(verbosity=2)

別テナントの同じID、間に別の予約を挟んだ再送、在庫ちょうどまでの合計消費、予約と関係のない在庫、失敗したときの予約一覧。どれも事前の15件とは違う形の入力で、Codexの実装は5件すべてに合格しました。足すテストは、仕様の条件ごとに「事前テストとは違う並び・組み合わせ」を1つずつ考えると作りやすくなります。

手順3:差分で変更範囲を確かめる

テストの結果とは別に、何が変わったかを差分で確かめます。

python3 -m compileall -q .
python3 -m unittest discover -v
git diff --check
git diff --stat
git status --short

git diff --stat は変更をファイルごとに要約し、git status --short はまだGitに登録していない新しいファイルも表示します。今回の変更は inventory.py だけで、test_contract.py と SPEC.md はそのまま、依存の追加もありませんでした。テストのファイルが変わっていたら、合格の意味が変わるので、テストを弱めていないかを先に確かめます。

手順4:テストが決めていない契約をコードで探す

ここまでの3つは、渡した条件と頼んだ範囲の確認です。最後に、仕様が何も書いていない入力を探します。探し方は、入力を判定している行を読み、仕様の言葉と食い違いうる書き方を見つけて、その値を実際に渡すことです。

今回の実装で数量を判定していたのは、次の1行でした。

if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0:

isinstance(quantity, int) は、intのサブクラスの値も「int」と判定します。boolだけを先に除いているので、bool以外のサブクラスは通ります。

同じ SPEC.md と同じ15件のテストで、設計案を添えて別のセッションのCodexに実装させた版もあります(その引き継ぎの記録は設計案を別のセッションに渡す記事で扱っています)。そちらの判定は次の1行でした。

if type(quantity) is not int or quantity <= 0:

type(quantity) is not int は、組み込みのint以外をすべて拒否します。2つの実装に、class Quantity(int) で作った Quantity(1) と、比べるための値を、在庫5の商品への1件の予約として渡しました(Python 3.13)。

渡した数量 この記事の実装 別のセッションの実装
Quantity(1)(intのサブクラス) 受け入れる(在庫5→4) ValueError で拒否する
1 受け入れる 受け入れる
True 拒否する 拒否する
1.0 拒否する 拒否する

2つの実装は、事前15件・事後5件のテストにどちらもすべて合格しています。テストに Quantity のような値がないので、合格の件数は同じでも、この1行の違いは結果に出ません。Codexの報告も、どちらも「数量の厳密な検証」と書くだけでした。

どちらが正しいかは、仕様からは決まりません。呼び出し側が数量をintのサブクラスで渡す設計なら受け入れる実装が必要で、組み込みのintに限りたい理由があれば拒否する実装が必要です。決まっていないまま採用すると、その扱いは仕様ではなく、たまたま書かれた1行で決まります。

自分の実装で同じ確認をするときは、次のように仕様の空白に当たる値を渡し、結果を表に残します。

from inventory import reserve


class Quantity(int):
    pass


stock = {("a", "pen"): 5}
for value in [Quantity(1), 1, True, 1.0]:
    request = {"tenant": "a", "sku": "pen", "quantity": value, "request_id": "x"}
    try:
        print(type(value).__name__, "受け入れ", reserve(stock, [request]))
    except ValueError as exc:
        print(type(value).__name__, "拒否", exc)

今回の題材で空白になっていたのは数量の型でしたが、文字列の前後の空白、Noneや空文字、非常に大きな数など、仕様に書いていない入力はほかにも考えられます。全部を試すより、判定している行を読み、そこで使っている関数(isinstance・type・strip など)が仕様の言葉より広いか狭いかを見るほうが早く見つかります。

採用するか、何を決めてから採用するか

4つの点検の結果から、次のように判断します。

  • 1〜3のどれかが不合格:採用しない。落ちたテストか、想定外の差分を添えて直しを頼む。

  • 1〜3が合格で、4で扱いが決まった:採用する。決めた扱いを SPEC.md に一文足し、その値を渡すテストを test_contract.py に加えてから取り込む。

  • 1〜3が合格で、4の扱いを決める人がいない:取り込みを待ち、呼び出し側の担当に扱いを確かめる。

今回なら、「数量はintのサブクラスも受け入れる」か「組み込みのintだけを受け入れる」かを決め、Quantity(1) を渡すテストを1件足せば、次に別の実装が来ても同じ扱いになります。テストに足した時点で、4つ目の問いは1つ目の問いに移ります。

点検を続けるなら、合格の件数に加えて、受け取ってから修正を頼み直した回数と、4つの点検にかかった時間を記録しておくと、Codexに任せる範囲を決める材料になります。

出典と確認日

  • OpenAI: Codex best practices。計画、編集後の検証、差分のレビューを含む進め方の案内。再確認日:2026-10-09。

コメント

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です