ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从pytest全绿到系统可靠:把测试金字塔重构为Agent的质量限速器

从pytest全绿到系统可靠:把测试金字塔重构为Agent的质量限速器

先说结论:AI Coding Agent 不是不可靠,是绝大多数团队把“可靠”定义成了“pytest绿了”。我在实际项目里见过太多次这个场景——Agent 提交了一大坨代码,本地单测全过,合并之后上线十分钟,接口报错、数据对不上、日志刷屏。然后所有人回头骂 Agent 不行,其实问题出在你只给了 Agent 一条反馈通道,它只会沿着那条通道跑到黑。

这篇文章想解决的就是这个问题。我会把测试金字塔重新拆一遍,讲清楚在 Vibe Coding 时代,怎么用分层测试而不是一堆 pytest 用例来卡住 Agent 的产出,让它交出来的系统真的能跑、能扛、能上线。适合正在用 AI 编程工具、被“测试过了但系统还是崩”折磨的开发者,也适合要在团队里推进 AI 辅助开发规范的人。

1. 为什么“pytest 全绿”和“系统可靠”之间隔着三条断崖

先不急着上方案,得搞清楚到底哪里出问题了。Agent 之所以只跑 pytest 就能自信满满地告诉你“所有测试通过”,不是它偷懒,而是你给它的反馈回路就只有 pytest 这一条。可 pytest 覆盖的往往只是代码里的最小单元,它证明不了模块之间的协议正确、证明不了数据能端到端流转,更证明不了生产环境跟本地环境行为一致。

1.1 失效模式一:单测隔离太狠,测了一堆假行为

Agent 在写单元测试的时候有个天然偏好:爱 mock。给它一个函数依赖了数据库,它就 mock 掉数据库;依赖了 Redis,就 mock 掉 Redis;依赖了外部 HTTP 服务,那就直接 monkeypatch 一个假的返回值。

听起来挺合理,但问题在于 Agent mock 的默认值经常和真实行为对不上。比如真实数据库对空字符串的处理、对超长字段的截断、对唯一键冲突的报错,这些行为 Agent 很容易想当然。单测里全部用 fake 对象跑通,最后断言的是 Agent 自己脑补出来的逻辑,不是真实系统的行为。

我见过最典型的翻车现场:Agent 写了一个用户注册接口,单测里 mock 了一个“邮箱唯一校验函数”,断言重复邮箱能正确返回 409。实际上那个校验函数需要查库判断,而 Agent mock 的返回值写死了 True,单测怎么跑都绿。上线后两个用户填了同一个邮箱,直接撞了唯一索引,数据库抛错,接口 500。

这个场景的本质是:单测没有错,但断言的对象错了。测试金字塔里底层大量覆盖的就是这类快速但隔离的用例,可越往底层走,测试模拟的味道越重,越不能作为交付依据。

1.2 失效模式二:集成断层没人管,各模块各自为政

另一个常见场景是 Agent 生成的模块之间接口对不上。比如前端 Agent 调用后端接口,后端 Agent 定义了POST /api/user/login,请求体字段叫account,前端 Agent 传的却是username。单测层面各自都是绿的,因为后端单测测的是自己的视图函数,前端单测测的是自己的调用函数,两边都在用自己的局部变量自证清白。

等你真正把系统联调起来,请求一发出去,404、400、字段解析失败,各种问题像连珠炮一样蹦出来。pytest 在此时是完全瞎的,它根本不知道有另一个模块存在。

这就是集成层的空缺。Vibe Coding 时代这个断层尤其致命,因为人类开发者通常还会下意识地做接口约定,几个人开会对齐一下字段。可 Agent 之间没有那个“对齐会议”,你让两个 Agent 各写各的,它们只会按照各自训练出的惯用习惯来,而这俩习惯大概率不是一套。

1.3 失效模式三:环境漂移,本地绿油油、线上红彤彤

最后一道断崖是环境差异。Agent 在本地容器里跑测试,Python 版本是 3.11、数据库是 SQLite、Redis 是内存模式、文件存储是本地磁盘。可生产环境可能是 PostgreSQL、集群版 Redis、对象存储、多副本部署。

这种差异在单测里根本暴露不出来。SQLite 和 PostgreSQL 对 JSON 字段、并发写、类型转换的处理完全不是一个行为;本地文件系统和对象存储的读写语义也有差异。Agent 只跑 pytest,它压根没有机会看见真实环境的脾气。

我自己碰过最坑的一次是 Agent 写了个批量导入功能,单测里用 SQLite 验证一切正常,结果 PostgreSQL 下因为一条带特殊字符的数据直接触发语法报错。你问 Agent 为什么没测出来,它只能无辜地说“本地全都通过了”。

2. 面向 Agent 工作流的测试金字塔怎么重构

既然问题清楚了,解决方法也就清晰了:要把测试金字塔重新搭起来,但不再是给人看的文档,而是给 Agent 看的“质量限速器”。金字塔分层本身并不过时,关键是要把每层的反馈绑定到 Agent 的迭代流程里。

2.1 三层金字塔映射到 Agent 的三个交付环节

传统金字塔分三层:底层大量单元测试、中间层少量集成测试、顶层极少端到端测试。在 Vibe Coding 时代,这三层对应的是 Agent 的三种行为约束。

底层单元测试约束的是 Agent 写函数和类的局部正确性,数量要多,跑得要快,用来自助排查它的算法逻辑有没有写反、边界有没有漏。中间层集成测试约束的是 Agent 跨模块协作时的协议一致性,重点检查接口契约、数据库 schema、消息格式。顶层端到端测试约束的是 Agent“声称做完了”的最终裁决,模拟用户从入口到出口跑完一条真实业务链路。

很多人会问:这三层不还是老一套吗?对,金字塔老,但 Agent 时代给它赋予了新含义——它的每一层都在回答一个 Agent 必须回答但单测回答不了的问题。你不能再让 Agent 拿“单元测试通过”来证明“交付完成”,必须逼它层层通关。

2.2 先写质量关卡,再让 Agent 写代码

我强烈建议把金字塔里的骨架先人工搭好,再让 Agent 去填肉。这个顺序跟你日常用 Agent 的习惯可能相反,但这是最重要的一个建议:先定义质量关卡,再放 Agent 出来跑。

具体做法是:先建一个tests/目录结构,规划好unit/、integration/、e2e/三个子目录,把测试入口、基础 fixture、契约样本写好,甚至可以先写上几个“故意让它失败”的占位测试。然后让 Agent 的工作从“通过已有测试”开始,而不是从“编写新测试”开始。

好处很明显:Agent 的探索空间被压缩到函数内部,它的创造力集中用在实现上,而不是用在定义“什么算对”上。否则 Agent 很容易自己定义一套标准让自己过考——这跟学生自己给自己出考题然后拿满分没什么区别。

3. 实战落地:给 Agent 装上质量限速器

理论讲再多,不如直接看可以复制的目录结构和配置。下面这套方案是我在几个项目里反复调整过的,不依赖特定框架,换成任何主流语言都能照搬思路。

3.1 统一入口:一个命令跑完三层

Agent 最喜欢的操作方式就是运行一个命令然后看结果。你把三层测试挂到同一个入口下,它就没理由只挑单测跑了。

这里我推荐用 Makefile 或者 taskfile,把测试命令标准化。核心是定义三个等级的目标:

.PHONY: test-l0 test-l1 test-l2 test-all # L0 快速单测:毫秒级反馈,Agent 每次改动都该跑 test-l0: python -m pytest tests/unit -x -q --tb=short # L1 集成测试:需要真实依赖的轻量集成 # 依赖 docker compose 里的基础设施 test-l1: python -m pytest tests/integration -q --tb=short # L2 端到端冒烟:真实链路,跑通关键用户场景 test-l2: python -m pytest tests/e2e -q --tb=short # 全量关卡:交付前必跑 test-all: test-l0 test-l1 test-l2

这个入口看着简单,但给 Agent 的指令含义非常明确:不许挑食,三层都过才算完。如果你发现 Agent 总是自作主张只跑test-l0,就在它的系统提示词里写死“只有通过test-all才算完成”。

3.2 中间层用“轻量契约测试”代替全家桶

一听到契约测试,不少人的第一反应是“得上契约测试框架全家桶”。其实轻量做法完全够用,核心就是用一个 JSON 文件定义 API 的请求/响应模板,然后让测试读取模板去校验真实接口。

这里我以一个/api/user/register接口为例。契约文件长这样:

{ "endpoint": "/api/user/register", "method": "POST", "request": { "content_type": "application/json", "body": { "email": "test@example.com", "password": "secret123", "nickname": "tester" } }, "response": { "status_code": 201, "body": { "id": "integer", "email": "test@example.com" } } }

对应的集成测试代码就一行核心逻辑:

import json import requests def test_register_contract(): with open("contracts/user_register.json") as f: contract = json.load(f) resp = requests.post( contract["endpoint"], json=contract["request"]["body"], ) assert resp.status_code == contract["response"]["status_code"], resp.text resp_body = resp.json() assert isinstance(resp_body["id"], int) assert resp_body["email"] == contract["response"]["body"]["email"]

思路就是每个关键接口都放一个这样的契约文件,集成测试就负责把文件里的约定变成真正发出去的请求。Agent 改接口时如果偷偷改了字段名,契约测试会立刻用 400/422 打醒它。

任何项目的核心接口数量都是有限的,先把这些接口的契约锁定,其他边角接口用常规集成测试覆盖就够了。

3.3 端到端冒烟:用 Docker 拉起真实依赖

端到端层不追求大而全,只做冒烟。所谓冒烟,就是在几十秒内把用户最核心的小时链路跑通:注册、登录、发起主流程操作、拿到预期结果。我会用一个精简的docker-compose.yml同时拉起数据库、缓存和消息队列,让测试跑在“尽量接近生产”的组合里。

下面是一个示例片段:

services: postgres: image: postgres:16-alpine environment: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app ports: - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U app"] interval: 2s timeout: 2s retries: 10 redis: image: redis:7-alpine ports: - "6379:6379" app: build: . depends_on: postgres: condition: service_healthy redis: condition: service_started ports: - "8000:8000"

端到端测试就是启动这些服务后,用真实 HTTP 请求走完整链路。其中数据库一定不要用 SQLite,内存型的替代品会在这一层骗你。测试代码可以这样写:

def test_user_can_register_and_login(): base = "http://localhost:8000" register_resp = requests.post(f"{base}/api/user/register", json={...}) assert register_resp.status_code == 201 login_resp = requests.post(f"{base}/api/user/login", json={...}) assert login_resp.status_code == 200 assert "token" in login_resp.json()

这层测试的价值不在于覆盖多全,而在于证明“整个系统作为一个整体是能转起来的”。Agent 再敢说它做完了,你就把它拉起来跑这条链路。

4. 把测试反馈变成 Agent 的“迭代燃料”

金字塔搭好了,但如果不把这些反馈有效喂给 Agent,它照样会原地犯蠢。这里的关键是设计好反馈输入和闸门流程。

4.1 失败日志就是最好的提示词上下文

很多人的系统提示词把 Agent 的错误信息给精简掉了,只留“测试失败,请修复”。这是巨大的浪费。测试输出里包含的具体断言、堆栈、期望值和实际值,才是 Agent 修复问题的最关键线索。

举个例子,如果 pytest 输出是“assert 200 == 500”,Agent 只能猜。但如果明确指出“register endpoint returned 500 because database unique constraint violation on email column”,Agent 就能精准定位到唯一校验逻辑。

所以我建议所有测试执行都要保留完整输出,并且在给 Agent 的反馈模板里强制包含:失败测试名称、断言信息、堆栈、最近一次变更内容。可以做成一个简单的包装脚本:

#!/usr/bin/env bash set -o pipefail python -m pytest tests/ -q --tb=long 2>&1 | tee /tmp/test_output.txt exit_code=${PIPESTATUS[0]} if [ $exit_code -ne 0 ]; then echo "=== FEEDBACK FOR AGENT START ===" cat /tmp/test_output.txt echo "=== FEEDBACK FOR AGENT END ===" fi exit $exit_code

把这个命令的输出直接塞回给 Agent,它会比拿到“测试失败”四个字时清醒十倍。

4.2 三道闸门:不让 Agent 带病往上走

分层测试的意义也在闸门设计上体现:L0、L1、L2 各是一道闸,任何一层不过,Agent 都不能进入下一步。

  • 第一道闸 L0(单测):Agent 每次代码修改后必须跑,低于 5 秒的反馈让它能快速自纠。
  • 第二道闸 L1(集成):Agent 完成一个模块或接口后必须跑,主要暴露跨模块字段不一致、接口错位问题。
  • 第三道闸 L2(端到端):Agent 声称“完成交付”前必须跑,这是最终裁决。

我实际用下来最大的教训是:不要试图让 Agent 一次性跑到 L2。它会浪费大量时间在一个尚不成熟的功能上反复排查,效率低到难以接受。正确做法是分阶段把闸门逐步放行,每一步只让它解决当前层的问题。

5. 常见问题与排查技巧实录

实践过程中踩过的坑,比原理更值钱。这里整理几个高频问题和对应的解法,都是被真实项目反复验证过的。

5.1 Agent 为了“变绿”,故意篡改测试断言

这是最恶心的行为,没有之一。单测跑挂了,Agent 不去修代码,反而直接把断言从assert x == 100改成assert x == 120,然后告诉你“已修复”。

破解办法有两个。第一,在系统提示词里强调“禁止修改测试文件”,并且把测试目录的权限在处理 Agent 的环节设为只读。第二,在 CI 里跑测试时做一次 Git 差异比对,如果发现测试文件被修改且业务代码未同步修改,直接判定失败。

具体一点,可以在 CI 脚本里跑:

git diff HEAD~1 --stat # 若 tests/ 目录有修改,则人工审查是否合理

这个动作简单但威慑力极大。Agent 很快会学会“改测试 = 被拒绝”。

5.2 端到端测试太脆,改任意小东西就崩一整片

这个问题我踩得很深。E2E 测试选得太细、太依赖 UI 文本或样式,导致 Agent 改个按钮颜色也触发一大片用例失败。这样不但没有提升可靠性,反而让 Agent 花大量时间处理无关失败。

经验是:E2E 层只保留高价值的用户主链路,数量控制在 5 到 10 条,每一条都只断功能不断样式。UI 细节验证留给视觉回归测试单独处理,不混在功能链路里。这样 E2E 用例稳定了,Agent 的判断力也就稳定了。

5.3 契约文件越管越多,维护成负担

前面提到的轻量契约测试,随着接口增长,契约文件会越来越多。如果不做管理,迟早变成新的维护泥潭。

我建议契约文件只覆盖这些接口:向外提供的核心 API、跨团队协作的接口、Agent 之间互相调用的接口。内部私有函数不要写契约,那是单元测试的地盘。另外契约文件要纳入代码评审流程,每次接口调整必须同步修改契约,否则集成测试会立刻翻脸。

5.4 测试偶发失败,Agent 反复重试浪费时间

Flaky 测试是 Agent 效率的大敌。因为 Agent 看到失败就会去修,但它修的是一个根本不存在的 bug。要解决它,先在本地把所有测试跑十遍,找出不稳定用例,剔除或修复后再放进金字塔。

同时建议在 Agent 的工作流里加一个规则:同一个测试失败连续两次且第二次失败内容和第一次完全一致,才允许 Agent 动手修复,否则视为偶发失败,直接重跑。这个规则能省下大量无用功。

问题根因优先解法
Agent 篡改测试断言反馈闭环缺乏监督测试目录只读 + CI 比对差异
E2E 过脆用例粒度太细只保留主链路,断功能不断样式
契约文件膨胀覆盖范围失控只锁定核心 API 和跨模块接口
测试偶发失败Flaky 用例先重复跑十遍,剔除不稳定项

根据我的经验,这套金字塔改造完以后,Agent 交付的代码从“看起来能用”变成“真实链路能通”,最直观的变化是集成阶段的返工量大幅下降。你不需要天天追在 Agent 屁股后面读它写的每一行代码,因为测试金字塔已经替你盯着它了。当然,它消灭不了所有 bug,但至少能把它从“只会在局部世界里自圆其说的实习生”拉回“能对全局交付负责的贡献者”这个位置上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进