Skip to content

第 22 章 · 实战二:测试智能体——自动化执行与报告

本章目标:把前两章的测试智能体流水线搬进 GitHub Actions——push 时自动跑冒烟集、每晚定时全量回归;用基线快照识别回归缺陷并自动建 Issue,结果推送 Slack 摘要,同时把 CI 场景下的成本与稳定性控制到位。

21.1 从本地脚本到 CI 任务:三个变化

前两章的流水线在终端里手动跑,进 CI 后有三个本质变化:

维度本地运行CI 运行
交互方式终端流式输出给人看非交互,产物落盘给机器读
结果格式Markdown 报告JUnit XML(CI 原生解析展示)
失败语义跑完即止回归判定:上次通过本次失败才是真缺陷

核心原则:CI 里 Agent 是"无人值守员工"——它必须可重复、有预算上限、产出机器可读的报告。

21.2 编程式入口:runPipeline 与 JUnit 输出

main.ts 的命令行逻辑重构为可导入的函数,并新增 JUnit 报告生成:

typescript
// pipeline/ci.ts —— CI 编程式入口
import { readFile, writeFile } from "node:fs/promises";
import { parseRequirements, reviewRequirements } from "./stages1-2.js";
import { makeTestPlan } from "./stage3.js";
import { generateCases } from "./stage4.js";
import { executeAll, type TestResult } from "../executor/index.js";

/** JUnit XML 转义:属性值里的引号和尖括号会破坏 XML 结构 */
function esc(s: string): string {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;")
          .replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

/** 把 TestResult[] 序列化为 GitHub Actions 可识别的 JUnit XML */
export function toJUnitXml(suiteName: string, results: TestResult[]): string {
  const cases = results.map((r) => `
    <testcase name="${esc(r.caseId)}" classname="${esc(suiteName)}" time="${(r.durationMs / 1000).toFixed(2)}">
      ${r.status === "failed" ? `<failure message="${esc(r.error ?? "assertion failed")}">${esc(r.evidence ?? "")}</failure>` : ""}
      ${r.status === "skipped" ? `<skipped/>` : ""}
    </testcase>`).join("\n");
  const failed = results.filter((r) => r.status === "failed").length;
  return `<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="${esc(suiteName)}" tests="${results.length}" failures="${failed}">
${cases}
</testsuite>`;
}

/**
 * 完整流水线:需求 → 用例 → 执行 → JUnit 报告。
 * mode=smoke 只跑 P0 用例(push 触发),mode=full 全量(定时触发)。
 */
export async function runPipeline(opts: {
  requirementPath: string;
  mockups?: string[];
  mode: "smoke" | "full";
}) {
  const doc = await readFile(opts.requirementPath, "utf-8");
  const points = await parseRequirements(doc);
  const review = await reviewRequirements(points);
  if (!review.passed) throw new Error(`需求评审未通过:${review.blocker}`);
  const plan = await makeTestPlan(points, review);

  let cases = await generateCases(points, review, plan, opts.mockups);
  if (opts.mode === "smoke") {
    // 冒烟集只保留 P0,控制 push 流水线的时长与成本
    cases = cases.filter((c) => c.priority === "P0");
  }

  const results = await executeAll(cases);
  const failed = results.filter((r) => r.status === "failed").length;
  console.log(`::error title=TestFailures::${failed} 条用例失败`);   // Actions 注解
  return { results, junit: toJUnitXml(`test-agent-${opts.mode}`, results) };
}

::error 是 GitHub Actions 的 workflow command 语法,会把摘要直接标注在 PR 的 Checks 面板上。

21.3 GitHub Actions:双触发工作流

一个 workflow 文件承载两种触发:push 跑冒烟、cron 每晚全量回归。

yaml
# .github/workflows/test-agent.yml
name: AI Test Agent

on:
  push:
    branches: [main]
    paths: ["src/**", "docs/requirements/**"]   # 代码或需求变更才触发
  schedule:
    - cron: "0 18 * * *"   # UTC 18:00 = 北京时间凌晨 2 点,全量回归

jobs:
  smoke:                          # ① push 触发:P0 冒烟集
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    timeout-minutes: 30           # 硬性超时,防止 Agent 死循环烧钱
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci && npx playwright install --with-deps chromium
      - name: Run smoke pipeline
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          MAX_TURNS: "12"                       # CI 中限制轮数(见 21.6)
        run: npx tsx pipeline/ci-entry.ts smoke docs/shopping-cart.md
      - uses: actions/upload-artifact@v4        # 上报报告,无论成败
        if: always()
        with: { name: junit-smoke, path: reports/junit-smoke.xml }

  nightly:                        # ② 定时触发:全量回归 + 巡检
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }                # 需要历史提交读取基线快照
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci && npx playwright install --with-deps chromium
      - name: Full regression
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}   # 自动建 Issue 用
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        run: npx tsx pipeline/ci-entry.ts full docs/shopping-cart.md
      - name: Publish test report
        uses: mikepenz/action-junit-report@v4   # 解析 JUnit 展示在 Checks 页
        if: always()
        with: { report_paths: reports/junit-*.xml }

要点:两个 job 都设了 timeout-minutesif: always() 上传产物——Agent 类任务必须有硬超时兜底。

21.4 回归巡检:基线快照与自动建 Issue

全量回归的关键不是"有没有失败",而是"是不是新失败"。用基线快照做差分:

typescript
// pipeline/regression.ts —— 基线对比 + 自动建 Issue
import { readFile, writeFile } from "node:fs/promises";
import { Octokit } from "octokit";
import type { TestResult } from "../executor/types.js";

type Baseline = Record<string, "passed" | "failed">;   // caseId -> 上次状态

export async function loadBaseline(): Promise<Baseline> {
  try { return JSON.parse(await readFile("reports/baseline.json", "utf-8")); }
  catch { return {}; }                                  // 首次运行无基线
}

export async function saveBaseline(results: TestResult[]) {
  const b: Baseline = {};
  for (const r of results) b[r.caseId] = r.status === "passed" ? "passed" : "failed";
  await writeFile("reports/baseline.json", JSON.stringify(b, null, 2));
}

/** 差分分类:新失败 = 回归缺陷;持续失败 = 存量问题;恢复 = 好消息 */
export function classify(results: TestResult[], baseline: Baseline) {
  return {
    regressions: results.filter(r => r.status === "failed" && baseline[r.caseId] === "passed"),
    chronic:     results.filter(r => r.status === "failed" && baseline[r.caseId] !== "passed"),
    recovered:   results.filter(r => r.status === "passed" && baseline[r.caseId] === "failed"),
  };
}

/** 回归缺陷自动建 Issue,附上证据链接,避免人工复制粘贴 */
export async function fileRegressionIssues(regressions: TestResult[]) {
  const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
  for (const r of regressions) {
    await octokit.rest.issues.create({
      owner: process.env.GITHUB_REPOSITORY!.split("/")[0],
      repo:  process.env.GITHUB_REPOSITORY!.split("/")[1],
      title: `[回归缺陷] ${r.caseId} 上次通过,本次失败`,
      labels: ["regression", "ai-test-agent"],
      body: [
        `### 失败原因`, "```", r.error ?? "N/A", "```",
        `### 证据`, r.evidence ?? "无",
        ``, `> 由 AI 测试智能体夜间巡检自动创建`,
      ].join("\n"),
    });
  }
}

注意区分两类失败的处理策略:回归缺陷立即建 Issue(有人改坏了东西);存量慢性失败只在周报里汇总,避免 Issue 刷屏。

21.5 结果通知:Slack 摘要

巡检结束后推一条人能 10 秒读完的摘要:

typescript
// pipeline/notify.ts —— Slack webhook 通知
export async function notifySlack(summary: {
  total: number; passed: number; regressions: number; recovered: number; runUrl: string;
}) {
  const emoji = summary.regressions > 0 ? "🔴" : "🟢";
  const text = [
    `${emoji} *夜间回归巡检完成*`,
    `• 总计 ${summary.total} 条,通过 ${summary.passed}`,
    summary.regressions ? `• ⚠️ 新增回归缺陷 *${summary.regressions}* 个(已自动建 Issue)` : "• 无新增回归 ✅",
    summary.recovered ? `• 🎉 ${summary.recovered} 个历史失败已恢复` : "",
    `• 详情:<${summary.runUrl}|查看 Actions 日志>`,
  ].filter(Boolean).join("\n");

  // Slack Incoming Webhook:POST 一段 Block Kit 即可发消息
  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text }),
  });
}

只有回归缺陷 > 0 或有恢复才值得打扰人;一切正常的夜晚可以静默或只发一行绿字。

21.6 成本与稳定性:无人值守的三道保险

CI 里没人盯着终端,失控的代价是真金白银。三道防线:

typescript
// executor/guard.ts —— Agent 执行护栏
import { Agent } from "@earendil-works/pi-agent-core";

export async function guardedRun(agent: Agent, prompt: string, opts: {
  maxTurns: number;       // ① 轮数上限:单条用例最多 8 轮工具调用
  retries: number;        // ② 失败重试:基础设施类错误才值得重试
}) {
  for (let attempt = 1; attempt <= opts.retries; attempt++) {
    try {
      // maxTurns 由环境变量注入,CI 与本地可用不同档位
      const cap = Number(process.env.MAX_TURNS ?? opts.maxTurns);
      return await agent.prompt(prompt, { maxTurns: cap });
    } catch (err: any) {
      const isInfra = /timeout|ECONNRESET|429/.test(err.message);   // 网络类错误
      if (!isInfra || attempt === opts.retries) throw err;
      await new Promise((r) => setTimeout(r, attempt * 5000));      // 指数退避
    }
  }
}
保险手段效果
① 轮数上限MAX_TURNS 环境变量注入 prompt 配置单条用例成本封顶
② 选择性重试仅网络/限流类错误重试,断言失败不重试断言失败重试只会浪费钱
③ 会话缓存相同需求文档 hash 未变时复用 cases.json,跳过生成阶段夜间巡检省掉约 60% LLM 调用

第③条实现很简单:sha256(requirements.md) 与缓存文件里的哈希比对,一致则直接 readFile("cases.json")——需求没变,用例就不会变。

本章小结

  • CI 中 Agent 必须非交互化:产物落盘、输出 JUnit XML、用 ::error 写回 Checks 面板;
  • 双触发设计:push 跑 P0 冒烟控时长,cron 夜间全量回归保覆盖;
  • 回归判定靠基线快照差分:新失败才建 Issue,慢性失败进周报,避免告警疲劳;
  • 无人值守三道保险:MAX_TURNS 封顶成本、选择性重试、需求哈希缓存省掉重复生成;
  • 通知要"值得被打扰":只有回归或恢复才推送,摘要控制在十秒内能读完。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 为什么 CI 中要把测试报告输出为 JUnit XML 格式?

2. 夜间全量回归中,某用例「上次通过、本次失败」,应如何处理?

3. 关于 CI 中的失败重试策略,正确的做法是?

4. 「需求哈希缓存」优化能在夜间巡检中节省约多少 LLM 调用?

🛠️ 动手实践

🛠️ 动手实践

  1. 为你的项目编写 .github/workflows/test-agent.yml:push 只跑 P0 冒烟集,配置 30 分钟硬超时与 MAX_TURNS=10,观察 Actions 面板的 JUnit 报告渲染效果。
  2. 实现基线快照差分:故意在一个接口测试中改错期望状态码制造一次"回归",验证 Issue 是否被自动创建且包含失败原因与证据。
  3. 接入 Slack webhook,改造 notifySlack 使其在"全部通过"时静默跳过发送,仅在存在回归或恢复时推送,连续观察三个夜晚的消息质量。

三章实战到此完结。你已经拥有了一条从需求文档到 CI 门禁的完整 AI 测试流水线——返回课程导学回顾全程,或把它部署到自己的项目中开始值守。