第 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 报告生成:
// 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, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, """);
}
/** 把 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 每晚全量回归。
# .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-minutes 和 if: always() 上传产物——Agent 类任务必须有硬超时兜底。
21.4 回归巡检:基线快照与自动建 Issue
全量回归的关键不是"有没有失败",而是"是不是新失败"。用基线快照做差分:
// 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 秒读完的摘要:
// 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 里没人盯着终端,失控的代价是真金白银。三道防线:
// 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 调用?
🛠️ 动手实践
🛠️ 动手实践
- 为你的项目编写
.github/workflows/test-agent.yml:push 只跑 P0 冒烟集,配置 30 分钟硬超时与MAX_TURNS=10,观察 Actions 面板的 JUnit 报告渲染效果。 - 实现基线快照差分:故意在一个接口测试中改错期望状态码制造一次"回归",验证 Issue 是否被自动创建且包含失败原因与证据。
- 接入 Slack webhook,改造
notifySlack使其在"全部通过"时静默跳过发送,仅在存在回归或恢复时推送,连续观察三个夜晚的消息质量。
三章实战到此完结。你已经拥有了一条从需求文档到 CI 门禁的完整 AI 测试流水线——返回课程导学回顾全程,或把它部署到自己的项目中开始值守。