本日核心價值 (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)
以訂單列金額計算為例:單價 × 數量,可選折價百分比,再加稅,最後用 Decimal 與 ROUND_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.sleep 或 ThreadPool,當作 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 的驗證段落):
tests/test_pricing.py
pytest -q tests/test_pricing.py
pricing.py
數量 0 是業務邊界、不是錯誤:空的訂單列應得到 Decimal("0.00"),不該丟例外;-1 才是契約違反。Codex 常把兩者都寫成 pytest.raises(ValueError),這會讓假測試變綠、真實回歸變盲。折價 None 與 0 必須同值,否則呼叫端會為「有沒有傳 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")。1.005 期望改成 1.00 讓 ROUND_HALF_EVEN 過關。修法:契約寫死捨入模式,失敗時先印 repr(actual) 再決定改哪一邊。None。修法:案例名稱寫 half_up / none_coupon,不要寫 timezone。本日總結 (Takeaways)
x.xx5 進位點,而不是請模型自由發揮。Decimal + 明確 rounding;單元測試的期望值也必須是 Decimal。codex exec --sandbox workspace-write;exec 預設 read-only,寫不進測試檔。明日預告 (Next)
明天進入 Code Review 工作流:建立 AI 重構 (Refactoring) 與資安掃描機制,把測試綠燈之後的 diff 送進有嚴重度表的審查閘道。