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 也会自动拦截 requestshttpx 发出的 HTTP 调用,从磁带文件中返回预先录制的响应。测试行为完全一致,但速度提升约两个数量级(从秒级降到毫秒级)。

踩坑经验:磁带文件是 YAML 格式,容易包含敏感信息(如 API Key)。使用 filter_headersfilter_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 的哪个关键路径出现了行为退化。现在,团队可以在合并之前定位到那行变更,而不是等用户报告“邮件摘要功能坏了”。

回顾

我们在本章完成了三件事:

  1. 搭建独立测试环境(~20 分钟):用 Docker Compose 编排了 Postgres、Redis 和 WireMock,通过 session 级 fixture 实现了“启动-运行-销毁”的完整生命周期。
  2. 录制与回放测试数据(~20 分钟):用 VCR.py 录制了真实 HTTP 响应到 YAML 磁带文件,实现了离线的确定性回放,彻底消除了外部 API 不稳定性对测试的干扰。
  3. 建立自动化回归流水线(~15 分钟):在 GitHub Actions 中配置了触发、执行、阻断的完整流程,确保破坏性变更在代码审查阶段就被捕获。

行动清单

  • [ ] 编写 docker-compose.test.yml 并验证沙箱能够完整启动
  • [ ] 为每个关键 Skill 的 API 调用编写至少一个带 VCR 装饰器的集成测试
  • [ ] 在本地执行 make test-replay,确认没有遗留的真实 HTTP 调用
  • [ ] 将 .github/workflows/regression.yml 推送到仓库并观察首次运行结果
  • [ ] 与团队约定:回归套件必须全绿才能合并 PR

现在,当我们重构 Skill 的内部逻辑或升级底层模型时,有了这张自动化安全网。但测试通过只是“功能正确”的保证,当线上真出了问题,我们还需要迅速找到根因。下一章将转向高效调试 Skills 需要可视化与可解释性工具——利用日志、Tracing 和可视化界面快速定位问题根源,让你从“知道出事了”进化到“知道哪里出事了以及为什么”。

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
讨论数量: 0
发起讨论 只看当前版本


暂无话题~