測試策略
這一頁描述的是 orchestration 的目標設計,不一定等於目前程式碼的實際行為。原文刻意不討論遷移與向後相容。
V2 應該用少量高價值的整合測試來驗證,而不是用一大套單元測試,把正要測的行為都 mock 掉。
目標並不是「完全不用測試替身(test double)」。目標是:測試替身只出現在真正的行程、網路、時鐘、id 和檔案系統邊界上。核心的 orchestration 行為必須真的執行。
測試原則#
V2 測試的預設形態是:
command dispatch
-> real Orchestrator
-> real ProviderAdapter
-> replayed ProviderRuntime transport
-> real adapter normalizer
-> real V2 event store/sink using production persistence semantics
-> real V2 projection/projector
-> real Checkpoint policy
-> assertions
Replay 框架取代的是外部的 Provider 行程或網路串流。它不取代 adapter、normalizer、command/event 基礎設施、projection reducer/projector、checkpoint 政策,也不取代商業邏輯。
Replay transcript(錄製下來的紀錄)裡的原始 Provider frame,是貼近真實的 transport 證據。在正式環境裡,對等的原始 frame 是診斷用的 log 資料,保留期限有上限。整合測試仍然應該使用真正的、正規化之後的 orchestration 持久化與 projection,這樣 replay 的輸入走過的 adapter 與 orchestration 路徑,才會和即時的 Provider 輸出相同。
允許的測試替代品#
允許的替代品:
- Provider runtime transport,由決定性的 replay transcript 支撐。
- Effect runtime 的時間,在測試中用
effect/testing的TestClock控制。 - Effect 的
Random,提供一個決定性的測試實作,讓 UUID/數字保持穩定。 - 暫時的檔案系統/worktree。
- 暫時的資料庫,或是具有相同 repository 介面的記憶體內資料庫。
- 假的 process supervisor,只在直接測試行程失敗行為的時候使用。
整合測試中不允許:
- mock 的 orchestrator。
- mock 的 Provider adapter。
- mock 的 Provider event normalizer。
- mock 的 command/event 基礎設施或 V2 event sink。
- mock 的 projection reducer。
- mock 的 checkpoint service 行為。
- mock 的 Provider capability 政策。
- 把預先正規化好的 domain event 當成 adapter 測試的輸入。
- 自訂的時鐘/id 服務,重複了 Effect 的
Clock、DateTime或Random服務已經做的事。
純 reducer 測試仍然有效,但數量應該少,而且有明確的對象。它們應該直接測試 projection 的不變條件(invariant),而不是用來取代整合測試的涵蓋範圍。
正式環境的程式碼應該透過 Effect runtime 的 API 讀取時間,例如 DateTime.now 和 Clock.currentTimeMillis,而不是透過 Date.now 或臨時寫的包裝函式。正式環境的程式碼應該透過 effect/Random 取得隨機值,而不是直接用 crypto.randomUUID、Math.random,或自訂的全域 id 產生器。
測試應該提供 Effect 的測試服務:
import { TestClock } from "effect/testing";
Id 配置器仍然可以提供領域專屬的輔助函式,例如 newRunId 或 newNodeId,但這些輔助函式應該建立在 Random 之上,這樣測試用的 layer 就能產生決定性的值,而不必 mock orchestration 邏輯。
通用的 replay runtime#
Replay 必須不偏向任何 Provider。Codex 的 NDJSON fixture 只是某一個 Provider 的 transcript 格式,不是框架本身。
type ProviderReplayTranscript = {
provider: ProviderKind;
protocol: string;
version: string;
scenario: string;
entries: ProviderReplayEntry[];
};
type ProviderReplayEntry =
| {
type: "expect_outbound";
label?: string;
frame: unknown;
}
| {
type: "emit_inbound";
label?: string;
frame: unknown;
afterMs?: number;
}
| {
type: "runtime_exit";
status: "success" | "error" | "cancelled";
error?: unknown;
};
Replay runtime 負責決定性的 transport 語意:
- 依序送出 inbound event。
- 對 outbound command 做斷言。
- 暫停/繼續,以及時間控制。
- 模擬 runtime 結束/錯誤。
- resume cursor/session 的還原。
- transcript metadata 的驗證。
Replay runtime 不可以知道 turn、plan、approval、subagent 或 checkpoint 是什麼意思。Provider 專屬的 frame 由 Provider adapter 來解讀。
復原測試只在 Provider transport 邊界使用 replay。App 重新啟動的測試方式,是把最外層的 orchestrator/server layer 拆掉,再對著持久化的資料重新建立。閒置清理和當機復原,則透過正式環境的生命週期服務來測試,例如 session reaper 或 runtime 復原政策。測試不可以在 adapter 或 orchestrator 上新增「只為了讓斷言能重啟 session」的方法。
各 Provider 的 transcript 格式#
每個 Provider 都可以在通用的 replay 外層格式(envelope)裡,使用自己的原始 frame 格式。
例子:
Codex replay transcript
-> JSON-RPC app-server requests/responses/notifications
-> consumed by CodexAdapter
Claude replay transcript
-> Claude Agent SDK query() outbound options and yielded SDKMessage chunks
-> consumed by ClaudeAdapter
Cursor replay transcript
-> Cursor Agent SDK open/send calls and ordered onDelta/run results
-> consumed by CursorAdapter
OpenCode replay transcript
-> OpenCode SDK requests/responses plus ordered SSE events
-> consumed by OpenCodeAdapter
ACP replay transcript
-> logical JSON-RPC requests/responses/notifications over a strict NDJSON peer
-> consumed by AcpAdapter and a provider flavor (Grok or ACP Registry)
Fixture 應該盡可能原樣保留 Provider 的原始證據。預期的 V2 event 或 projection 是斷言,不是 fixture 的輸入。
以 Claude 來說,一開始的 replay 邊界是 Agent SDK 的 query() 呼叫所回傳的 async iterable。Transcript 應該包含我們送出的 query prompt/options,然後依 Provider 的順序重播原始的 SDKMessage chunk。這是刻意要拿真實的 Claude SDK 輸出來測試 V2 adapter;它不測試 SDK 自己的子行程或 transport parser。
ACP 的 fixture 是對著一個子行程 replay peer 執行,這個 peer 會驗證每一個 outbound frame,並送出錄好的 inbound frame。這只取代外部的 ACP driver transport;共用的 runtime、adapter 的正規化、orchestration、持久化和 projection 仍然是正式環境的程式碼。通用的 ACP Registry harness 會把符合協定標準的 ACP transcript 重新指向(retarget);Provider 擴充的 transcript 則維持只限於擁有該擴充的 flavor。
OpenCode 的 fixture 在 HTTP/SSE 邊界上取代 SDK client。它們必須保留「請求的回應」和「SSE event」之間的競態(race),因為使用者訊息和終結的 session.status event,可能比對應的 promptAsync 或 abort 回應更早到達。Subagent 的 fixture 必須同時保留父與子的 session id,這樣「只有 root 才算終結」的行為才測得到。
Provider transcript 的錄製工具放在 server 的 orchestration testkit 裡,而不是放在各 Provider 的 client 套件裡。Codex app-server 的 transcript 用 bun run record:codex-replay -- --scenario <name> 錄製,Claude Agent SDK 的 transcript 用 bun run record:claude-replay -- --scenario <name> 錄製。Cursor Agent SDK 的 transcript 用 pnpm --filter t3 record:cursor-replay --scenario <name> 錄製。
Contract 測試的層級#
建議的層級:
- V2 contract 的 schema 測試。
- 針對硬性不變條件的純 projection 測試。
- Provider adapter 的 replay 測試:從原始 transcript 到 V2 domain event。
- 完整的 orchestration 整合測試:從 command 經過 replay runtime 到最終的 projection。
第四層是最重要的一層。就是這種測試,才抓得到生命週期對不上的問題,例如子 turn 把父 run 關掉,或是 checkpoint 太早被擷取。
最初的十個整合測試#
V2 應該從大約十個扎實的測試開始:
simple:送出一則訊息,會建立一個 run、一個 root node、一個 Provider turn、一則 assistant 回應,以及一個 root checkpoint。multi_turn:後續的訊息會在同一個 app thread 上建立順序單調遞增的 app run。message_steering:steering 會附加到進行中 run 的意圖上,而不是變成一個不相干的 run。turn_interrupt:interrupt 得到確認(acknowledgement)並不會讓 run 完成,要等 Provider 的終結 event 到達才算。steering_restart_fallback:沒有原生 steering 的 Provider,會中斷進行中的 attempt,並在同一個 run 底下建立一個替代的 attempt。subagent:子 Provider turn 會建立巢狀的 execution node,而且絕對不會讓父 run 完成。subagent_checkpoint:子代/subagent 的 node 會建立巢狀的 checkpoint scope,但不會讓 app 的 run 計數往前推進。thread_rollback:rollback 以 checkpoint scope 為目標,並與 Provider rollback 的 snapshot 進行對帳(reconcile)。approval_request:Provider 的核准 callback 會變成持久化的 runtime request,並透過真正的 adapter 路徑解決。provider_switch_return:從某個 Provider 切走會建立一份 context handoff;切回來時則 resume 先前的 Provider thread,並附上一份差異(delta)handoff。
只有在能保護一個新的不變條件,或是能重現一個真實的失敗模式時,才應該增加額外的測試。
Fixture 規則#
- Fixture 是原始的 Provider transcript,不是 mock 出來的 domain event。
- Fixture 應該包含足夠的 outbound 預期,足以證明 app 送出了正確的 Provider command。
- Fixture 應該包含協定 metadata、Provider 版本、模型、cwd 政策,以及擷取時的時間戳記。
- Fixture 的重播在 Effect
TestClock和決定性的Randomlayer 之下,必須是決定性的。 - 當 Provider transcript 是從真實的執行產生時,要保留 Provider frame 原本的順序。
- 遮蔽敏感資料(redaction)時,應該保留 id、方法名稱、生命週期順序,以及關聯結構。
- Fixture transcript 是測試輸入,也是診斷用的證據。這不代表正式環境會把所有原始的 Provider frame 存進 SQLite。
斷言#
斷言應該優先針對最終的 projection 和持久化、正規化之後的 orchestration event,而不是實作上碰巧發生的呼叫。
好的斷言:
- 重複 dispatch 同一個 command,會回傳原本的 receipt sequence,而不會重播 Provider transport。
- 已儲存 event 的 sequence 單調遞增。
- snapshot 的 sequence,加上「從某個 sequence 之後開始串流」的行為。
- run 的狀態與 ordinal。
- 進行中/最終的 run attempt。
- execution node 的父/子結構。
- Provider thread 與 Provider turn 的關聯。
- checkpoint scope 的階層。
- pending/已解決的 runtime request。
- handoff 的涵蓋範圍與策略。
- replay transcript 的 frame 數量,以及正規化後的 event 數量(在相關的時候)。
薄弱的斷言:
- 內部函式確切被呼叫了幾次。
- 私有輔助函式的呼叫順序。
- adapter/runtime 邊界以下,mock callback 收到的引數。
實作順序#
測試基礎設施應該在改寫正式環境程式碼之前就先建好:
- V2 contract schema。
- Effect service 定義。
- Provider runtime transport 的抽象層。
- 通用的 replay runtime。
- Codex、Claude、Cursor、OpenCode 和 ACP 的 transcript loader,用於由 app 擁有的 Provider replay fixture。
- 針對核心不變條件的 projection reducer 測試。
- 完整的 command 到 projection 整合測試。
- 正式環境的 layer。
這樣做可以讓架構在建造的過程中就一直是可以執行的,也避免測試只驗證到簡化過的 mock。
本頁譯自 docs/orchestration-v2/testing-strategy.md(英文原文,版本 f6fb27a)。標示「本站補充」的區塊不在原文裡。