6.2. 集成测试与回归测试保障端到端行为不变
你需要什么
在开始之前,请确保以下就绪:
- 环境:一台能够运行 Docker Compose 的开发机(本地或远端均可)
- 工具:
- Docker Desktop 或 Docker Engine 20.10+
- Python 3.10+,推荐使用 uv 或 pip 管理依赖
- pytest ≥ 8.0,pytest-asyncio ≥ 0.24
- VCR.py ≥ 6.0(用于 HTTP 录制与回放)
- 前置:已有一个可运行的 Agent Skill 项目,且单元测试覆盖率 ≥ 80%
- 预计时间:约 60 分钟(含首次搭建与调试)
最终成果
完成本章后,你将拥有一套独立于外部依赖的集成测试环境,以及一条在 CI 中自动触发的回归流水线。具体来说:
- 使用 Docker Compose 启动 Skill 所需的所有依赖服务(数据库、消息队列、外部 API 模拟器)
- 通过 VCR.py 录制真实 HTTP 响应,在测试中使用离线“磁带”回放,使回归套件无需联网
- 在 GitHub Actions(或你选择的 CI 平台)中配置工作流,对每个 Pull Request 自动执行回归套件,若有退化立即阻断合并
为什么做这个?因为 Skill 的代码本身通过单元测试只能证明“孤立函数正确”,无法回答“昨天能用的搜索 Skill,今天换了模型版本之后还能不能正确调用 Google Custom Search API 并解析出 Markdown 列表”。集成测试与回归测试正是用来守住这条端到端的红线。
1. 搭建独立的测试环境
我们的第一个动作是建立一个与外界完全隔离的沙箱。Skill 在真实环境里可能依赖 PostgreSQL、Redis、或者 LangFuse 这类可观测性后端,但在测试中引入真实连接等于把“网络波动”和“第三方限流”伪装成测试失败,这是集成测试中最常见的误报来源。
1.1 编写 docker-compose.test.yml
创建项目根目录下的 docker-compose.test.yml,内容如下:
# docker-compose.test.yml —— 仅供集成测试使用的沙箱环境
version: "3.9"
services:
# 用官方的轻量 Postgres 镜像,数据不持久化
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: skill_test
POSTGRES_PASSWORD: skill_test_pass
POSTGRES_DB: skill_test_db
ports:
- "5433:5432" # 注意映射到非默认端口,防止和本地 PG 冲突
tmpfs: /var/lib/postgresql/data # 每次容器销毁后数据全部丢弃
# 如果 Skill 有 Redis 缓存,同样用无持久化模式
redis:
image: redis:7-alpine
ports:
- "6380:6379"
command: redis-server --save "" --appendonly no # 禁用 RDB/AOF
# Mock Server:用于模拟任何外部 HTTP API
wiremock:
image: wiremock/wiremock:3.5.4
ports:
- "8080:8080"
volumes:
- ./tests/mock/mappings:/home/wiremock/mappings # 预定义的 stub 规则
踩坑经验:不要在测试环境里挂载数据卷做持久化。一旦某个测试在数据库中留下脏数据而没有清理,下一个测试的运行结果就可能变成“看运气”。用
tmpfs或--rm策略确保每次环境都是从零开始的可复现状态。
1.2 编写环境启动与销毁的 fixture
在 tests/conftest.py 中添加以下 fixture,实现 pytest 会话级别的环境生命周期管理:
# tests/conftest.py
import subprocess
import time
import socket
import pytest
def _wait_for_port(host: str, port: int, timeout: int = 30):
"""轮询等待某个端口可用,超时则抛异常"""
import socket
start = time.time()
while time.time() - start < timeout:
try:
with socket.create_connection((host, port), timeout=1):
return
except (ConnectionRefusedError, OSError):
time.sleep(0.5)
raise TimeoutError(f"Port {port} on {host} not reachable after {timeout}s")
@pytest.fixture(scope="session")
def docker_services():
"""会话级 fixture:在测试开始前启动 Docker Compose 服务,测试结束后销毁"""
# 启动
subprocess.run(
["docker", "compose", "-f", "docker-compose.test.yml", "up", "-d"],
check=True
)
# 等待关键端口就绪
_wait_for_port("localhost", 5433) # Postgres
_wait_for_port("localhost", 6380) # Redis
_wait_for_port("localhost", 8080) # WireMock
yield # 所有测试在此运行
# 销毁
subprocess.run(
["docker", "compose", "-f", "docker-compose.test.yml", "down", "-v"],
check=True
)
预期结果:执行 pytest 时,你能在终端看到 Docker 容器的启动日志,随后测试用例利用 localhost:5433 等端口与沙箱内的服务交互,全部完成后所有容器自动销毁,不留下任何残留进程。
2. 录制与回放测试数据
有了沙箱环境,接下来解决“外部 API 依赖”的问题。假设你的 Skill 依赖 https://api.openweathermap.org 获取天气数据,我们不希望在回归测试中每次都去真实调用 —— 不仅慢,而且可能因为 API 配额耗尽而导致测试假失败。
2.1 配置 VCR.py 作为录制层
安装依赖:
pip install vcrpy pytest-recording
在 tests/conftest.py 中追加 VCR 配置:
import vcr
# VCR 配置:录制模式决定了“什么时候用真实请求,什么时候回放磁带”
my_vcr = vcr.VCR(
cassette_library_dir="tests/cassettes", # 磁带存放目录
record_mode="once", # 首次运行录制,之后全部回放
match_on=["method", "scheme", "host", "port", "path", "query"],
# 过滤掉敏感的 API Key,不写入磁带文件
filter_headers=["authorization"],
filter_query_parameters=["api_key", "appid"],
)
# 全局 fixture:任何测试只需使用 @pytest.mark.vcr() 即可启用录制/回放
@pytest.fixture(autouse=False)
def vcr_cassette(request):
"""为标记了 @pytest.mark.vcr() 的测试自动提供 VCR 上下文"""
marker = request.node.get_closest_marker("vcr")
if not marker:
yield
return
cassette_name = marker.kwargs.get("cassette", request.node.name)
with my_vcr.use_cassette(f"{cassette_name}.yaml"):
yield
2.2 编写第一个带录制的集成测试
下面是测试一个“天气查询 Skill”的关键路径。Skill 内部会调用 OpenWeatherMap API,我们关心的端到端行为是:“输入城市名,返回温度范围与天气描述”。
# tests/integration/test_weather_skill.py
import pytest
from my_agent.skills.weather import WeatherSkill
@pytest.mark.vcr(cassette="weather_skill_london") # 指定磁带文件名
@pytest.mark.asyncio
async def test_weather_skill_returns_forecast(docker_services):
"""
关键路径回归:查询伦敦天气,应返回包含温度区间和描述的结构化结果
"""
skill = WeatherSkill(api_key="test-key") # api_key 会被过滤器过滤,不写入磁带
result = await skill.get_forecast(city="London")
# 验证端到端的结构化输出
assert result.city == "London"
assert result.temperature_min is not None
assert result.temperature_max is not None
assert isinstance(result.description, str)
assert len(result.description) > 0
# 验证数值关系,避免出现 max < min 的脏数据
assert result.temperature_max >= result.temperature_min
首次运行:pytest -m vcr —— VCR.py 会向真实 API 发起一次 HTTP 请求,将完整的请求/响应对保存为 tests/cassettes/weather_skill_london.yaml。
后续运行:即使断网,VCR.py 也会自动拦截 requests 或 httpx 发出的 HTTP 调用,从磁带文件中返回预先录制的响应。测试行为完全一致,但速度提升约两个数量级(从秒级降到毫秒级)。
踩坑经验:磁带文件是 YAML 格式,容易包含敏感信息(如 API Key)。使用
filter_headers和filter_query_parameters在处理层剥离,同时将tests/cassettes/加入.gitignore,但在 CI 中显式恢复或重新生成。另一个团队容易忽略的问题是:磁带中记录的响应体可能包含时间戳,这些动态字段会导致断言不稳定。解决方案是在match_on中排除body,或者对响应做规范化处理(如移除时间戳后再比较)。
2.3 验证录制回放的正确性
一个简单但有效的验证方法:在断网条件下运行一遍测试套件,再对比联网条件下的结果。在 Makefile 中增加两个目标:
# 联网模式:真实请求,磁带更新
test-record:
pytest -m vcr --record-mode=rewrite
# 离线模式:仅回放,不允许任何真实网络请求
test-replay:
pytest -m vcr --block-network
预期结果:make test-replay 在断网或防火墙阻断网络的环境下也能全绿通过,且耗时显著缩短。
3. 回归测试的自动化流水线
现在沙箱环境和离线回放都就绪了,最后一个环节是将它们嵌入 CI,让每一个 PR 都自动经受回归检验。
3.1 编写 GitHub Actions 工作流
创建 .github/workflows/regression.yml:
# .github/workflows/regression.yml
# 触发条件:任何向 main 分支的 PR,或直接 push 到 main
name: Regression Suite
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
regression:
runs-on: ubuntu-latest
timeout-minutes: 15 # 防止资源泄漏的容器占用 Runner 进程
steps:
- uses: actions/checkout@v4
# 步骤 1:启动 Docker 沙箱
- name: Start Docker sandbox
run: docker compose -f docker-compose.test.yml up -d --wait
# 步骤 2:安装依赖
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r requirements.txt && pip install pytest pytest-asyncio vcrpy
# 步骤 3:以离线回放模式执行回归套件
- name: Run regression tests (replay mode)
env:
BLOCK_NETWORK: "1" # 自定义环境变量,测试框架检测到后禁用所有真实网络调用
run: pytest tests/integration/ -m vcr -v --tb=short
# 步骤 4:清理(即使前面步骤失败也会执行)
- name: Tear down Docker sandbox
if: always()
run: docker compose -f docker-compose.test.yml down -v
流程说明:
docker compose up -d --wait确保容器已启动且健康检查通过后才进入下一步BLOCK_NETWORK=1作为一种保险机制:如果某个测试没有使用 VCR 装饰器,却意外发起了真实 HTTP 请求,pytest 的pytest-recording插件会直接抛出BlockedRequestError,让问题立刻暴露--tb=short在 CI 日志中提供足够定位问题的堆栈,但不至于刷屏
3.2 预期结果:阻断破坏性变更
当某人提交了一个看似无害的 PR,比如“升级了 openai 库的版本”或“优化了 Skill 的提示词模板”,回归流水线会做以下检查:
| 检查项 | 覆盖路径 | 失败信号 |
|---|---|---|
| 依赖服务可用性 | 数据库 / Redis / WireMock 是否正常启动 | 容器启动超时失败 |
| API 契约兼容性 | 录制的 HTTP 请求是否能被新代码正确发出 | VCR 匹配失败 (CannotFindCassette) |
| 结构化输出完整性 | 关键字段是否存在、类型是否正确 | AssertionError |
| 业务逻辑不变性 | 温度范围、数据关系、边界值 | AssertionError |
整套流程运行时,你想获得一份这样的反馈:
tests/integration/test_weather_skill.py::test_weather_skill_returns_forecast PASSED [ 33%]
tests/integration/test_search_skill.py::test_search_with_pagination PASSED [ 66%]
tests/integration/test_email_skill.py::test_send_summary_email FAILED [ 100%]
FAILED tests/integration/test_email_skill.py::test_send_summary_email - AssertionError: Expected 'summary' field in response, but got None
失败的测试精确指向了哪个 Skill 的哪个关键路径出现了行为退化。现在,团队可以在合并之前定位到那行变更,而不是等用户报告“邮件摘要功能坏了”。
回顾
我们在本章完成了三件事:
- 搭建独立测试环境(~20 分钟):用 Docker Compose 编排了 Postgres、Redis 和 WireMock,通过 session 级 fixture 实现了“启动-运行-销毁”的完整生命周期。
- 录制与回放测试数据(~20 分钟):用 VCR.py 录制了真实 HTTP 响应到 YAML 磁带文件,实现了离线的确定性回放,彻底消除了外部 API 不稳定性对测试的干扰。
- 建立自动化回归流水线(~15 分钟):在 GitHub Actions 中配置了触发、执行、阻断的完整流程,确保破坏性变更在代码审查阶段就被捕获。
行动清单:
- [ ] 编写
docker-compose.test.yml并验证沙箱能够完整启动 - [ ] 为每个关键 Skill 的 API 调用编写至少一个带 VCR 装饰器的集成测试
- [ ] 在本地执行
make test-replay,确认没有遗留的真实 HTTP 调用 - [ ] 将
.github/workflows/regression.yml推送到仓库并观察首次运行结果 - [ ] 与团队约定:回归套件必须全绿才能合并 PR
现在,当我们重构 Skill 的内部逻辑或升级底层模型时,有了这张自动化安全网。但测试通过只是“功能正确”的保证,当线上真出了问题,我们还需要迅速找到根因。下一章将转向高效调试 Skills 需要可视化与可解释性工具——利用日志、Tracing 和可视化界面快速定位问题根源,让你从“知道出事了”进化到“知道哪里出事了以及为什么”。
agent skills 入门到精通
关于 LearnKu