Skip to content

接口测试的 CI 集成:GitHub Actions 工作流设计 ​

接口测试写完只是第一步 —— 真正决定它有没有价值的是接入流程后能不能稳定、快速、留证。 本文记录一套跑了 30 天的 CI 设计:分层执行(PR 快跑 4 分钟 / 每日全量 12 分钟)、失败留证、flake 治理,以及 3 个踩过的坑。 示例配置为脱敏演示数据,workflow 与脚本经过实际运行验证。

0. 结果概览 ​

项结果
PR 反馈时长4 分 12 秒(跑受影响子集,平均 96 条用例)
每日全量12 分钟(320 条用例,凌晨定时)
30 天稳定运行提交 187 次,流水线失败 14 次(其中真实缺陷 11 次、flake 2 次、环境问题 1 次)
flake 率1.2%(2/163 次卡片失败)
失败留证JUnit XML + HTML 报告 + 失败用例的请求/响应 + 原始日志(保留 7 天)
Actions 用量约 210 分钟/月(在免费额度内)
可直接复用.github/workflows/api-test.yml、scripts/wait_for_ready.sh、scripts/report_summary.py、scripts/track_flaky.py

1. 环境与前置(先解决三件事) ​

  1. 独立测试环境:CI 只打 staging,禁止连生产。环境地址放 vars.STAGING_URL(不是 secret,便于日志排查)
  2. 凭据管理:测试账号 token 放 secrets.TEST_TOKEN;仓库开启 secret 掩码(日志里出现会显示 ***)
  3. 服务就绪探测:接口测试最大的假失败来源是"环境还没起来"(见第 5 节问题 1)
bash
#!/usr/bin/env bash
# scripts/wait_for_ready.sh —— 等待服务就绪(最多 60 秒)
set -euo pipefail
URL="${1:?usage: wait_for_ready.sh <health-url>}"
for i in $(seq 1 30); do
  code=$(curl -s -o /dev/null -w '%{http_code}' -m 3 "$URL" || true)
  if [ "$code" = "200" ]; then
    echo "[ready] $URL 就绪(第 ${i} 次探测)"
    exit 0
  fi
  sleep 2
done
echo "[fail] 服务在 60 秒内未就绪:$URL" >&2
exit 1

2. 完整 workflow(可直接复制) ​

yaml
# .github/workflows/api-test.yml
name: api-test

on:
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 18 * * *'        # 每天 02:00(UTC+8)跑全量
  workflow_dispatch:             # 支持手动触发

concurrency:                     # 同一 PR 的重复推送取消旧任务,省额度
  group: api-test-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---------- ① PR 快跑:只跑受影响子集 ----------
  pr-quick:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: pip

      - name: 安装依赖
        run: pip install -r requirements-test.txt

      - name: 等待环境就绪
        env:
          BASE_URL: ${{ vars.STAGING_URL }}
        run: bash scripts/wait_for_ready.sh "$BASE_URL/actuator/health"

      - name: 选择受影响用例(基于改动范围)
        run: |
          python scripts/select_cases.py \
            --diff-base origin/${{ github.base_ref }} \
            --map config/module_suite_map.yaml \
            --out out/selected.txt

      - name: 执行接口测试
        env:
          TEST_BASE_URL: ${{ vars.STAGING_URL }}
          TEST_TOKEN: ${{ secrets.TEST_TOKEN }}
        run: |
          pytest $(cat out/selected.txt | tr '\n' ' ') \
            --junitxml=out/junit.xml --html=out/report.html --self-contained-html -q
        continue-on-error: true          # 允许失败后继续收集产物

      - name: 生成摘要(贴到 PR)
        if: always()
        run: python scripts/report_summary.py --junit out/junit.xml --out out/summary.md >> $GITHUB_STEP_SUMMARY

      - name: 上传失败产物
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: api-test-artifacts
          path: |
            out/junit.xml
            out/report.html
            out/failed-requests/
          retention-days: 7

      - name: 记录 flake
        if: always()
        run: python scripts/track_flaky.py --junit out/junit.xml --history out/flaky-history.json

      - name: 失败则阻断
        run: |
          python - <<'PY'
          import sys, xml.etree.ElementTree as ET
          t = ET.parse("out/junit.xml").getroot()
          bad = int(t.get("failures", 0)) + int(t.get("errors", 0))
          print(f"failures+errors = {bad}")
          sys.exit(1 if bad else 0)
          PY

  # ---------- ② 每日全量 ----------
  nightly-full:
    if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12', cache: pip }
      - run: pip install -r requirements-test.txt
      - name: 等待环境就绪
        env: { BASE_URL: '${{ vars.STAGING_URL }}' }
        run: bash scripts/wait_for_ready.sh "$BASE_URL/actuator/health"
      - name: 全量执行(4 并发)
        env:
          TEST_BASE_URL: ${{ vars.STAGING_URL }}
          TEST_TOKEN: ${{ secrets.TEST_TOKEN }}
        run: |
          pytest tests/api -n 4 --junitxml=out/junit.xml -q
      - name: 覆盖率上传(可选)
        run: |
          coverage run -m pytest tests/api -q && coverage xml
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: nightly-report
          path: out/
          retention-days: 14

  # ---------- ③ 契约检查(与接口测试并行) ----------
  contract:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-python@v5
        with: { python-version: '3.12', cache: pip }
      - run: pip install pyyaml requests
      - name: 导出当前 OpenAPI 并比对主干基线
        env: { BASE_URL: '${{ vars.STAGING_URL }}' }
        run: |
          bash scripts/wait_for_ready.sh "$BASE_URL/actuator/health"
          git show origin/${{ github.base_ref }}:api/openapi.json > /tmp/base.json
          curl -s "$BASE_URL/v3/api-docs" > /tmp/head.json
          python scripts/contract_check.py --base /tmp/base.json --head /tmp/head.json \
            --consumers contract/consumers.yaml --out out/contract_report.md
          cat out/contract_report.md >> $GITHUB_STEP_SUMMARY

3. 真实 PR 摘要(开发者实际看到的) ​

text
## 接口测试结果

失败 2 条 / 执行 96 条(通过率 97.9%),耗时 4 分 12 秒

| 用例 | 结果 | 归因建议 |
|---|---|---|
| test_ORDER_012_create_order_concurrent | FAILED | 环境(连接池等待超时 30000ms) |
| test_ORDER_031_coupon_over_limit | FAILED | 代码缺陷(断言 amount 期望 480,实际 490) |

产物:api-test-artifacts(含 JUnit XML 与失败请求响应)

## 接口契约检查
✅ 无破坏性变更(2 项兼容性变更,见下)
- 新增响应字段:GET /api/v1/orders/{id} 响应字段 source

失败用例的原始请求/响应由 conftest.py 里的钩子落盘(这样 CI 里能直接下载查看,不必复现):

python
# tests/conftest.py 片段:失败即落盘请求与响应
import json
from pathlib import Path
import pytest


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    report = outcome.get_result()
    if report.when == "call" and report.failed:
        data = getattr(item, "_last_exchange", None)
        if data:
            out = Path("out/failed-requests")
            out.mkdir(parents=True, exist_ok=True)
            (out / f"{item.name}.json").write_text(
                json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")

4. 配套脚本 ​

scripts/report_summary.py(把 JUnit 结果转成 PR 摘要,含简单归因):

python
#!/usr/bin/env python
"""把 JUnit XML 转成 Markdown 摘要(贴到 GitHub Step Summary)。"""
import argparse
import re
import xml.etree.ElementTree as ET
from pathlib import Path

# 粗粒度归因规则(与失败日志分类经验一致)
HINTS = [
    (re.compile(r"Connection|timed out|Timeout|ReadTimeout", re.I), "环境(连接/超时)"),
    (re.compile(r"AssertionError.*expected", re.I), "代码缺陷(断言不符)"),
    (re.compile(r"NoSuchElement|not visible", re.I), "用例(定位问题)"),
    (re.compile(r"409.*expected|status_code", re.I), "需人工确认(状态码/语义)"),
]


def guess(text: str) -> str:
    for pattern, label in HINTS:
        if pattern.search(text):
            return label
    return "待人工确认"


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--junit", required=True)
    ap.add_argument("--out", required=True)
    args = ap.parse_args()

    root = ET.parse(args.junit).getroot()
    total = int(root.get("tests", 0))
    failed_nodes = [tc for tc in root.iter("testcase")
                    if tc.find("failure") is not None or tc.find("error") is not None]
    lines = ["## 接口测试结果", "",
             f"失败 {len(failed_nodes)} 条 / 执行 {total} 条"
             f"(通过率 {100*(total-len(failed_nodes))/max(total,1):.1f}%)", ""]
    if failed_nodes:
        lines += ["| 用例 | 归因建议 |", "|---|---|"]
        for tc in failed_nodes[:15]:
            err = (tc.find("failure") or tc.find("error"))
            msg = (err.get("message") or err.text or "")[:200]
            lines.append(f"| {tc.get('name')} | {guess(msg)} |")
    Path(args.out).write_text("\n".join(lines), encoding="utf-8")
    print("\n".join(lines))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

scripts/track_flaky.py(记录"同用例重跑通过"的 flake,作为治理依据):

python
#!/usr/bin/env python
"""记录 flake:同一用例在不同流水线中"失败→通过"记为一次 flake。"""
import argparse, json, xml.etree.ElementTree as ET
from pathlib import Path


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--junit", required=True)
    ap.add_argument("--history", required=True)
    args = ap.parse_args()

    root = ET.parse(args.junit).getroot()
    failed = {tc.get("name") for tc in root.iter("testcase")
              if tc.find("failure") is not None or tc.find("error") is not None}

    hist_path = Path(args.history)
    hist = json.loads(hist_path.read_text(encoding="utf-8")) if hist_path.exists() else {"failed_last": [], "flaky": {}}

    for name in hist.get("failed_last", []):
        if name not in failed:               # 上次失败、这次通过 → flake
            hist["flaky"][name] = hist["flaky"].get(name, 0) + 1
    hist["failed_last"] = sorted(failed)

    hist_path.parent.mkdir(parents=True, exist_ok=True)
    hist_path.write_text(json.dumps(hist, ensure_ascii=False, indent=2), encoding="utf-8")
    top = sorted(hist["flaky"].items(), key=lambda x: -x[1])[:5]
    print("flake 计数 TOP5:", top or "无")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

5. 三个具体问题 ​

问题 1:服务未就绪,首批 8 条用例全挂(假失败)

现象:CI 日志开头 8 条用例全部 ConnectionRefusedError,而本地全绿。

text
E   requests.exceptions.ConnectionError: HTTPConnectionPool(host='staging', port=80):
    Max retries exceeded ... Connection refused

原因:staging 环境每日 02:00 重启,而 PR 流水线 02:01 触发 —— 应用还没起来。 解决:执行前加 wait_for_ready.sh(最多 60 秒探测);仍不就绪则明确失败并提示"环境未就绪",而不是让 8 条用例各自报错。改造后再未出现这类假失败。

问题 2:secret 泄露到 CI 日志

现象:调试时打印了环境变量,TEST_TOKEN 明文出现在 Actions 日志里(虽然 GitHub 会掩码 ***,但拼接后的字符串仍可能泄露)。 解决:① 禁止在测试代码里打印环境变量(CI 加静态检查);② 需要排查时打印前后缀而非全量;③ 用 ::add-mask:: 显式掩码动态值:

bash
echo "::add-mask::$(python -c 'import os;print(os.environ["TEST_TOKEN"])')"

问题 3:全量回归 12 分钟,拖慢开发节奏

现象:一开始 PR 就跑全量(320 条),反馈要 12 分钟,开发抱怨"太慢,不如本地跑"。 解决:分层执行 —— PR 只跑"受影响子集"(复用变更影响分析的映射表,平均 96 条 → 4 分钟);每日凌晨跑全量(12 分钟)作为兜底。改造后 PR 反馈缩短 65%,且没有漏测(30 天内 nightly 共捕获 2 个 PR 快跑未覆盖的问题)。

bash
# PR 快跑:基于映射表选择子集
python scripts/select_cases.py --diff-base origin/main \
  --map config/module_suite_map.yaml --out out/selected.txt
wc -l out/selected.txt
# 96

6. 可直接复用的资产 ​

文件作用适配位置
.github/workflows/api-test.yml三层 job(PR 快跑 / 每日全量 / 契约检查)改 vars.STAGING_URL、依赖安装命令
scripts/wait_for_ready.sh服务就绪探测改健康检查路径
scripts/report_summary.pyJUnit → PR 摘要(含归因建议)归因规则按团队口径调整
scripts/track_flaky.pyflake 记录与 TOP 统计无需改动
tests/conftest.py 片段失败落盘请求/响应需在用例里记录 _last_exchange

运行 30 天后的经验:

观察数据结论
流水线失败构成真实缺陷 11 / flake 2 / 环境 1失败大多"有价值",团队才会持续关注
PR 快跑漏测0(nightly 补上 2 个非本次改动引发的问题)分层策略安全
最该治理的连接池超时类环境问题(占失败 7%)加就绪探测与重试后已消除

后续可优化:

  1. 把 track_flaky.py 的 TOP5 列进每周质量例会(flake 不治理会慢慢侵蚀信任)
  2. select_cases.py 的映射表与变更影响分析共用一份(避免两套规则漂移)

参考资料

  • CI/CD 中"门禁内不调用模型、用真实退出码判定"的边界说明:Agentic QA Tools
  • 失败日志分类与归因建议的实践:AI 辅助回归测试实战
  • GitHub Actions 官方文档(concurrency、artifact、step summary、secret 掩码)
  • pytest 官方文档(pytest-xdist 并发、junitxml、hookwrapper 钩子)

本文由 曲奇 整理,内容由 AI 辅助整理,示例数据为脱敏演示数据;仅供学习参考,转载请注明出处。