iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Claude AI

盡信 Claude,不如無 Code — 心法與全端實戰系列 第 19 篇

Day 19 把寫測試外包給地端:同一個模型,給清單和給一句話差多少

  • 分享至 

  • xImage
  •  

前面幾天造的東西有一個共同點:它們都在限制 AI —— CLAUDE.md 限制它怎麼做(Day 6),測試限制它做得對不對(Day 9),settings.json 限制它做得到什麼(Day 10),hook 擋在它寫入之前(Day 17)。

今天換一個方向。不是限制它,是換一個更便宜的它 —— 一台 Mac mini、一個 35B 的地端模型、一支自己包的 CLI,把寫單元測試整批交出去。

然後用一個比「跑得過」更硬的裁判,驗它交回來的東西。同一個模型、同一段程式碼,我給了兩種交代法:一份寫死預期值的清單,和一句話。各跑三次。

心法先講:先問誰當裁判,再問誰來做。


它宣稱什麼

「把工作外包給地端模型」這個做法的宣稱是:量大、機械性、規格明確的工作,不需要用最貴的模型做。

聽起來合理,但它預設了一件事 —— 省下的生成成本,大於驗證與返工的成本:

外包的划算程度 = 省下的生成成本 − 驗證與返工的成本

如果驗證與返工要花的力氣比自己寫還多,外包就是負收益。所以判準不是「這件事難不難」,是「規格能不能先寫死,結果有沒有一個便宜的裁判」。

工作 有沒有便宜的裁判 能不能外包
寫單元測試 ✓ 跑測試 + 突變檢查 可以
寫實作 ✓ 前提是先有測試 有條件
機械性重構 ✓ 既有測試 可以
架構設計 / 介面定義 ✗ 只外包候選方案,不外包決定

第一列的裁判寫的是「跑測試 + 突變檢查」,後半截不能省。下一節說為什麼。

「跑得過」不是裁判

一個測試可以完全不測任何東西:

it('借 14 天會算出到期時刻', () => {
  expect(dueAt('2026-09-04T02:00:00Z', 14)).toBeDefined()   // ← 只要不是 undefined 就過
})

這個測試永遠是綠的。把 dueAt 的算式改成加 13 天、加 0 天,它還是綠的。它有測試該有的一切外觀:有名字、有斷言、跑得動、進得了 CI、算得進覆蓋率 —— 判別力是零。

而這種測試特別容易從「量產」的路徑長出來,因為寫出這種測試最快,而且看起來最像是完成了工作。

覆蓋率抓不到它。覆蓋率量的是測試有沒有執行到那一行,不是那一行寫錯了測試會不會紅。上面那個例子覆蓋率可以是 100%。

突變檢查(mutation testing)量的是後者:

故意把 code 改壞,看測試會不會抓到。改壞了測試還是綠的 —— 那個突變「活下來」了,代表這段 code 沒有被真的測到。

指標叫殺掉率(mutation score):被抓到的突變數 ÷ 有效的突變數(編譯不過、或讓測試根本跑不起來的突變不算進分母;這次 56 個全部有效)。Day 9 手動改過四種寫法打 overdue.js;今天交給 Stryker 自動產生,一次幾十個。

我怎麼量

硬體:Mac mini M4 Pro,20-core GPU,64 GB 統一記憶體。

模型:qwen3.6-35b-a3b-mlx(MLX 4-bit 量化,約 20.4 GB)。為什麼不用更強的 qwen3.8-27b,下一節有數字。

工具:pil —— 用開源的 Pi coding agent 接上 LM Studio 裡的地端模型,它會實際改檔案,所以跑之前 git 必須是乾淨的,不然分不清哪些改動是它做的。這次只給它讀寫檔的工具、不給 bash,而且用 --only "tests/local/**" 鎖死它只能寫測試資料夾。

標的:圖書館借閱 repo 的兩支純函式 —— book-state.js(書的狀態機:available ⇄ on_loan,非法轉移丟 IllegalTransitionError,未知輸入丟 TypeError)和 due-at.js(借出時算到期時刻)。Day 9 打的是 overdue.js,這兩支沒碰過。在實驗用的 clone 裡,原本那份測試先刪掉,不讓它抄;repo 裡的原檔留著當對照組。

兩種交代法。差別不只是有沒有清單 —— A 還多了四條規則 —— 所以比的是「把判斷先寫死再交出去」和「一句話交出去」;模型、起點 commit、工具權限、取樣參數都相同:

A. 精準清單 —— 我寫一份 19 條的 tests/PLAN.md,每一條寫死輸入和預期值,臨界點一個一個列出來(節錄三條,完整版在 repo):

| B2 | 最小借期 1 天 | dueAt('2026-09-04T02:00:00Z', 1) | '2026-09-05T02:00:00.000Z' |
| B6 | now 非法 | now 各用 '昨天'、''、undefined、null、1757988000000(數字)、
                  new Date('2026-09-04T02:00:00Z'),loanDays 用 14 | 每一個都丟 TypeError |
| B7 | loanDays 非法 | 0、-1、1.5、NaN、Infinity、'14'、undefined、null | 每一個都丟 TypeError |

給 pil 的指令附上四條規則:照清單不增減、不准讀實作推測預期值、一個 it 只驗一件事、只寫這兩個檔。

B. 一句話 —— 不給清單,實作隨它讀:

幫 src/domain/book-state.js 和 src/domain/due-at.js 寫單元測試,
放在 tests/local/book-state.test.js 與 tests/local/due-at.test.js,用 vitest。

對照組:repo 裡原本那份測試(開發時先紅後綠寫的,22 個),同一把 Stryker 打一次。

分工(這一條是實驗成立的前提):

需要判斷的東西 —— 架構、介面、測試規格 —— 留在雲端模型和我手上;需要耐力的東西 —— 把規格變成測試碼 —— 外包給地端;判定先交給機器;機器判不了的,才輪到人。

要記的數字:交回來的測試有幾個、原樣跑有幾個紅(返工)、耗時,以及最後的殺掉率。每一輪都從同一個 commit 開新分支、同一段指令、同一組工具;模型取樣用 Pi 設定檔裡的 temperature 1.0、top_p 0.95、top_k 20,reasoning 開著。紅的我只做最小修正 —— 格式、算錯的預期值照規格改對 —— 修到全綠再打突變。

為什麼是 35B-A3B,不是更強的 27B

磁碟上有兩個 Qwen:3.6-35b-a3b(MoE,35B 總參數、每個 token 只動 3B)和 3.8-27b(dense,每個 token 動全部 27B)。3.8 是 8 月中才出的,各家評測都說它強很多,理論上該用它。

8 月我拿同一份考卷打過兩個。考卷不是「測試跑不跑得過」—— 那種題目兩個都滿分 —— 是往一支分級計費函式裡注入 9 個各自獨立的 bug,看模型寫出來的測試抓到幾個:

模型 抓到 寫了幾條測試 耗時
qwen3.6-35b-a3b(MoE,4-bit) 6 / 9 19 191 秒
qwen3.8-27b(dense,8-bit) 7 / 9 18 2,776 秒

在這份 9 個注入 bug 的小考卷、每個模型各跑一次的條件下,27B 多抓到一個(四捨五入的位數),代價是 14.5 倍時間,46 分鐘。所以選 3.6 不是因為 3.8 不好,是多的那一分不值 14.5 倍時間 —— 而且那一分只要在測試清單裡多寫一句「驗四捨五入到 2 位」就補得回來。

這句話後面會再出現一次:多的那一分,清單補得回來。

接上去之前,先踩了三個坑

三個坑有一個共同點:都是安靜地失敗,或是錯誤訊息指錯方向。它們決定了後面的數字能不能信。

第一,context 對不上,它會假裝跑完(8 月踩的)。Pi 不會去驗證 server 實際載入的 context 長度,它只信自己設定檔裡的那個數字(32,768)。當時 LM Studio 如果用預設值載入模型 —— 可能只有 8,192 —— Pi 就會照 32k 的假設送出超長的請求。症狀不是報錯,是空的 log、exit 0、沒有產出任何檔案,看起來就像沒執行。所以我在 pil 裡加了一道檢查:跑之前先問 server 實際載入的 context,比設定檔小就用 -c 卸載重載:

lms load qwen3.6-35b-a3b-mlx -c 32768 --gpu max -y

今天想重現這個坑,重現不出來 —— 但發現了另一件事。在現在的 LM Studio(0.4.23)上,-c 8192、-c 32768、--context-length 8192、不帶旗標讓它第一個請求時自動載入,全部載入成模型的上限 262,144;換一個 gemma 模型帶 -c 4096,也是載成它的上限 131,072。旗標被收下了、沒有報錯、載入成功,但實際 context 沒有照指定值走 —— LM Studio 的文件把 --context-length 寫成載入時設定 context 的參數,我在 0.4.23 實測到的行為不是這樣。

坑換了方向:以前是 context 太小、Pi 安靜地失敗;現在是 -c 寫了等於沒寫,而 pil 那道檢查只驗「不小於設定值」,所以照樣通過。這次沒有影響到結果 —— 至少看得到的只是多吃記憶體 —— 但它說明了一件事:一道檢查是對著某個版本的行為寫的,版本一換,它可能還是綠的,只是已經不在檢查你以為的那件事。

第二,「模型有沒有載入」問錯了 API 會得到錯的答案(8 月踩的,今天重測一樣)。/v1/models 列的是已下載的模型,不是在記憶體裡的;今天它列 5 個,lms ps 只有 1 個真的載入了。問前者會以為一切就緒,然後 LM Studio 在第一個請求時才自動載入 —— 8 月那時就是用預設 context 載入,回到第一個坑。

第三,檢查說「沒回應」,其實是「不讓你進」(今天踩的)。pil --check 一開頭就紅:

✗ LM Studio 沒回應     127.0.0.1:1234 — 試試 lms server start

照它說的啟動 server,成功了,再檢查 —— 還是「沒回應」。直接 curl 才看到真相:401。Day 15 那次我把 LM Studio 的 Require Authentication 重新打開了,而 Pi 設定檔裡的 key 還是佔位字串。檢查只看「有沒有成功」,把 401 和「server 沒開」混成同一句話 —— 它給的修法方向是錯的。

三個坑加起來,跟 Day 13 那個誤報、Day 17 那個 hook 是同一句話:一個會安靜地過、或把原因講錯的檢查,比沒有檢查更糟。 外包之前,先確認「它真的在跑」這件事本身有裁判。

還有一條原則跟成本直接相關:地端的輸出一律寫進檔案,不要讓它直接回到 Claude 的 context。 批次產測試檔,如果讓結果印在終端被雲端模型讀走,等於把整批測試碼塞進最貴的那個 context —— 外包省下的錢就這樣還回去了。

量到什麼

交回來的時候:

交代法 輪 耗時 交回幾個測試 原樣跑,紅幾個
A 精準清單 1 115 秒 45 0
A 精準清單 2 48 秒 20 整個檔案載不進來(import 漏了 afterEach)
A 精準清單 3 59 秒 46 0
B 一句話 1 258 秒 21 3
B 一句話 2 114 秒 26 3
B 一句話 3 1,563 秒(卡住,手動停) 44 14

A 的測試數跳動(45 / 20 / 46)只是有沒有用 it.each 把一條清單展開成多個 it,涵蓋的案例一樣。第 2 輪那個漏字,清單第一行其實寫了 import 要帶什麼 —— 補一個字就好。

B 的第 3 輪,兩個測試檔在第 1 分鐘就寫完了,之後它一直沒有結束 —— 測試檔沒再變過,只在十幾分鐘後多建了一個空的設定檔。它在想什麼我看不到(log 只在結束時才寫),26 分鐘後我手動停掉。同樣沒有 bash 的 A,三輪都在兩分鐘內自己收工。

返工的類型(B 三輪合計,比總數更有用):

返工原因 次數 是誰的問題
預期值少了毫秒(...00Z 應為 ...00.000Z) 13 格式只寫在實作的註解裡,它讀得到,但沒照著寫
把 toThrow() 的回傳值當成錯誤物件用 4 把 Vitest 的 API 語意用錯了
日期算錯(時區換算、3650 天) 2 算錯了
測了不存在的行為(以為沒帶 Z 的時間字串會解析失敗) 1 把 JavaScript Date.parse 的行為判斷錯了

A 那邊,這四類一次都沒出現 —— 預期值是清單寫死的,它不用算、也不用猜格式。

修到全綠之後,突變檢查的結果(Stryker 對兩支檔案產生 56 個突變):

測試來源 殺掉率 存活
A 精準清單(三輪) 89.3%、89.3%、89.3% 6、6、6
B 一句話(三輪) 83.9%、87.5%、85.7% 9、7、8
對照:repo 原本那份 87.5% 7

A 三輪的存活突變是同一組 6 個,一個不差。

存活的突變在說什麼

總分只是一個數字,要看的是活下來的是哪幾個:

存活的突變 A B 對照 意義
拿掉 TRANSITIONS.get(status)?.get(event) 的 ?. 活 活 活 等價突變,要扣掉
TypeError 的訊息字串換成空字串(5 個) 活 活 活 規格沒規定訊息內容
this.name = 'IllegalTransitionError' 換成空字串 殺 活 活 清單 A9 寫了要驗 e.name
IllegalTransitionError 的訊息換成空字串 殺 一輪活 殺 B 第 1 輪沒驗訊息內容
typeof now !== 'string' 整條檢查拿掉 殺 兩輪活 殺 行為漏洞

第一列是等價突變。 拿掉 ?. 之後程式行為真的沒變 —— status 在前一行已經驗過白名單,get(status) 不可能是 undefined。這種突變殺不掉是正常的,把它算進失敗率,會讓這個指標開始說謊,然後你就會開始不信任它。跟 Day 13 講誤報要當 bug 修是同一件事:一個會冤枉你的檢查,終究會被忽略。扣掉它之後,A 是 50 / 55 = 90.9% —— 這是我人工扣除後的數字,Stryker 自己報的是 89.3%。

第二列不是等價突變 —— 訊息換成空字串,行為確實變了 —— 是規格沒要求的行為。 規格只說「丟 TypeError」,沒說訊息寫什麼。三組都沒驗訊息,這是對的 —— 硬要驗,就是把實作的措辭釘成規格。

第五列才是真的漏。 把這一行拿掉:

if (typeof now !== 'string') throw new TypeError(`now 必須是 ISO-8601 字串:${now}`)

B 有兩輪的測試照樣全綠。因為它們測了 null、空字串、亂寫的字串 —— 這些下一行 Date.parse 會回 NaN,照樣丟錯。只有一種輸入會穿過去:Date 物件。Date.parse(new Date(...)) 會先把 Date 物件轉成字串,再解析那個字串,得到一個正常的數字,函式就默默接受了。

清單 B6 把 Date 物件列成一個案例,所以 A 三輪都抓得到。這件事我不是今天才學到的 —— 8 月那份考卷,沒殺掉的兩個突變,當時打過的每個模型都一樣:

M1  一級邊界 <= 改成 <   charge(100) 少算      三個都沒抓到
M2  二級邊界 <= 改成 <   charge(500) 算錯級    三個都沒抓到

規格白紙黑字寫著 1–100、101–500、501 以上,每個模型都測了一般值,沒有一個去碰 100 和 500 本身。從那之後我的清單都把臨界點一個一個列出來。臨界點是出題的人的責任,不能指望它自己想到。

差在哪

同一個模型、同一段程式碼,兩種交代法的差別:

  • 殺掉率跟著交代方式走。 A 三輪分數一樣、存活的突變一樣 —— 至少在這三輪裡,模型的隨機性沒有反映到結果上。B 三輪三個分數。
  • 返工的類型說明了錯在誰。 B 的錯大多落在「預期值該是多少」那一類 —— 格式、時區、日期算術;另外是 Vitest 和 JavaScript 的語言細節。前一類正是清單替它回答掉的問題。A 三輪唯一的一次返工是 import 漏字,機械性的,修一個字。
  • 耗時也在飄。 B 三輪 114 秒到 26 分鐘;A 三輪都在兩分鐘內。清單等於告訴它「做完的樣子」,一句話沒有。
  • A 比對照組高。 地端照清單寫的測試(89.3%)比 repo 原本那份(87.5%)還多殺一個 —— 差別就是清單那一句「驗 e.name」。這不是地端比雲端強,是多的那一分,清單補得回來 —— 跟 27B 那一分是同一件事。

所以回到開頭那個等式。這篇量的是「穩不穩」,不是「省多少」—— 寫清單、跑突變、修返工的時間我沒有逐項計時,所以不能說它一定比較便宜。能說的是:寫清單花的力氣也算在「驗證與返工的成本」裡,它換來的是交回來少讀、少修,突變分數也可預期。要外包的不是「寫測試」這件事,是「把寫好的清單翻成測試碼」這件事。 前者要判斷,後者只要耐力。

我自己也被假綠燈騙了一次

A 第 2 輪跑完,我的彙整腳本印出這一行:

Tests  13 passed (13)

全綠。我差點就記成「零返工」。重跑一次、看完整輸出才發現上面還有一行:

FAIL  tests/local/due-at.test.js
ReferenceError: afterEach is not defined

due-at 那個檔案整個沒有載入,13 個 passed 全部來自另一個檔案。我的腳本只抓了「Tests」那一行 —— 我替地端設了裁判,卻沒替自己的裁判設裁判。

判定要看 exit code 和「跑了幾個檔案」,不能只看 passed 那個數字。這條本來就寫在我的外包筆記裡(Xcode 的 Executed 0 tests + TEST SUCCEEDED),換一個工具就又踩了一次。


總結

外包這件事最容易被誤解的地方,是把它當成「省錢」。

它其實是分類:把工作按「有沒有裁判」分開。有裁判的,才有資格談誰來做、做多便宜 —— 誰做還是會影響返工多少;沒裁判的,再便宜都不能交出去。而裁判本身也要分層:

層 裁判 它證明什麼
測試跑得過 Vitest 測試能跑
測試殺得掉突變 Stryker 測試真的在測東西
突變分數可信 等價突變扣掉、臨界點寫進清單 裁判不把等價突變算成漏測,也抓得到清單列出的臨界點

少了第二層,你只知道測試跑得動,不知道它有沒有用 —— 而一個沒有判別力的測試,比沒有測試更糟,因為它給你綠燈。

而同一個地端模型,給它一句話,它交回來的測試要修、分數在飄、有一輪停不下來;給它一份寫死預期值的清單,三輪結果一樣。差別不在模型,在清單。

這一篇留下的心法:

先問誰當裁判,再問誰來做:「跑得過」只證明能跑,測試的裁判是突變檢查 —— 而等價突變要扣掉,裁判才不會冤枉人。能外包的不是「寫測試」,是「把清單翻成測試碼」;預期值和臨界點寫死在清單裡,殺掉率才可預期;臨界點是出題的人的責任。

明天:換一個有歷史的專案 —— 一份當年手工做掉的需求,讓 OpenSpec 重拆一次,看它逼我回答哪些我當年沒回答的問題。


參考資料


上一篇
Day 18 排程:要 Claude 每天早上自己跑,先把「做什麼」寫成腳本
下一篇
Day 20 同一份需求,當年我直接寫了,今天讓 OpenSpec 先問
系列文
盡信 Claude,不如無 Code — 心法與全端實戰 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言