第16章 测试策略与行为验证
2026.07.29测试一个自主 agent 系统——确定性测试覆盖确定性逻辑,隔离测试覆盖工具行为,真实 LLM 测试验证端到端能力。
模式层
16.1 三层测试金字塔
传统软件测试金字塔:单元测试 → 集成测试 → E2E 测试。
Agent 系统的测试金字塔需要针对 agent 的特殊性做调整:
┌──────────┐
│ 真实 LLM │ ← L3:真实 LLM 冒烟(门控,不默认运行)
│ 冒烟 │
┌┴──────────┴┐
│ 黑盒行为 │ ← L2:ScriptedProvider 模拟 LLM 响应
│ 验证 │
┌┴───────────┴┐
│ 离线单元 │ ← L1:StubClient 无 LLM 调用
│ 测试 │
└─────────────┘
L1:离线单元测试。 不涉及 LLM 调用。测试工具执行逻辑、权限查找链、消息序列化/反序列化、work graph 调度。使用 StubClient——一个返回固定响应的 LLM client。L1 测试是默认运行的(cargo test)。
L2:黑盒行为验证。 使用 ScriptedProvider——从预先录制的响应序列中播放,模拟 LLM 的对话行为。测试 agent 在给定 LLM 响应下的行为是否符合预期:工具调用顺序是否正确、验收门是否触发、子 agent 是否按预期创建。L2 测试默认运行,但标记为 #[cfg(feature = "integration")] 门控。
L3:真实 LLM 冒烟。 连接真实的 LLM provider,测试 agent 的端到端能力。需要设置 CODECODER_API_KEY。L3 测试默认不运行(#[ignore]),需要显式触发。
| 层级 | 依赖 | 速度 | 覆盖范围 | 默认运行 |
|---|---|---|---|---|
| L1 | 无 | 毫秒级 | 工具、权限、消息模型 | ✅ |
| L2 | ScriptedProvider | 秒级 | agent 行为、验收流程 | ✅ |
| L3 | 真实 LLM API | 分钟级 | 端到端能力 | ❌ |
16.2 Agent 测试的特殊挑战
Agent 系统的测试面临两个特殊挑战:
挑战一:非确定性输出。 同一个 prompt 在不同时间、不同模型版本下可能产生不同的输出。L2 层的 ScriptedProvider 解决了这个问题——通过预录制 LLM 响应,测试可以确定性验证 agent 行为。
挑战二:状态依赖。 Agent 的行为取决于当前上下文状态。测试需要精确控制 agent 的起始状态——包括 system prompt、session 历史、work graph 状态。L2 测试通过在测试前重置状态、注入预定义的上下文来解决。
案例层
16.3 StubClient
StubClient 是 L1 测试的 LLM provider。它不调用真实 API,而是返回固定响应:
struct StubClient {
response: String,
}
impl ProviderClient for StubClient {
fn send(&self, _request: &Request) -> Result<Response> {
Ok(Response {
content: vec![ContentItem::Text(self.response.clone())],
tool_calls: vec![],
})
}
}
使用场景:测试工具执行逻辑、权限查找链、work graph 调度——这些逻辑不依赖 LLM 的具体输出,只需要 agent 按正确的顺序调用工具。
#[test]
fn test_tool_permission_chain() {
let client = StubClient::new("read src/mod.rs");
let agent = AgentLoop::new(client, default_permissions());
let result = agent.process_turn();
assert!(result.is_ok());
// Verify the agent called the read_file tool
assert!(agent.tool_calls().contains("read_file"));
}
16.4 ScriptedProvider
ScriptedProvider 是 L2 测试的 LLM provider。它从预录制的响应序列中播放:
struct ScriptedProvider {
responses: VecDeque<Response>,
}
impl ScriptedProvider {
fn from_file(path: &str) -> Result<Self> {
let content = fs::read_to_string(path)?;
let responses: Vec<Response> = serde_json::from_str(&content)?;
Ok(Self {
responses: VecDeque::from(responses),
})
}
}
impl ProviderClient for ScriptedProvider {
fn send(&mut self, request: &Request) -> Result<Response> {
// Verify the request meets expectations (optional)
self.verify_request(request)?;
// Return the next pre-recorded response
self.responses.pop_front()
.ok_or_else(|| "no more responses".into())
}
}
使用场景:测试 agent 在给定 LLM 响应下的行为。比如 LLM 返回了两个 ToolCall——agent 应该依次执行它们;LLM 返回了 NeedsFix 信号——审查门应该触发 needs_fix 流程。
#[test]
fn test_two_tool_calls_in_sequence() {
let provider = ScriptedProvider::from_file("tests/fixtures/two-tool-calls.json")?;
let agent = AgentLoop::new(provider, default_config());
let result = agent.process_turn();
assert!(result.is_ok());
// Verify the agent executed two tool calls in sequence
assert_eq!(agent.executed_tools().len(), 2);
assert_eq!(agent.executed_tools()[0].name(), "glob");
assert_eq!(agent.executed_tools()[1].name(), "read_file");
}
16.5 L3 真实 LLM 冒烟
L3 测试使用真实 LLM provider,验证 agent 的端到端能力:
#[test]
#[ignore] // Not run by default
fn test_end_to_end_refactoring() {
// Set up real LLM provider
let provider = OpenAIClient::from_env()?;
let agent = AgentLoop::new(provider, full_config());
// Send the task
agent.process_message("Extract module A's interface into a standalone trait")?;
// Verify the result
let workgraph = agent.workgraph();
assert!(workgraph.is_completed());
assert!(workgraph.all_milestones_done());
// Verify the file was modified
assert!(Path::new("src/interface.rs").exists());
}
L3 测试的门控策略:
- 不在 CI 中默认运行(需要
CODECODER_API_KEY) - 标记为
#[ignore],需要显式cargo test --include-ignored运行 - 使用独立的测试项目(
tests/fixtures/),不修改主项目代码
16.6 测试统计
CodeCoder 写作时的测试统计:
测试总数:481
L1(离线单元测试):~450
L2(黑盒行为验证):~28
L3(真实 LLM 冒烟):3(全部 #[ignore])
L1 测试覆盖了工具执行、权限查找链、消息序列化、work graph 调度、compaction 等核心逻辑。L2 测试覆盖了验收 pipeline、子 agent 创建、headless 运行等行为路径。L3 测试只覆盖最关键的三条端到端路径(重构、审查、headless 运行)。
16.7 测试作为活的规格
CodeCoder 的一个设计原则:测试是活的规格。
传统上,规格文档和测试是分离的——规格文档描述"系统应该做什么",测试验证"系统确实做了什么"。当规格和测试不一致时,往往测试是准确的(代码不会撒谎)。
CodeCoder 的测试策略更进一步:测试代码直接反映了系统的行为契约。权限测试描述了"什么操作需要什么级别的权限"。验收测试描述了"什么条件算里程碑完成"。这些测试不仅仅是验证工具——它们是系统行为的可执行规格。
一个例子——权限 key 的测试:
#[test]
fn test_permission_key_precision() {
// Verify that run_command:git does not match git status && git push
let key = PermissionKey::parse("run_command:git");
assert!(key.matches("git status")); // Simple command matches
assert!(!key.matches("git status && git push")); // Compound command does not match (hashed keying)
}
这个测试同时是:测试代码(验证行为)、规格文档(描述"复合命令的 keying 规则")、契约(告诉维护者"修改这个规则时需要更新哪些测试")。
ADR 深度阅读
测试体系的分层演进
CodeCoder 的测试体系经历了从"单层"到"三层"的演进。
初始阶段(单层): 只有 L1 测试,使用 StubClient。测试覆盖工具逻辑和权限检查,但不覆盖 agent 行为路径。问题:agent 行为路径的变化(如 LLM 返回了意外的 ToolCall)不会被测试捕获。
第二阶段(双层引入): 引入 L2 测试(ScriptedProvider)。通过录制 LLM 响应序列,可以验证 agent 在给定输入下的行为。验收 pipeline、子 agent 创建、headless 运行的测试都在这一阶段加入。
第三阶段(L3 冒烟引入): 引入 L3 测试(真实 LLM)。L2 测试验证了 agent 在给定 LLM 响应下的行为,但"LLM 是否会在真实场景下生成预期的响应"不在 L2 的覆盖范围内。L3 测试使用真实 LLM 验证端到端路径——但出于成本和确定性考虑,L3 测试默认不运行。
三层测试体系的引入顺序反映了 CodeCoder 的演进路径:先确定性逻辑(L1),再行为路径(L2),最后端到端验证(L3)。每一层的引入都是因为前一层覆盖不了的问题在真实使用中暴露了。
——技术卷正文完——
五编 16 章覆盖了从哲学基础到工程实践的完整体系。如果按顺序读到这,你应该已经掌握了设计一个自主 agent 系统所需的全部核心判断框架和工程实现细节。
附录包含术语表、项目数据页和 ADR 索引。请翻阅附录获取完整参考。