3831 字
约 12 分钟
3
第十讲. 跑通完整流程才算真正验证

第十讲. 跑通完整流程才算真正验证

单元测试通过后,agent 经常会说"做完了",但端到端运行时才会暴露真正的问题。举例来说,让 agent 给 Electron 应用加一个文件导出功能,它写了渲染进程组件、预加载脚本、服务层逻辑,每个组件的单元测试都通过了。agent 说"做完了"。实际点击导出按钮时:文件路径格式不对、进度条没反应、大文件导出时内存泄漏。5 个组件边界缺陷,单元测试一个都没发现。

每个部分单独看都"对"了,但拼在一起就出了问题。Google 的测试金字塔告诉我们,大量单元测试是基础,但如果你止步于此,就会系统性地漏掉组件交互问题。对于 AI 编码 agent 来说,这个问题更严重,因为 agent 倾向于只跑最快的测试然后宣告完成。只有端到端测试能证明系统级缺陷不存在

单元测试的盲区

单元测试的设计哲学是隔离:模拟依赖,专注被测单元。这个哲学使单元测试快速且精确,但也制造了系统性的盲区。每个模块在隔离环境中表现完美,但真正拼在一起运行时才会暴露以下几类问题:

接口不匹配:渲染进程传给预加载脚本的文件路径是相对路径,但预加载脚本期望绝对路径。各自的单元测试都用了 mock,都通过了。只有端到端跑通时才发现问题。

状态传播错误:数据库迁移改了表结构,但 ORM 的缓存层还持有旧结构的缓存条目。单元测试每次都是全新的 mock 环境,不会暴露这种跨层状态不一致。

资源生命周期问题:文件句柄、数据库连接、网络套接字的获取和释放跨越多个组件。单元测试为每个测试创建和销毁独立资源,不会暴露资源竞争或泄漏。

环境依赖性:代码在测试环境(一切 mock)行为正确,在真实环境因配置差异、网络延迟、服务不可用而失败。

端到端测试同时影响结果与行为

这是很多人没意识到的一点:当 agent 知道它的工作要过端到端测试时,它的编码行为会改变。

  1. 考虑组件交互:写代码时会想"这个接口和上游怎么对接",不只关注单个函数。
  2. 尊重架构边界:有架构约束的系统里,端到端测试迫使 agent 遵守边界规则。
  3. 处理错误路径:端到端测试通常包含故障场景,迫使 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 自动变强:每个被捕获的缺陷类别都变成永久防线。

延伸阅读

练习

  1. 跨组件缺陷检测:选一个涉及至少三个组件的修改任务。先只跑单元测试记录结果,再跑端到端测试。分析每个额外发现的缺陷属于哪种跨层交互问题。
  2. 架构规则自动化:选项目里的一条架构约束,把它变成可执行检查(含面向 agent 的错误消息)。集成到 harness 里,用基准任务验证效果。
  3. 审查反馈提升:从代码审查历史中找一个重复出现的意见类型,按五步流程转化为自动化检查。比较提升前后该类问题的出现频率。

代码示例

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
  • 添加修复文本,解释预加载边界
第十讲. 跑通完整流程才算真正验证
http://clxhxhhr.top/posts/275/
作者
clxstart
发布于
2026-07-26
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。