タグ: リファクタリング

  • 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の表で「どのテストも落ちなかった挙動」が残ったら、次のどちらかを選びます。

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

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

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

    出典と確認日