這個產品的核心功能會呼叫別人的模型 API。那件事很難測,因為它慢、要錢,而且同樣的輸入不保證得到同樣的輸出。
我可以只測到呼叫之前為止。但編纂、對話、健檢就是那一段。
測模型迴圈時,我起一台真的 HTTP 伺服器,把 base URL 指過去。整段編纂另有一支測試用 registerRunner 換掉 runner,那支不打模型,在行程裡把工具叫完。模型來回的那一段走下面這台伺服器:
const seen: any[] = [];
const fx = createServer(async (req, res) => {
let body = ''; for await (const c of req) body += c;
seen.push(JSON.parse(body));
assert.equal(req.headers['x-api-key'], 'sk-ant-test');
res.setHeader('content-type', 'application/json');
if (seen.length === 1) res.end(JSON.stringify({ id: 'm1', type: 'message', role: 'assistant', model: 'claude-sonnet-5', stop_reason: 'tool_use', stop_sequence: null, content: [{ type: 'text', text: '先看目錄。' }, { type: 'tool_use', id: 'tu1', name: 'list_folder', input: { path: 'wiki' } }], usage: { input_tokens: 11, output_tokens: 6 } }));
else res.end(JSON.stringify({ id: 'm2', type: 'message', role: 'assistant', model: 'claude-sonnet-5', stop_reason: 'end_turn', stop_sequence: null, content: [{ type: 'text', text: '完成' }], usage: { input_tokens: 25, output_tokens: 4 } }));
}).listen(0, '127.0.0.1');
await new Promise(r => fx.once('listening', r));
process.env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${(fx.address() as AddressInfo).port}`;
出處:test/ingest.test.ts:125-135
listen(0) 讓作業系統給一個空的埠,所以測試之間不會互搶。第一次請求回一個工具呼叫、第二次回結束,這就是一次完整的 agent 迴圈,而且完全確定。
被測的那一邊長這樣,那些「憑空出現」的值都是從這裡傳進去的:
const events: string[] = [];
const r = await runAgent({ provider: 'anthropic', model: 'claude-sonnet-5', apiKey: 'sk-ant-test', system: 's', user: 'u', maxSteps: 5,
tools: [{ name: 'list_folder', description: 'd', input_schema: { type: 'object', properties: { path: { type: 'string' } } } }],
exec: async (name, input) => `${name}:${input.path}`, onEvent: e => events.push(e.type) });
出處:test/ingest.test.ts:137-140
apiKey 決定假伺服器會收到什麼 x-api-key,system: 's' 是那個一個字母的系統提示詞,exec 是假的工具實作(回傳 工具名:參數,所以斷言看得出工具有沒有被正確呼叫),onEvent 把事件型別收進 events 陣列。
比起 mock 套件,這個做法多了一個關鍵能力:seen 陣列裡是真的送出去的請求。所以斷言可以下在「我送給模型的東西對不對」,而不只是「我有沒有呼叫那個函式」:
const last = seen[1].messages.at(-1);
assert.equal(last.role, 'user'); assert.equal(last.content[0].type, 'tool_result'); assert.equal(last.content[0].tool_use_id, 'tu1'); assert.equal(last.content[0].content, 'list_folder:wiki');
assert.equal(seen[0].system, 's'); assert.equal(seen[0].tools[0].name, 'list_folder');
出處:test/ingest.test.ts:143-145
這幾行驗的是送出去的 Anthropic 請求:系統提示詞、工具名稱,以及工具結果被包成 tool_result 再送回去。之前我們已經講過,OpenAI 相容的那一套格式不同,用的是 role: 'tool'。下一支測試起另一台伺服器,斷言的就是那個欄位。
assert.deepEqual(events, ['usage', 'text', 'tool', 'result', 'usage', 'text']);
進度面板是即時的,所以事件的種類與順序就是使用者看到的東西。少一個 usage 事件,費用統計就會少算;tool 跟 result 的順序反了,畫面上會先出現結果再出現呼叫。
這種斷言很脆,改一點實作就會紅。我還是留著,因為它蓋住的正是使用者會看到的那一層。
之前我們已經講過,壓力測試不能打第三方網站。測試更嚴格:推上 main 或開 pull request 就會跑。
所以匯入的測試也起一台 fixture 伺服器:
if (req.url?.startsWith('/paper')) { res.setHeader('content-type', 'text/html; charset=utf-8'); res.end(PAPER_HTML); return; }
if (req.url?.startsWith('/spa')) { res.setHeader('content-type', 'text/html; charset=utf-8'); res.end('<html><head><title>SPA 課程</title></head><body><div id="root"></div><script>document.getElementById("root").innerHTML = "<h1>Claude Cowork 簡介</h1><p>" + "這段內容是瀏覽器端用 JavaScript 產生的,伺服器端抓不到。".repeat(12) + "</p>";</script></body></html>'); return; }
if (req.url?.startsWith('/thin')) { res.setHeader('content-type', 'text/html'); res.end('<html><head><title>App</title></head><body><div id="root"></div><script>/* SPA */</script></body></html>'); return; }
if (req.url?.startsWith('/challenge')) { res.setHeader('content-type', 'text/html'); res.end('<html><head><title>Just a moment...</title><meta name="citation_doi" content="10.1016/j.test.2020.1"></head><body><p>Enable JavaScript and cookies to continue</p></body></html>'); return; }
if (req.url?.startsWith('/crossref/')) {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ message: { title: ['Keyword Assignment in Medical Libraries'], author: [{ family: 'Chen', given: 'Amy' }, { family: 'Lin', given: 'Bo' }], issued: { 'date-parts': [[2024, 3]] }, 'container-title': ['Journal of the Medical Library Association'] } }));
return;
}
/paper 是正常文章,HTML 在 PAPER_HTML。/spa 的正文由 JavaScript 寫進頁面。/thin 只有一個空的 div#root。/challenge 的標題是 Just a moment...,同時帶了 citation_doi。/crossref/ 回一份假的書目 JSON。
/challenge 要測的是兩個條件交會:頁面被擋下來,但有 DOI。測試斷言它被收成一篇論文、DOI 對得上,作者來自那份假的 Crossref。有書目就不開瀏覽器,是之前已經講過的匯入條件。
字數少於兩百才算內容太薄,是之前那條規則。/thin 比這條線更空。剛好卡在兩百字的頁,真實網站很少有,fixture 做得出來。
測試連的是真的 PostgreSQL。CI 走 GitHub Actions,公開 repo 用 GitHub 提供的 runner,不另外計費。workflow 在推上 main 或開 pull request 時跑,裡面直接開一個容器:
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: wikibrain
POSTGRES_PASSWORD: wikibrain
POSTGRES_DB: wikibrain
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U wikibrain" --health-interval 5s --health-timeout 5s --health-retries 20
出處:.github/workflows/test.yml:9-18
理由是這個產品的邏輯有很大一部分就寫在 SQL 裡:樂觀鎖的 WHERE version = $5、待編纂的 NOT EXISTS、軟刪除復活的 ON CONFLICT … WHERE deleted_at IS NOT NULL。換成替身等於把要測的東西換掉。
少了健康檢查,測試會在資料庫還沒接受連線的時候就開始跑,於是你得到一個每十次紅一次、重跑就好的 CI。那種測試會訓練你忽略紅燈。
這支匯入測試自己註冊一個帳號,結束時刪掉:
await auth.api.signUpEmail({ body: { email, password, name: 'im' } });
await pool.query(`UPDATE "user" SET "emailVerified" = true WHERE email = $1`, [email]);
第二行是繞過驗證信的方式。設定裡開了 requireEmailVerification,所以剛註冊的帳號登不進去;測試不去收信,直接把那個欄位改成 true。帳號資料裡只有這個欄位是直接改的,註冊和登入仍走正式那條。
不共用固定資料。共用的測試資料會長出隱性依賴:某支測試改了一頁,另一支莫名其妙紅了。
最外層有一支 Playwright 腳本 test/e2e/m3_web.py,開真的瀏覽器。它做的是登入、改走「貼上文字」匯入、確認頁面出現在 raw/、再用 MCP 更新同一頁,看版本衝突時畫面會不會重載。匯入對話框預設停在貼網址,腳本改去貼文字。它沒有按編纂,註解寫著不要真的送出,避免花錢。
單元測試驗邏輯,這支驗畫面與 API 接起來之後還會不會動。它不進 CI。test.yml 的最後一步是 npm test,沒有這支腳本。它要本機的前後端都先跑起來,手動執行。
以上全部沒有測到一件事:模型實際上會不會照規則做。
假的模型端點回的是我寫好的腳本,所以它一定照規則做。真實的模型會不會在第四步先搜尋、會不會把佔位符當字面值抄進去、會不會在規則說「一事一頁」時真的只開一頁,這些沒辦法用斷言鎖住。
我的做法是把它當成觀察:每次改提示詞或工具描述,跑幾次真的編纂,讀產出的頁面跟工作紀錄。
還有一類東西也鎖不住:只有在第二個使用者同時在用的時候才會出現的行為。那需要的是一組會互相干擾的請求,也就是壓力測試在做的事。
模型來回由本機一台 HTTP 伺服器回答。作業系統配一個空埠,第一次回工具呼叫,第二次回結束。斷言看的是送出去的那份請求:系統提示詞是 s,工具名叫 list_folder,工具結果帶著 tool_result 和 tu1 再送回去。事件順序鎖成 usage、text、tool、result、usage、text。
匯入同樣不打外面的網站。被擋下來但有 DOI 的頁收成論文,作者從假的 Crossref 來;/thin 是空的 div#root。CI 開 PostgreSQL 16,健康檢查通過才跑,樂觀鎖、待編纂、軟刪除復活都打在真的 SQL 上。測試自己註冊帳號,只把 emailVerified 改成 true。瀏覽器那支腳本登入、改走貼上文字、確認頁面在 raw/,版本衝突時畫面會重載。它沒有按編纂,也不在 npm test 裡。假伺服器一定照腳本回答,模型會不會照規則寫頁,要另外跑真的編纂,讀頁面和工作紀錄。