TopicReview 的 1562 行測試幾乎全部是 Claude 寫的,我只做 review——上線半個多月只加新測試、不改舊測試。這篇講為什麼測試是 Claude 最該幹的活,review 該看什麼不看什麼(以及真實 spec 裡一個被遺漏的邊界),以及這種分工的預設配置。
上一篇結尾說 TopicReview 的「119 spec 全綠」。讀者真正該問的下一個問題:這些測試誰寫的?
答:Claude 寫了幾乎所有 1562 行測試程式碼,我只做 review。上線到現在半個多月,這 1562 行的後續維護是只加新測試不改舊測試。
這篇講為什麼測試是 Claude 最該幹的活,review 時該看什麼不看什麼,以及這個分工實際能走多遠。
TopicReview 的測試分在 7 個檔案裡:
spec/services/topic_review_service_spec.rb 760 行(88 個測試)
spec/requests/topic_reviews_spec.rb 281 行(32 個測試)
spec/requests/review_appeals_spec.rb 152 行(16 個測試)
spec/requests/review_votes_spec.rb 127 行
spec/policies/topic_review_policy_spec.rb 109 行
spec/jobs/close_topic_review_job_spec.rb 71 行(7 個測試)
spec/models/topic_review_spec.rb 62 行
───────────────────────────────────────────
1,562 行
涵蓋四種測試類型:service(業務邏輯)、request(controller + integration)、policy(Pundit 權限)、job(定時任務)。
初始 commit d162f1e 帶 Co-Authored-By: Claude Sonnet 4.6,一次性寫了其中 1100+ 行。之後的 spec 變動全是「Add test for...」——沒有一條是 refactor 或 rewrite:
00393fc Add test for finalize! with zero votes (expired review)
3f53304 Add test for finalize! with legacy votes missing reasoning
3b185da Update specs to use PROVISIONAL_PENALTY constant
補漏,不是返工。這個細節很重要——後面會回來講。
四條硬理由:
1. 輸入輸出明確。測試本質是「給定狀態 → 期待行為」。這是 Claude 最拿手的翻譯——把規範翻譯成斷言。程式碼邏輯有時候還要權衡、琢磨,測試沒什麼可琢磨的。
2. 機械 × 高數量。一個 describe .open! 下要涵蓋「有陪審員 / 無陪審員 / 無 topic / 已有 active review」四種 context,每個 context 再鋪 2-5 個 it block。人類寫到第三種 context 會本能想偷懶省一個 case。Claude 不會偷懶——它寫第 88 個 it 和第 1 個的認真程度完全一樣。
3. 回饋閉環超短。測試寫完立刻跑 rspec,通不通過馬上知道。不像業務程式碼要等實際用幾天才暴露問題。閉環短意味著 Claude 的錯誤會被 rspec 立刻擋下來,你不需要盯著看。
4. 天然並行。it block 之間相互獨立,沒有隱藏依賴,可以大批量鋪展。Claude 一次產生幾十個獨立測試正是它的強項。
這部分是整個分工的關鍵。
不看:
看:
最後一條是 review 真正的價值。Claude 涵蓋「它能想到的」測試,但它想不到的測試不會主動補。這正是 human review 的定位——從業務規則反查缺失。
打開 spec/services/topic_review_service_spec.rb 開頭的 describe ".open!":
describe ".open!" do
context "when there are eligible jurors" do
# review 狀態正確 / post under_review / 建立 assignments / 通知 author / 通知 jurors / 不重複開
end
context "when there are no eligible jurors" do
# 建立 review 但不建立 assignments
end
context "when post has no topic" do
# 回傳 nil
end
end
看起來很全。但 eligible_jurors 在 model 裡的真實規則是排除三類人:
def eligible_jurors
excluded_ids = [ post.user_id ] + post.reports.pluck(:user_id) + review_votes.where(stage: :initial).pluck(:user_id)
User.jurors_and_judges.where.not(id: excluded_ids.uniq)
end
現在看測試——哪個 test 斷言了「原作者不會被選進陪審員」?
翻遍 service_spec.rb 和 model_spec.rb,沒有。model_spec.rb 只測了 pending_vote_by scope 的幾種情況,沒測 eligible_jurors 本身。service_spec.rb 裡只有一行註解:# Jurors must NOT be the post author——是 setup 的註解,不是斷言。
這就是 review 能發現的缺失:三條排除規則(原作者 / 舉報人 / 已投過的),沒有一條被測試保護。如果以後改 eligible_jurors 的實作——比如有人重構時不小心把 post.user_id 漏掉——所有現有測試都會通過,但生產會讓原作者進自己的陪審團。
Claude 沒錯,它測了它被要求測的東西。但它沒主動問「這三條規則分別需要測試保護嗎」。這種提問——從規則反查測試涵蓋——就是 review 要幹的事。
(實話說,我 review 的時候也漏了這個。是寫這篇文章二次 audit 時才看到的。這說明 review 也不是一次到位——但比不 review 好 10 倍。)
如果「Claude 寫 + human review」是完美分工,初始 commit 之後就不應該有新測試 commit 了。實際情況更有意思——有補漏但沒有重寫:
00393fc Add test for finalize! with zero votes (expired review)
3f53304 Add test for finalize! with legacy votes missing reasoning
第一條是發現 bug 後補的——e8cb2db Default to keep verdict when review expires with zero votes 是 fix,00393fc 是配套的 regression test。第二條同理跟在 abaa22e Fix CloseTopicReviewJob failing due to reasoning validation on old votes 後面。
這兩條 commit 證明兩件事:
「夠好 + 能持續打補丁」這個標準比「完美」現實得多。追求完美 review 會讓你不敢放手讓 Claude 寫測試;接受「夠好」才能啟動分工。
不是所有測試都適合完全丟給 Claude:
這些場景裡人類主導,Claude 做輔助。
把這個分工落成可執行的 default:
程式設計師看測試的心理資源比看程式碼更少。測試重複、機械、耗神但必須。這些特徵正好是 Claude 的強項——它不會煩、不會累、不會到第 50 個 it 就偷工減料。
你做的事不是「寫測試」,是「保證有測試涵蓋到的業務規則不漏」。一個是實作,一個是判斷——判斷留給你,實作交給 Claude。
119 spec / 1562 行一次上線且半個多月不返工,不是因為我更會寫測試,是因為我完全沒寫測試。我只是比 Claude 多做一件事:知道哪些業務規則該被保護。