第十讲. 跑通完整流程才算真正验证
单元测试通过后,agent 经常会说"做完了",但端到端运行时才会暴露真正的问题。举例来说,让 agent 给 Electron 应用加一个文件导出功能,它写了渲染进程组件、预加载脚本、服务层逻辑,每个组件的单元测试都通过了。agent 说"做完了"。实际点击导出按钮时:文件路径格式不对、进度条没反应、大文件导出时内存泄漏。5 个组件边界缺陷,单元测试一个都没发现。
每个部分单独看都"对"了,但拼在一起就出了问题。Google 的测试金字塔告诉我们,大量单元测试是基础,但如果你止步于此,就会系统性地漏掉组件交互问题。对于 AI 编码 agent 来说,这个问题更严重,因为 agent 倾向于只跑最快的测试然后宣告完成。只有端到端测试能证明系统级缺陷不存在。
单元测试的盲区
单元测试的设计哲学是隔离:模拟依赖,专注被测单元。这个哲学使单元测试快速且精确,但也制造了系统性的盲区。每个模块在隔离环境中表现完美,但真正拼在一起运行时才会暴露以下几类问题:
接口不匹配:渲染进程传给预加载脚本的文件路径是相对路径,但预加载脚本期望绝对路径。各自的单元测试都用了 mock,都通过了。只有端到端跑通时才发现问题。
状态传播错误:数据库迁移改了表结构,但 ORM 的缓存层还持有旧结构的缓存条目。单元测试每次都是全新的 mock 环境,不会暴露这种跨层状态不一致。
资源生命周期问题:文件句柄、数据库连接、网络套接字的获取和释放跨越多个组件。单元测试为每个测试创建和销毁独立资源,不会暴露资源竞争或泄漏。
环境依赖性:代码在测试环境(一切 mock)行为正确,在真实环境因配置差异、网络延迟、服务不可用而失败。
端到端测试同时影响结果与行为
这是很多人没意识到的一点:当 agent 知道它的工作要过端到端测试时,它的编码行为会改变。
- 考虑组件交互:写代码时会想"这个接口和上游怎么对接",不只关注单个函数。
- 尊重架构边界:有架构约束的系统里,端到端测试迫使 agent 遵守边界规则。
- 处理错误路径:端到端测试通常包含故障场景,迫使 agent 考虑异常处理。
测试金字塔与审查反馈提升
flowchart TB
subgraph Unit["单元测试只看孤立部件"]
U1["渲染层测试"]
U2["Preload 测试"]
U3["服务层测试"]
end
subgraph E2E["端到端运行会穿过真实系统"]
R["点击渲染层按钮"] --> P["Preload 桥"]
P --> S["服务层"]
S --> F["文件系统 / 操作系统"]
F --> Result["真实导出文件"]
end
flowchart LR
Review["审查意见:<br/>renderer 不能直接 import fs"] --> Rule["加一条 direct fs import 检查"]
Rule --> Message["报错里直接告诉 agent<br/>把文件访问移到 preload"]
Message --> Harness["把这条检查加入 harness"]
Harness --> Stronger["以后再犯会第一时间报错"]
OpenAI 在 Codex 工程实践中强调:为 agent 写的错误消息必须包含修复指导。不写 "Direct filesystem access in renderer",而写 "Direct filesystem access in renderer. All file operations must go through the preload bridge. Move this call to preload/file-ops.ts and invoke it via window.api." 这把架构规则变成了自动修正的闭环。错误消息不只是告诉你"出了什么问题",还要告诉你"该怎么改",让 agent 能够自主完成修正。
核心概念
- 组件边界缺陷:组件 A 和 B 各自单元测试通过,但它们的交互产生了不正确的行为。这是端到端测试最擅长捕获的问题类型。
- 测试充分性梯度:单元测试能检测的缺陷 <= 集成测试能检测的缺陷 <= 端到端测试能检测的缺陷。每往上一层,检测能力增强。
- 架构边界执行规则:把架构文档里的规则(如"渲染进程不能直接访问文件系统")变成可执行的自动化检查,从"写在纸上"变成"跑在 CI 里"。
- 审查反馈提升:把重复出现的代码审查意见转化为自动化测试。每次发现重复问题就加一条规则,harness 会自动变强。
- 面向 agent 的错误消息:失败信息不只是说"出了什么问题",还要告诉 agent 具体怎么修,把测试失败变成自我修正的反馈循环。
实施方法
0. 先定好架构边界,再写端到端测试
端到端测试的前提是系统有清晰的边界。如果架构是一团面条,端到端测试只会证明"这团面条整体能跑",不会告诉你哪里违反了设计意图。
OpenAI 的经验:对 agent 生成的代码库,架构约束必须是第一天就建立的早期前置条件,不是等团队规模大了再考虑的事。 原因很直接:agent 会复制仓库中已有的模式,即使那些模式是不均匀的或次优的。没有架构约束,agent 会在每次会话中引入更多偏差。
OpenAI 采用了"分层领域架构",每个业务领域被分成固定的层:Types → Config → Repo → Service → Runtime → UI。依赖方向严格向前,跨领域关注点通过显式的 Providers 接口进入。任何其他依赖都是禁止的,并且通过自定义 lint 机械执行。
关键原则:执行不变量,不微管实现。 比如要求"数据在边界解析",但不规定用哪个库。错误消息要包含修复指导,要告诉 agent 具体怎么改,不只说"违规了"。
来源:OpenAI: Harness engineering: leveraging Codex in an agent-first world
1. harness 必须包含端到端层
在你的验证流程里明确:对于涉及跨组件修改的任务,端到端测试通过是完成的前置条件:
## 验证层级
- 层级 1: 单元测试 (必须通过)
- 层级 2: 集成测试 (必须通过)
- 层级 3: 端到端测试 (涉及跨组件修改时必须通过)
- 跳过任何必须层级的任务 = 未完成
2. 把架构规则变成可执行检查
每条架构约束都应该有对应的测试或 lint 规则:
# 检查渲染进程是否直接调用 Node.js API
grep -r "require('fs')" src/renderer/ && exit 1 || echo "OK: no direct fs access in renderer"
3. 设计面向 agent 的错误消息
失败信息要包含三要素:什么出了问题、为什么、怎么修:
ERROR: Found direct import of 'fs' in src/renderer/App.tsx:12
WHY: Renderer process has no access to Node.js APIs for security
FIX: Move file operations to src/preload/file-ops.ts and call via window.api.readFile()
4. 建立审查反馈提升流程
每次在代码审查中发现新类型的 agent 错误,就把它变成自动化检查。一个月后你的 harness 会比月初强得多。
实际案例
任务:在 Electron 应用中实现文件导出功能。涉及渲染进程 UI、预加载脚本文件系统代理、服务层数据转换。
单元测试阶段:渲染组件测试(通过,mock 文件操作)、预加载脚本测试(通过,mock 文件系统)、服务层测试(通过,mock 数据源)。agent 声明完成。
端到端测试揭示的缺陷:
| 缺陷 | 描述 | 单元测试 | 端到端 |
|---|---|---|---|
| 接口不匹配 | 文件路径格式不一致 | 未检测 | 检测 |
| 状态传播 | 导出进度未通过 IPC 传回 UI | 未检测 | 检测 |
| 资源泄漏 | 大文件导出句柄未释放 | 未检测 | 检测 |
| 权限问题 | 打包环境权限不同 | 未检测 | 检测 |
| 错误传播 | 服务层异常未到 UI 层 | 未检测 | 检测 |
5 个缺陷全部被端到端测试捕获,单元测试一个都没发现。代价是测试时间从 2 秒增加到 15 秒,在 agent 工作流里完全可以接受。
核心要点
- 单元测试对组件边界缺陷系统性盲视:它们的隔离设计恰好使其无法检测交互问题。
- 端到端测试不仅检测缺陷,还改变 agent 的编码行为:让它更关注集成和边界。
- 架构规则必须可执行:每次提交自动检查,不能只写在文档里等人来看。
- 错误消息要面向 agent 设计:包含"怎么修"的具体步骤,形成自我修正闭环。
- 审查反馈提升让 harness 自动变强:每个被捕获的缺陷类别都变成永久防线。
延伸阅读
- How Google Tests Software - Whittaker et al. — 测试金字塔模型的经典来源
- Harness Engineering - OpenAI — 架构约束自动化执行的工程实践
- Chaos Engineering - Netflix (Basiri et al.) — 主动注入故障验证系统弹性
- QuickCheck - Claessen & Hughes — 属性测试方法,介于示例测试和形式化验证之间
练习
- 跨组件缺陷检测:选一个涉及至少三个组件的修改任务。先只跑单元测试记录结果,再跑端到端测试。分析每个额外发现的缺陷属于哪种跨层交互问题。
- 架构规则自动化:选项目里的一条架构约束,把它变成可执行检查(含面向 agent 的错误消息)。集成到 harness 里,用基准任务验证效果。
- 审查反馈提升:从代码审查历史中找一个重复出现的意见类型,按五步流程转化为自动化检查。比较提升前后该类问题的出现频率。
代码示例
Electron 架构规则
Electron 架构规则
- 渲染器代码不能直接访问文件系统。
- 预加载是渲染器与 Electron 主进程之间的唯一桥梁。
- 检索和索引逻辑位于服务模块中,而非 UI 组件中。
- 日志应该是结构化的,并从服务边界发出。
e2e-runner.ts
/**
* e2e-runner.ts
*
* A minimal E2E test harness. Defines test cases as user action sequences
* (import doc -> index -> ask question -> verify citation). Simulates
* running them and shows the difference between "unit tests pass" and
* "full pipeline works".
*
* Run: npx tsx docs/lectures/lecture-10-why-end-to-end-testing-changes-results/code/e2e-runner.ts
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface PipelineStep {
name: string;
unitTestPasses: boolean;
// Simulated actual behavior in the pipeline
actualBehavior: "works" | "fails" | "partial";
failureReason?: string;
}
interface TestCase {
name: string;
steps: PipelineStep[];
}
interface TestResult {
testCase: string;
unitTestsPassed: number;
unitTestsTotal: number;
unitTestResult: "PASS" | "FAIL";
e2eResult: "PASS" | "FAIL";
e2eFailureStep?: string;
e2eFailureReason?: string;
}
// ---------------------------------------------------------------------------
// Test cases -- realistic scenarios where unit tests pass but E2E fails
// ---------------------------------------------------------------------------
const testCases: TestCase[] = [
{
name: "Import document and ask question",
steps: [
{
name: "Parse uploaded document",
unitTestPasses: true,
actualBehavior: "works",
},
{
name: "Store document chunks",
unitTestPasses: true,
actualBehavior: "works",
},
{
name: "Index chunks for retrieval",
unitTestPasses: true,
actualBehavior: "partial", // Indexes but with wrong embedding dimensions
failureReason: "Embedding dimension mismatch between indexer and retriever",
},
{
name: "Retrieve relevant chunks",
unitTestPasses: true, // Unit test uses mock data with correct dimensions
actualBehavior: "fails",
failureReason: "Empty results due to dimension mismatch from previous step",
},
{
name: "Generate answer with citations",
unitTestPasses: true, // Unit test provides pre-retrieved chunks
actualBehavior: "fails",
failureReason: "No chunks retrieved, so answer has no citations",
},
],
},
{
name: "Delete document and verify removal",
steps: [
{
name: "Find document by ID",
unitTestPasses: true,
actualBehavior: "works",
},
{
name: "Delete document record",
unitTestPasses: true,
actualBehavior: "works",
},
{
name: "Remove indexed chunks",
unitTestPasses: true,
actualBehavior: "fails", // Orphaned chunks remain in the index
failureReason: "Index cleanup query timed out, chunks remain orphaned",
},
{
name: "Verify document not in search results",
unitTestPasses: true, // Unit test mocks the search
actualBehavior: "fails",
failureReason: "Orphaned chunks from previous step still appear in results",
},
],
},
{
name: "Multi-user concurrent access",
steps: [
{
name: "User A imports document",
unitTestPasses: true,
actualBehavior: "works",
},
{
name: "User B imports document",
unitTestPasses: true,
actualBehavior: "works",
},
{
name: "User A queries their document",
unitTestPasses: true,
actualBehavior: "partial", // Cross-contamination of results
failureReason: "No user-scoping on retrieval, returns chunks from User B's doc",
},
{
name: "Verify only User A's results returned",
unitTestPasses: true,
actualBehavior: "fails",
failureReason: "Results include documents from other users",
},
],
},
];
// ---------------------------------------------------------------------------
// Run tests
// ---------------------------------------------------------------------------
function runUnitTests(tc: TestCase): { passed: number; total: number } {
const passed = tc.steps.filter((s) => s.unitTestPasses).length;
return { passed, total: tc.steps.length };
}
function runE2ETest(tc: TestCase): { pass: boolean; failureStep?: string; failureReason?: string } {
// Pipeline: if any step actually fails, the whole E2E fails
for (const step of tc.steps) {
if (step.actualBehavior === "fails") {
return {
pass: false,
failureStep: step.name,
failureReason: step.failureReason ?? "Unknown failure",
};
}
// "partial" means the step technically completes but creates problems downstream
// We let it continue but track it
}
// Check if any step was "partial" (which may cause downstream issues)
const partialSteps = tc.steps.filter((s) => s.actualBehavior === "partial");
if (partialSteps.length > 0) {
// The partial steps may or may not cause overall failure
// In our simulation, partial steps always lead to failure downstream
// unless there's an explicit "fails" step that already caught it
// This case means all steps were either "works" or "partial"
return {
pass: false,
failureStep: partialSteps[partialSteps.length - 1].name,
failureReason: partialSteps[partialSteps.length - 1].failureReason ?? "Partial completion",
};
}
return { pass: true };
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
function pad(s: string, len: number): string {
return s.length >= len ? s : s + " ".repeat(len - s.length);
}
function run(): void {
console.log("\n" + "=".repeat(95));
console.log(" E2E TEST RUNNER -- Unit Tests vs Full Pipeline");
console.log("=".repeat(95));
const results: TestResult[] = testCases.map((tc) => {
const unit = runUnitTests(tc);
const e2e = runE2ETest(tc);
return {
testCase: tc.name,
unitTestsPassed: unit.passed,
unitTestsTotal: unit.total,
unitTestResult: unit.passed === unit.total ? "PASS" : "FAIL",
e2eResult: e2e.pass ? "PASS" : "FAIL",
e2eFailureStep: e2e.failureStep,
e2eFailureReason: e2e.failureReason,
};
});
// Per-test-case detail
for (let i = 0; i < testCases.length; i++) {
const tc = testCases[i];
const r = results[i];
console.log("\n Test Case: " + tc.name);
console.log(" " + "-".repeat(70));
const stepHeader = ` | ${pad("Step", 40)}| ${pad("Unit Test", 11)}| ${pad("E2E Actual", 12)}|`;
const stepSep = ` |${"-".repeat(42)}|${"-".repeat(13)}|${"-".repeat(14)}|`;
console.log(stepHeader);
console.log(stepSep);
for (const step of tc.steps) {
const utLabel = step.unitTestPasses ? "PASS" : "FAIL";
let e2eLabel: string;
if (step.actualBehavior === "works") e2eLabel = "PASS";
else if (step.actualBehavior === "partial") e2eLabel = "PARTIAL*";
else e2eLabel = "FAIL";
const marker = step.actualBehavior !== "works" ? ">>" : " ";
console.log(`${marker}| ${pad(step.name, 40)}| ${pad(utLabel, 11)}| ${pad(e2eLabel, 12)}|`);
}
if (r.e2eFailureStep) {
console.log(`\n E2E Failure at: ${r.e2eFailureStep}`);
console.log(` Reason: ${r.e2eFailureReason}`);
}
}
// Summary comparison
console.log("\n" + "=".repeat(95));
console.log(" COMPARISON: Unit Tests vs E2E Tests");
console.log("=".repeat(95) + "\n");
const header = `| ${pad("Test Case", 35)}| ${pad("Unit Tests", 15)}| ${pad("E2E Result", 12)}| Discrepancy`;
const sep = `|${"-".repeat(37)}|${"-".repeat(17)}|${"-".repeat(14)}|${"-".repeat(30)}`;
console.log(header);
console.log(sep);
for (const r of results) {
const utLabel = `${r.unitTestsPassed}/${r.unitTestsTotal} ${r.unitTestResult}`;
const discrepancy = r.unitTestResult === "PASS" && r.e2eResult === "FAIL";
const discLabel = discrepancy ? "UNIT PASS BUT E2E FAIL" : "Consistent";
console.log(`| ${pad(r.testCase, 35)}| ${pad(utLabel, 15)}| ${pad(r.e2eResult, 12)}| ${discLabel}`);
}
const falseConfidence = results.filter((r) => r.unitTestResult === "PASS" && r.e2eResult === "FAIL").length;
console.log("\n False confidence count: " + falseConfidence + " of " + results.length);
console.log(" Unit tests pass but E2E reveals integration failures that units cannot catch.\n");
}
run();
示例:将审查反馈转化为规则
示例:将审查反馈转化为规则
反复出现的审查意见:
不要从渲染器调用文件系统工具。使用预加载桥接。
提升为 harness 规则:
- 添加一个 lint 或 import 规则,阻止在渲染器代码中使用
fs - 添加修复文本,解释预加载边界