主题
接口测试的 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. 环境与前置(先解决三件事)
- 独立测试环境:CI 只打
staging,禁止连生产。环境地址放vars.STAGING_URL(不是 secret,便于日志排查) - 凭据管理:测试账号 token 放
secrets.TEST_TOKEN;仓库开启 secret 掩码(日志里出现会显示***) - 服务就绪探测:接口测试最大的假失败来源是"环境还没起来"(见第 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 12. 完整 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_SUMMARY3. 真实 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
# 966. 可直接复用的资产
| 文件 | 作用 | 适配位置 |
|---|---|---|
.github/workflows/api-test.yml | 三层 job(PR 快跑 / 每日全量 / 契约检查) | 改 vars.STAGING_URL、依赖安装命令 |
scripts/wait_for_ready.sh | 服务就绪探测 | 改健康检查路径 |
scripts/report_summary.py | JUnit → PR 摘要(含归因建议) | 归因规则按团队口径调整 |
scripts/track_flaky.py | flake 记录与 TOP 统计 | 无需改动 |
tests/conftest.py 片段 | 失败落盘请求/响应 | 需在用例里记录 _last_exchange |
运行 30 天后的经验:
| 观察 | 数据 | 结论 |
|---|---|---|
| 流水线失败构成 | 真实缺陷 11 / flake 2 / 环境 1 | 失败大多"有价值",团队才会持续关注 |
| PR 快跑漏测 | 0(nightly 补上 2 个非本次改动引发的问题) | 分层策略安全 |
| 最该治理的 | 连接池超时类环境问题(占失败 7%) | 加就绪探测与重试后已消除 |
后续可优化:
- 把
track_flaky.py的 TOP5 列进每周质量例会(flake 不治理会慢慢侵蚀信任) select_cases.py的映射表与变更影响分析共用一份(避免两套规则漂移)
参考资料
- CI/CD 中"门禁内不调用模型、用真实退出码判定"的边界说明:Agentic QA Tools
- 失败日志分类与归因建议的实践:AI 辅助回归测试实战
- GitHub Actions 官方文档(concurrency、artifact、step summary、secret 掩码)
- pytest 官方文档(pytest-xdist 并发、junitxml、hookwrapper 钩子)
本文由 曲奇 整理,内容由 AI 辅助整理,示例数据为脱敏演示数据;仅供学习参考,转载请注明出处。