問題
一套會自動回覆 Threads 公開留言的產線,上線前補四道防護。四道都寫完、測試全綠、 覆蓋率看起來很好,實際去打真的 API 才發現其中一道從來沒有生效過,另一道的 測試把常數改成 999 照樣綠。
原因
1. hide_status 沒有 HUSHED 這個值
"hidden": item.get("hide_status") == "HUSHED",
隱藏一則留言之後再查它,Threads 回的是 "HIDDEN"。HUSHED 這個值這個欄位根本
不會回,所以 hidden 永遠是 False。
沒有任何跡象可以察覺:欄位在、型別對、POST /{reply-id}/manage_reply 回
{"success":true}、raise_for_status() 過。而且被隱藏的留言仍然留在
GET /{media-id}/conversation 的結果裡,所以這個欄位是唯一分得出它還在不在的
東西——讀錯了就等於這個資訊完全不存在。
這是同一天第三個「回 200 但是錯答案」的坑:
| 端點 | 以為它回什麼 | 實際回什麼 |
|---|---|---|
GET /{user-id}/replies | 這個帳號收到的留言 | 這個帳號自己發出的回覆 |
GET /{media-id}/replies | 這則貼文的整串留言 | 只有第一層 |
hide_status | HUSHED / 其他 | NOT_HUSHED / HIDDEN / COVERED / … |
三個共同點:都回 200,都給出型別正確的資料,錯的樣子跟「本來就沒東西」一模一樣。
2. 所有 fixture 的值都一樣,所以錯的分支沒人走過
測試檔裡每一筆假資料的 hide_status 都是 "NOT_HUSHED"。那條路被覆蓋到了,
但那個值沒有。覆蓋率統計不會告訴你這件事——x == "HUSHED" 這行執行過了,
只是永遠回 False。
3. 自我指涉的測試
fetched = [_fetched(reply_id=f"r{i}") for i in range(MAX_REPLIES_PER_RUN + 3)]
...
assert len(sent) == MAX_REPLIES_PER_RUN
把 MAX_REPLIES_PER_RUN 改成 999:餵 1002 則、送出 999 則、斷言成立、綠的。
同一個常數同時當輸入跟預期值,測到的只有「程式碼有讀這個常數」。
解法
讀狀態欄位要反過來列白名單。 列「看得見的是哪幾個」,其餘一律當看不見:
_VISIBLE_STATUSES = ("NOT_HUSHED", "UNHUSHED")
def _is_hidden(status) -> bool:
"""欄位沒回來(None)就當它看得見——沒有證據不等於隱藏了。"""
return bool(status) and status not in _VISIBLE_STATUSES
黑名單漏一個值 = 靜默失敗;白名單漏一個值 = 多擋一則,看得出來。順帶把
COVERED / BLOCKED / RESTRICTED 這些不是自己動作造成的狀態一起涵蓋掉。
上限類的測試要把數字寫死。 上限 monkeypatch 成 3、餵 5 則、斷言送出 3 則、 剩 2 則還在 pending。常數本身另外測它跟環境的關係(真正的限制),而不是測它等於 自己:
def test_the_cap_fits_inside_one_scheduling_interval():
worst_case = MAX_REPLIES_PER_RUN * REPLY_PACING_SECONDS[1]
assert worst_case < 3600 / 2 # 排程一小時一次,還要留時間給別的工作
寫完測試就地做一次變異測試。 不用工具,一個 heredoc 就夠:
cp target.py /tmp/bak && python3 - <<'EOF'
p="target.py"; s=open(p).read()
s=s.replace("MAX_REPLIES_PER_RUN = 8", "MAX_REPLIES_PER_RUN = 999")
open(p,"w").write(s)
EOF
pytest -q; cp /tmp/bak target.py
沒紅就是沒測到。上面第 3 點就是這樣抓出來的——測試「跑起來是綠的」跟「它擋得住 迴歸」是兩件事,而前者完全無法推論後者。
寫不出可反覆實測的路徑,就先補反向操作。 hide_reply 過去沒辦法驗,因為驗
一次就要留下一則被隱藏的留言。補了 unhide_reply(同一個端點 hide=false)之後
變成:拿一則自己人的留言隱藏 → 確認狀態 → 放回去,隨時可以重跑。不然那條路第一次
真正執行會是在一則真的騷擾留言上。
關鍵洞察
「靜默失敗」的判準是:壞掉的樣子跟正常的樣子長得一模一樣。
空清單、False、200——這三個東西同時是「正確的答案」也是「壞掉的答案」,而區分
它們的唯一方法是拿真實資料對照一次。單元測試對這一類完全沒有意見,因為 fixture
是自己寫的,寫的時候就已經把誤解編碼進去了。
推論:每一個「回傳固定字串集合」的外部欄位,都要有一筆用真實觀測值寫的測試。 不是自己猜的值——猜的值會跟程式碼裡的誤解一致,然後兩邊互相證明對方是對的。
還有一條同源的:唯一的硬規則不能只寫在提示詞裡。 同一套系統裡「不要給任何 聯絡方式」原本只是 prompt 的一句話,輸出端只檢查長度。規則會被當禁令繞過去, 事實才會被拿來推理——會造成公開且不可逆後果的約束,要在程式碼裡再擋一次。