タグ: テスト

  • AIにリファクタリングを任せる前に、テストが何を守っているかを、わざと壊した版で確かめる

    AIにリファクタリングを任せる前に、テストが何を守っているかを、わざと壊した版で確かめる

    重複したコードの共通化のようなリファクタリングは、AIに任せやすい仕事です。挙動を変えない変更なので、既存のテストが全部通れば終わりにしたくなります。ただ、テストが全部通ったことは、テストが見ている挙動が変わらなかったことしか示しません。守りたい挙動をテストが本当に見張っているかは、別に確かめる必要があります。

    請求書と見積で重複していた明細の金額計算を、Codexに1つの関数へまとめさせました。既存の8件のテストはすべて合格です。そのうえで、明細ごとに丸めていた計算を「合計してから丸める」に変えた版をわざと作って走らせると、落ちたのは請求書側の1件だけでした。見積側だけを同じように壊した版では、8件が1件も落ちません。見積の丸め順序を見張るテストはなかったのです。そしてCodexの完了報告は、「未テストの挙動」としてまさにその見積の複数明細を挙げていました。

    この記事では、守りたい挙動を1つずつ壊してテストを走らせ、どのテストが落ちるかを確かめる手順を、この実例で示します。読み終えたら、AIにリファクタリングを任せる前に、どの挙動のテストを足しておくべきかを判断できます。

    題材:請求書と見積の明細計算を1つにまとめる

    元のコードでは、invoice()(請求書)と quote()(見積)が同じ明細の計算を2回書いていました。どちらも明細ごとに金額を小数第2位で丸め(四捨五入)、それを合計します。

    from decimal import Decimal, ROUND_HALF_UP
    def invoice(lines):
        total=Decimal("0")
        for price, quantity in lines:
            total+=(Decimal(price)*quantity).quantize(Decimal("0.01"),rounding=ROUND_HALF_UP)
        return format(total,".2f")
    def quote(lines):
        total=Decimal("0")
        for price, quantity in lines:
            total+=(Decimal(price)*quantity).quantize(Decimal("0.01"),rounding=ROUND_HALF_UP)
        return {"amount":format(total,".2f"),"currency":"JPY"}

    Codex CLIの新しいセッションに、重複した明細の計算を非公開の補助関数に抜き出すこと、シグネチャ・出力・例外・Decimalの丸めを保つこと、既存テストを変えないこと、まだテストしていない挙動を報告することを、英語の仕様書で1回依頼しました(2026年9月21日、Python 3.13)。Codexは _line_amount() を追加して両方から呼ぶ形にし、変更は billing.py だけ、既存の8テストはすべて合格と報告しました。

    def _line_amount(price, quantity):
        return (Decimal(price)*quantity).quantize(Decimal("0.01"),rounding=ROUND_HALF_UP)

    既存の8テストは次のとおりです。7件が invoice()、1件が quote() を見ています。

    import unittest
    from billing import invoice, quote
    class Compat(unittest.TestCase):
        def test_empty(self): self.assertEqual(invoice([]),"0.00")
        def test_whole(self): self.assertEqual(invoice([("10",2)]),"20.00")
        def test_half(self): self.assertEqual(invoice([("0.005",1)]),"0.01")
        def test_per_line(self): self.assertEqual(invoice([("0.005",1),("0.005",1)]),"0.02")
        def test_negative(self): self.assertEqual(invoice([("-0.005",1)]),"-0.01")
        def test_quantity(self): self.assertEqual(invoice([("0.005",2)]),"0.01")
        def test_quote(self): self.assertEqual(quote([("2.555",1)]),{"amount":"2.56","currency":"JPY"})
        def test_generator(self): self.assertEqual(invoice((x for x in [("1",1)])),"1.00")

    手順1:守りたい挙動を1つずつ書き出す

    最初に、このリファクタリングで変わってはいけない挙動を、関数ごとに書き出します。今回の題材なら、たとえば次の3つです。

    • 請求書:明細ごとに丸めてから合計する

    • 見積:明細ごとに丸めてから合計する

    • 見積:戻り値の形(amount と currency の辞書)

    共通化で崩れやすいのは、関数どうしで同じに見えていた計算の細部です。とくに金額の丸め、権限の判定、日付の境界のように、間違えるとお客様や利用者に直接影響する箇所を優先して書き出します。全部を書き出す必要はありません。

    手順2:その挙動だけを壊した版を作る

    書き出した挙動ごとに、その挙動だけが変わる版を手で作ります。丸めの順序なら、「明細ごとに丸める」を「合計してから丸める」に変えます。2つの計算が本当に違う値を出すかは、先に小さなコードで確かめておきます。次を rounding_check.py として保存し、python3 rounding_check.py で実行します。

    from decimal import Decimal, ROUND_HALF_UP
    
    CENT = Decimal("0.01")
    
    def per_line(lines):
        return sum(
            ((Decimal(p) * q).quantize(CENT, rounding=ROUND_HALF_UP)
             for p, q in lines),
            Decimal("0"),
        )
    
    def after_total(lines):
        total = sum((Decimal(p) * q for p, q in lines), Decimal("0"))
        return total.quantize(CENT, rounding=ROUND_HALF_UP)
    
    lines = [("0.005", 1), ("0.005", 1)]
    print(f"明細ごと: {per_line(lines):.2f}")
    print(f"合計後: {after_total(lines):.2f}")
    assert per_line(lines) == Decimal("0.02")
    assert after_total(lines) == Decimal("0.01")
    明細ごと: 0.02
    合計後: 0.01

    0.005円の明細が2つあると、明細ごとに丸めれば0.02、合計してから丸めれば0.01になります。式は似ていても、出力が変わる変更です。どちらの丸め方が正しいかは業務の規則で決まることで、ここで見たいのは「今の挙動から変わったら、テストが気づくか」です。

    壊した版は、billing.py を一時的なコピーで書き換えて作ります。元のファイルやAIの成果物は書き換えません。

    手順3:既存テストを走らせ、どのテストが落ちるかを見る

    壊した版ごとに、既存の8テストを走らせました。

    走らせた版 既存8テストの結果 分かること
    Codexが共通化した版 8件すべて合格 8件が見ている挙動は変わっていない
    請求書・見積の両方を合計後の丸めに変えた版 7件合格、test_per_line の1件だけ失敗 請求書の丸め順序は、この1件が見張っている
    見積だけを合計後の丸めに変えた版 8件すべて合格 見積の丸め順序を見張るテストはない

    3行目の版に2明細(0.005円×2)の見積を渡すと 0.01、Codexが共通化した版では 0.02 が返ります。値は変わっているのに、8件はすべて合格しました。見積のテスト test_quote は明細が1つだけで、1明細では丸めの順序を変えても金額が変わらないからです。

    守りたい挙動ごとに壊した版を作り、既存8テストのどれが落ちるかを並べた結果の図
    見積だけを壊した版では、8件のどれも落ちませんでした。

    テストの件数が8件あっても、請求書の丸め順序を見張っていたのは1件、見積の丸め順序を見張っていたのは0件でした。もし共通化の途中で見積だけ計算の順序が変わっていたら、8件の合格はそれを知らせなかったことになります。

    手順4:AIの「未テストの挙動」を、壊す対象の候補にする

    Codexの完了報告には、次の一覧がありました(報告の原文の一部)。

    未テストの挙動:

    • 不正な価格・数量・明細構造に対する例外互換性

    • quote() の空入力、複数明細、負数、ジェネレーター入力

    • 非常に大きい値や特殊な Decimal コンテキスト

    2行目の「quote() の…複数明細」は、手順3で見つけた穴そのものです。今回、依頼でまだテストしていない挙動の報告を頼んだところ、AIは見積側の複数明細を挙げていました。この一覧を、手順1で書き出す挙動の候補として使えます。

    使い方は次のとおりです。

    1. 報告の「未テストの挙動」を、手順1の一覧に加える。

    2. 加えた挙動ごとに手順2・3で壊した版を作り、既存テストが落ちるかを見る。

    3. どのテストも落ちなかった挙動に、テストを1件足す。

    今回なら、見積の複数明細のテストを足します。

    import unittest
    from billing import quote
    class QuoteRounding(unittest.TestCase):
        def test_quote_per_line(self):
            self.assertEqual(quote([("0.005",1),("0.005",1)]),{"amount":"0.02","currency":"JPY"})

    このテストは、Codexが共通化した版では合格し、見積だけを壊した版では失敗しました。足したテストが本当に穴をふさいだかも、壊した版で落ちることまで見て確かめます。

    報告に挙がった挙動のすべてが、守りたい挙動とは限りません。「非常に大きい値」のように今の業務で起きない入力は、一覧に残して後回しにしてかまいません。

    任せる前に、テストを補うかを決める

    手順3の表で「どのテストも落ちなかった挙動」が残ったら、次のどちらかを選びます。

    • テストを足してから任せる:金額・権限・日付のように、変わると困る挙動。今回の見積の丸め順序はこちらです。

    • そのまま任せて、差分を読む:変わっても影響が小さい挙動。リファクタリングの差分で、その箇所に手が入っていないかを目で確かめます。

    変更が大きくなるときも、同じ確かめ方を「抜き出す」「呼び出し元を置き換える」「古い経路を消す」のような段階ごとに挟むと、どの段階で挙動が変わったかを追えます。どの段階でも、合格の件数ではなく、守りたい挙動ごとに見張っているテストがあるかで判断します。

    出典と確認日

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

    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。