iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
ChatGPT & Codex

ChatGPT + Codex 打造高效能 AI 開發工作流系列 第 11

Day 11: 單元測試自動化 (Unit Testing Workflow):利用 Codex 自動覆蓋 Edge Cases

  • 分享至 

  • xImage
  •  

Day 11: 單元測試自動化 (Unit Testing Workflow):利用 Codex 自動覆蓋 Edge Cases (Unit Testing Workflow with Codex)

本日核心價值 (Core Focus): 用 Codex 為金額計算函式自動覆蓋 None、邊界與 Decimal 進位點,並以 codex exec --sandbox workspace-write 跑 pytest 閉環:測試失敗時只依證據修正測試或程式。

概念說明與實戰情境 (Overview)

單元測試最常漏的不是 happy path,而是 None、數量 0、折價 100%、以及「四捨五入切換點」。這些案例像時區 DST 邊界:平常看起來正常,只在特定數值才翻盤。請 Codex「隨便寫測試」通常只會得到示範用 assert;正確做法是先寫死契約(輸入型別、捨入規則、錯誤條件),再讓模型產出 pytest,接著真正執行。官方 codex exec 預設是 read-only sandbox,寫測試檔或改 production code 一定要加 --sandbox workspace-write。閉環是:產生測試 → 跑 pytest → 失敗則帶 traceback 修正,禁止無證據改契約。

關鍵操作與範例 (Implementation & Example)

以訂單列金額計算為例:單價 × 數量,可選折價百分比,再加稅,最後用 DecimalROUND_HALF_UP 量化到分。全程禁止 float,避免 0.1 + 0.2 這類精度噪音被誤當成業務邏輯。

# pricing.py
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional

CENTS = Decimal("0.01")


def calculate_line_total(
    unit_price: Decimal,
    quantity: int,
    tax_rate: Decimal,
    coupon_percent: Optional[Decimal] = None,
) -> Decimal:
    if not isinstance(unit_price, Decimal) or not isinstance(tax_rate, Decimal):
        raise TypeError("unit_price and tax_rate must be Decimal")
    if coupon_percent is not None and not isinstance(coupon_percent, Decimal):
        raise TypeError("coupon_percent must be Decimal or None")
    if quantity < 0:
        raise ValueError("quantity must be >= 0")
    if unit_price < 0 or tax_rate < 0:
        raise ValueError("unit_price and tax_rate must be >= 0")
    if coupon_percent is not None and (
        coupon_percent < Decimal("0") or coupon_percent > Decimal("100")
    ):
        raise ValueError("coupon_percent must be between 0 and 100")

    subtotal = unit_price * Decimal(quantity)
    if coupon_percent is not None:
        subtotal -= subtotal * (coupon_percent / Decimal("100"))
    taxed = subtotal * (Decimal("1") + tax_rate)
    return taxed.quantize(CENTS, rounding=ROUND_HALF_UP)

契約要寫進 Prompt,不要讓模型自行發明規則。並發安全(concurrent-safe)在單元層級指的是:純函式不改動輸入物件、相同輸入得到相同輸出。不要開 threading.Thread 去測 race,那已超出 unit test。

Read pricing.py and write tests/test_pricing.py with pytest.

Contract:
- Money math uses decimal.Decimal only; reject float with TypeError.
- coupon_percent=None means no coupon (same numeric result as 0).
- Apply coupon to subtotal, then tax, then ROUND_HALF_UP to 2 decimal places.
- quantity==0 returns Decimal("0.00"); negative quantity/price/tax raise ValueError.
- coupon_percent outside 0..100 raises ValueError.

Coverage (unit level only):
1) Null: coupon_percent is None; also TypeError when unit_price is None or float.
2) Boundary: quantity 0 and 1; tax_rate 0; coupon 0, 100; quantity -1.
3) Decimal edge (like timezone DST edges): ROUND_HALF_UP at x.xx5,
   e.g. unit_price=Decimal("1.005"), quantity=1, tax_rate=0, coupon=None -> 1.01;
   also 19.99 * 0.05 tax without using float.
4) Concurrent-safe at unit level ONLY: inputs are not mutated; do NOT spawn threads.

Write the file, then run: pytest -q tests/test_pricing.py
If tests fail: fix tests or pricing.py using the failing assertion/traceback as evidence.
Do not widen the contract. Do not add HTTP, DB, or time.sleep.

非互動執行必須打開寫入權限,否則模型讀得懂檔案、卻無法落地測試:

codex exec --sandbox workspace-write "$(cat prompts/gen-pricing-tests.txt)"

官方文件寫明:codex exec 預設 read-only;需要改 workspace 時使用 --sandbox workspace-write。舊的 --full-auto 僅相容、會警告,新工作流不要再用。Windows 若在 WSL 跑 Codex,pytest 也要在同一個環境安裝,避免「模型寫了測試、主機 Python 沒有 pytest」。把驗證命令寫進 AGENTS.md(例如 pytest -q tests/test_pricing.py)能減少模型改去跑整個套件或安裝無關套件。單元測試工作流的完成定義是:契約內案例全綠,且沒有新增執行緒、網路或時鐘相依。若 Codex 堅持加 time.sleepThreadPool,當作 Prompt 失敗重跑,不要把那些案例合併進主分支。

以下是對應的 pytest,可直接當回歸基準;若 Codex 產出不同,用這份對契約,而不是對模型口氣:

# tests/test_pricing.py
from decimal import Decimal

import pytest

from pricing import calculate_line_total


def test_none_coupon_matches_zero_coupon():
    kwargs = dict(
        unit_price=Decimal("100.00"),
        quantity=2,
        tax_rate=Decimal("0.05"),
    )
    with_none = calculate_line_total(**kwargs, coupon_percent=None)
    with_zero = calculate_line_total(**kwargs, coupon_percent=Decimal("0"))
    assert with_none == with_zero == Decimal("210.00")


def test_rejects_none_and_float_money():
    with pytest.raises(TypeError):
        calculate_line_total(None, 1, Decimal("0.05"))
    with pytest.raises(TypeError):
        calculate_line_total(0.1 + 0.2, 1, Decimal("0.05"))  # type: ignore[arg-type]


def test_quantity_zero_and_one():
    assert calculate_line_total(Decimal("19.99"), 0, Decimal("0.05")) == Decimal("0.00")
    assert calculate_line_total(Decimal("10.00"), 1, Decimal("0.05")) == Decimal("10.50")


def test_full_coupon_then_tax_is_zero():
    total = calculate_line_total(
        Decimal("80.00"), 1, Decimal("0.10"), coupon_percent=Decimal("100")
    )
    assert total == Decimal("0.00")


@pytest.mark.parametrize(
    "quantity, unit_price, tax_rate, coupon",
    [
        (-1, Decimal("1.00"), Decimal("0"), None),
        (1, Decimal("-0.01"), Decimal("0"), None),
        (1, Decimal("1.00"), Decimal("-0.01"), None),
        (1, Decimal("1.00"), Decimal("0"), Decimal("100.01")),
    ],
)
def test_invalid_boundaries_raise(quantity, unit_price, tax_rate, coupon):
    with pytest.raises(ValueError):
        calculate_line_total(unit_price, quantity, tax_rate, coupon)


def test_half_up_edge_like_timezone_boundary():
    # 1.005 -> 1.01 under ROUND_HALF_UP; ROUND_HALF_EVEN would yield 1.00
    assert calculate_line_total(Decimal("1.005"), 1, Decimal("0"), None) == Decimal("1.01")


def test_tax_on_cents_stays_decimal():
    total = calculate_line_total(Decimal("19.99"), 1, Decimal("0.05"), None)
    assert total == Decimal("20.99")
    assert isinstance(total, Decimal)


def test_does_not_mutate_inputs():
    price = Decimal("10.00")
    tax = Decimal("0.05")
    coupon = Decimal("10")
    calculate_line_total(price, 3, tax, coupon)
    assert price == Decimal("10.00") and tax == Decimal("0.05") and coupon == Decimal("10")

失敗時把 完整 pytest traceback 貼回同一條 codex exec resume --last(或新的 exec,附上失敗片段)。Prompt 要要求:先判斷是測試理解錯契約,還是程式算錯;每次只改一邊,並引用失敗的 assert 實際值。沒有紅燈就不要「順便重構」。

建議迴圈(可放進 AGENTS.md 的驗證段落):

  1. 產生或更新 tests/test_pricing.py
  2. 執行 pytest -q tests/test_pricing.py
  3. 紅燈:帶 evidence 修測試或 pricing.py
  4. 綠燈:停止;不要為覆蓋率數字再加 thread / sleep / 網路

數量 0 是業務邊界、不是錯誤:空的訂單列應得到 Decimal("0.00"),不該丟例外;-1 才是契約違反。Codex 常把兩者都寫成 pytest.raises(ValueError),這會讓假測試變綠、真實回歸變盲。折價 None0 必須同值,否則呼叫端會為「有沒有傳 coupon 欄位」分出兩套金額。

pytest 失敗時,下一輪只要三樣證據:測試名稱、期望值、實際值(含 repr)。例如 assert Decimal("1.00") == Decimal("1.01") 指向捨入規則,不是「再加案例」。Prompt 應寫死決策:若契約是 ROUND_HALF_UP 且 actual 為 1.01,保留測試、改程式;若產品其實用銀行家捨入,先改契約文件再改測試。禁止同一輪既改期望又改實作。可用 codex exec resume --last 延續同一條執行緒,避免把 traceback 摘要成「有個測試失敗」就丟失數字。

把這套流程寫進團隊慣例時,完成條件只要三句:契約案例全綠、沒有執行緒或網路測、失敗時留下 actual/expected。不必追求行覆蓋率百分比。金額函式的回歸價值在進位點與 None,不在多一百個隨機數字。若 Codex 產出的測試檔超過一屏卻沒跑過,視為未交付,與「沒寫測試」同等。

注意事項與常見失敗 (Pitfalls)

  • codex exec 沒加 --sandbox workspace-write:模型會說明要寫哪些測試,但檔案不會出現;看起來像成功、repo 沒變。修法:寫檔與跑會產生 cache 的指令都用 workspace-write。
  • float 當期望值:assert total == 10.5 會把精度噪音測進去。修法:期望值一律 Decimal("10.50")
  • 把並發做成單元測試:對純函式開多執行緒,結果非確定且測不到契約。修法:只測不突變與 referential transparency;race 留給有鎖或有共用狀態的元件。
  • 模型「修測試去遷就 bug」:例如把 1.005 期望改成 1.00 讓 ROUND_HALF_EVEN 過關。修法:契約寫死捨入模式,失敗時先印 repr(actual) 再決定改哪一邊。
  • 一次生成上百個案例卻不跑:覆蓋率假象。修法:沒執行的測試不算交付。
  • 把時區函式的測試習慣套到金額:金額沒有 DST,真正的「邊界」是量化模式切換點與 None。修法:案例名稱寫 half_up / none_coupon,不要寫 timezone

本日總結 (Takeaways)

  • Edge Case 要寫進契約:None、0/1、折價 0/100、x.xx5 進位點,而不是請模型自由發揮。
  • 金額計算用 Decimal + 明確 rounding;單元測試的期望值也必須是 Decimal。
  • 並發安全在 unit 層級 = 無共享可變狀態與輸入不被修改,不是 thread 壓力測。
  • 官方指令是 codex exec --sandbox workspace-write;exec 預設 read-only,寫不進測試檔。
  • 閉環:產生 → 執行 → 用失敗證據修正;沒有 traceback 就不要改契約。

明日預告 (Next)

明天進入 Code Review 工作流:建立 AI 重構 (Refactoring) 與資安掃描機制,把測試綠燈之後的 diff 送進有嚴重度表的審查閘道。


上一篇
Day 10: 數據庫工作流:ChatGPT 自動生成高效 SQL Query、Migration 與 Index 優化
下一篇
Day 12: Code Review 工作流:建立 AI 重構 (Refactoring) 與資安掃描機制
系列文
ChatGPT + Codex 打造高效能 AI 開發工作流12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言