pytest + GitHub Actions:给录制回放加一道退出码门禁

简介: 新人最缺的不是用例,而是一条可重跑、可证伪的回归链路。本文手把手教你用5个纯Python文件(零第三方依赖),基于录制—回放构建最小可行回归基线:选可变目标→录制请求/响应对→回放比对→按“完全一致/文本改写/调用增减”三类差异输出0/1/2退出码,直通CI门禁。

新人缺的不是一堆用例,是一条能重跑、能证伪的回归链路。

面试到第十分钟,对方翻回你简历那页“自动化小项目”:改了代码之后,你怎么知道原来能用的功能没被改坏?你想起当时的动作——把那几条请求又点了一次,返回 200 就算没事。说白了,这里没有回归基线,只有你的印象。

2026 年 10 月,27 届秋招还在进行中,多批次陆续开;这段时间往作品集里补东西,补一条能重跑的链路,比再多写十个断言值钱。据 Google 官方 ADK 评估文档,录制—回放这套做法在智能体评估里已经被做成官方命令:adk conformance record 负责录,adk conformance test 负责回放比对,基线落成两份 YAML 文件。你手上要做的,是它一个人人跑得动的最小版,四步:选目标、录制、回放比对、退出码。

先把三个词摆正

回归基线 是一次被确认没问题的运行留下的完整证据,之后每一次改动都跟这份证据比,而不是跟你的记忆比。录制回放 是把请求与响应对先存成文件、再让当前代码走同一条路径去比对新结果的做法。可重跑 是这条链路的准入门槛:换台机器、换个时间跑,结论一样才叫基线,不一样那叫现场。

三类差异 是比对器的输出分类——完全一致、文本改写、新出现的调用(外加它的反面:消失的调用)。这三类你测过吗?分类的价值不在报了几条,在于每一类后面接的动作不一样:一致不用看,改写进复核,调用集变了当场拦下。

第一步别写用例,先挑一个会“变”的目标

结论:目标要同时满足两条——它自己会决定调几次外部能力,它依赖的外部能力会给出说法不完全稳定的文本。一个查天气的小工具就够,不需要真模型。

为什么不能挑一个纯计算函数?因为它输出稳定,回放永远一致,你写完整套链路也看不到差异长什么样。weather_tool.answer(query) 把一句自然语言拆成对外部能力的调用,拿到回复再拼成答案;fakesvc.py 是被模拟的外部端点,按 WORDING 换说法,模拟“上游升了个小版本”。

整套项目五个文件,下面按出场顺序贴原文:只用标准库加 pytest,照抄进一个空目录就能跑。第一段是被模拟的外部能力:

"""一个被模拟的外部能力:只会回文本的“天气助手”端点。

所有数据均为构造样本,不指涉任何真实接口、真实模型或真实企业系统。
WORDING 用来模拟“外部能力升了个小版本”:它只改写 rain_check 一类回答的说法。
"""

CITY = {
"北京": {"sky": "晴", "c": 21, "wind": 2, "rain": "无"},
"上海": {"sky": "多云", "c": 24, "wind": 3, "rain": "无"},
"广州": {"sky": "小雨", "c": 26, "wind": 2, "rain": "有"},
"深圳": {"sky": "雷阵雨", "c": 33, "wind": 4, "rain": "有"},
}

WORDING = "v1"

def complete(req):
city = req["city"]
if city notin CITY:
return {"text": f"查不到 {city} 的数据。"}
d = CITY[city]

if req["kind"] == "weather":          # 这一类两个版本说法一致
    return {"text": f"{city} {d['sky']} {d['c']}℃,风 {d['wind']} 级。"}

if req["kind"] == "rain_check":       # 这一类在 v2 被改写了说法
    if WORDING == "v1":
        if d["rain"] == "有":
            return {"text": f"{city} 有雨,建议带伞。"}
        return {"text": f"{city} 无雨,不用带伞。"}
    if d["rain"] == "有":
        return {"text": f"{city} 今天有降水,带伞更稳妥。"}
    return {"text": f"{city} 今天无降水,可以不带伞。"}

if req["kind"] == "alert":            # v2 才会用到的新调用
    return {"text": f"{city} 高温 {d['c']}℃,注意防暑。"}

return {"text": f"未知请求类型:{req['kind']}"}

第二段是被测目标,它自己决定要发几次调用:

"""被测目标:一个会把自然语言拆成外部调用的最小工具函数。

TOOL_VERSION = 1 是录制基线时的代码;改成 2 模拟“你加了一个需求”。
"""
import re

import fakesvc

TOOL_VERSION = 1
HIGH_TEMP = 30

def plan_calls(query):
city = next((c for c in fakesvc.CITY if c in query), None)
if city isNone:
return []
calls = [{"kind": "weather", "city": city}]
if any(k in query for k in ("下雨", "带伞")):
calls.append({"kind": "rain_check", "city": city})
return calls

def answer(query):
calls = plan_calls(query)
ifnot calls:
return calls, [], "没识别出城市。"
replies = [fakesvc.complete(c) for c in calls]

if TOOL_VERSION >= 2:
    m = re.search(r"(\d+)℃", replies[0]["text"])
    if m and int(m.group(1)) >= HIGH_TEMP:
        new_call = {"kind": "alert", "city": calls[0]["city"]}
        calls.append(new_call)
        replies.append(fakesvc.complete(new_call))

return calls, replies, " ".join(r["text"] for r in replies)

录制回放怎么实现?把请求和响应对钉成一个文件

基线文件里存的不该只是最终答案,要存到每一次调用的请求体和响应体。只存答案,差异报出来你只能猜是哪一步变了。

录制时把外部能力和被测工具都钉回基线版本,录完就是这条链路的起点:

"""第 2 步:录制。把“请求 + 响应”成对落到一个基线文件里。"""
import json

import fakesvc
import weather_tool

CASES = [
"北京今天天气怎么样",
"上海多少度",
"广州会下雨吗",
"深圳需要带伞吗",
"深圳今天热不热",
]

def record(cases=CASES, path="recordings.json"):
fakesvc.WORDING = "v1"
weather_tool.TOOL_VERSION = 1
baseline = {"target": "weather_tool.answer", "wording": fakesvc.WORDING, "cases": []}
for i, query in enumerate(cases, start=1):
calls, replies, reply = weather_tool.answer(query)
baseline["cases"].append({
"id": f"c{i}",
"query": query,
"calls": [{"req": c, "resp": r} for c, r in zip(calls, replies)],
"reply": reply,
})
with open(path, "w", encoding="utf-8") as f:
json.dump(baseline, f, ensure_ascii=False, indent=2)
print(f"录制完成:{len(baseline['cases'])} 条用例、"
f"{sum(len(c['calls']) for c in baseline['cases'])} 组请求响应 → {path}")
return baseline

if name == "main":
record()
$ python record.py
录制完成:5 条用例、7 组请求响应 → recordings.json
退出码=0
基线文件的顶层是 target、wording、cases 三个键,其中一条 case 长这样,请求与响应成对钉住:

{
"id": "c3",
"query": "广州会下雨吗",
"calls": [
{
"req": {
"kind": "weather",
"city": "广州"
},
"resp": {
"text": "广州 小雨 26℃,风 2 级。"
}
},
{
"req": {
"kind": "rain_check",
"city": "广州"
},
"resp": {
"text": "广州 有雨,建议带伞。"
}
}
],
"reply": "广州 小雨 26℃,风 2 级。 广州 有雨,建议带伞。"
}
三类差异怎么判:比对器只认签名,不认印象

比对器只做一件事:给每次调用算一个签名(请求类型 + 参数),拿基线签名集和当前签名集做集合比对,剩下逐条比文本。用 (kind, city) 当键而不是数组下标,是为了中间多插一次调用时,前后条不会被错配成“文本改写”。

"""第 3、4 步:回放 + 逐条比对 + 退出码规则。

三类差异:完全一致 / 文本改写 / 调用集变了(新增或缺失)。
退出码:0 = 只有完全一致;1 = 出现文本改写(进复核);2 = 出现调用集变化(当场拦下)。
"""
import json
import sys

import fakesvc
import weather_tool

BASELINE = "recordings.json"

def sig(call):
return (call["kind"], call["city"])

def compare(path=BASELINE):
with open(path, encoding="utf-8") as f:
baseline = json.load(f)
fakesvc.WORDING = "v2" # 外部能力升了个小版本
weather_tool.TOOL_VERSION = 2 # 你刚改了工具代码
rows = []
for case in baseline["cases"]:
calls, replies, reply = weather_tool.answer(case["query"])
base = {sig(c["req"]): c["resp"]["text"] for c in case["calls"]}
cur = {sig(c): r["text"] for c, r in zip(calls, replies)}
added = [s for s in cur if s notin base]
missing = [s for s in base if s notin cur]
rewrites = [(s, base[s], cur[s]) for s in base if s in cur and base[s] != cur[s]]
detail = [f"~ {s[0]}({s[1]})基线:{old}\n 现在:{new}"
for s, old, new in rewrites]
detail += [f"+ {a[0]}({a[1]}):{cur[a]}"for a in added]
detail += [f"- {m[0]}({m[1]}):基线里有,现在不再调了"for m in missing]

    # 一条用例可以叠着好几类差异,归类时取最严重的那一档
    if added or missing:
        kind = "新增调用"if added else"缺失调用"
    elif rewrites:
        kind = "文本改写"
    else:
        kind = "完全一致"
    rows.append({"id": case["id"], "query": case["query"], "class": kind,
                 "detail": detail, "reply_changed": reply != case["reply"]})
return rows

def main():
rows = compare()
for r in rows:
print(f"[{r['id']}] {r['query']} → {r['class']}")
for line in r["detail"]:
print(f" {line}")
n = {k: sum(1for x in rows if x["class"] == k)
for k in ("完全一致", "文本改写", "新增调用", "缺失调用")}
print("统计:" + "、".join(f"{k} {v} 条"for k, v in n.items()))
if n["新增调用"] or n["缺失调用"]:
sys.exit(2)
if n["文本改写"]:
sys.exit(1)
sys.exit(0)

if name == "main":
main()
$ python replay_diff.py
[c1] 北京今天天气怎么样 → 完全一致
[c2] 上海多少度 → 完全一致
[c3] 广州会下雨吗 → 文本改写
~ rain_check(广州)基线:广州 有雨,建议带伞。
现在:广州 今天有降水,带伞更稳妥。
[c4] 深圳需要带伞吗 → 新增调用
~ rain_check(深圳)基线:深圳 有雨,建议带伞。
现在:深圳 今天有降水,带伞更稳妥。

    + alert(深圳):深圳 高温 33℃,注意防暑。

[c5] 深圳今天热不热 → 新增调用

    + alert(深圳):深圳 高温 33℃,注意防暑。

统计:完全一致 2 条、文本改写 1 条、新增调用 2 条、缺失调用 0 条
退出码=2
看 c4:一次运行叠着两类差异,归类只报最严重的那一档,但两条证据都留在 detail 里。为什么这么写?因为“改坏了功能”最容易长这个样子——调用集变了,你只盯着最终文本,会以为只是话说得不同。

退出码就是门禁:挂到 pytest 和 GitHub Actions 上

结论:比对器给三条退出码——0 全一致、1 有文本改写(进复核)、2 调用集变了(当场拦下)。门禁不靠人读日志,靠流水线读退出码。

"""把回放器挂成一条会自己红的回归门禁。

跑法:python -m pytest -q
"""
import pytest

import replay_diff

ROWS = {r["id"]: r for r in replay_diff.compare()}
IDS = sorted(ROWS)

@pytest.mark.parametrize("case_id", IDS)
def test_call_set_matches_baseline(case_id):
r = ROWS[case_id]
assert r["class"] notin ("新增调用", "缺失调用"), \
f"{case_id}『{r['query']}』调用集变了:{r['detail']}"

@pytest.mark.parametrize("case_id", IDS)
def test_every_diff_is_explainable(case_id):
r = ROWS[caseid]
if r["class"] == "完全一致":
pytest.skip("无差异,不进复核")
assert r["detail"], "报出来的差异必须带可读证据"
$ python -m pytest -q
...FFss... [100%]
================================== FAILURES ===================================
__
test_call_set_matches_baseline[c4] __

case_id = 'c4'

E AssertionError: c4『深圳需要带伞吗』调用集变了:['~ rain_check(深圳)基线:深圳 有雨,建议带伞。\n 现在:深圳 今天有降水,带伞更稳妥。', '+ alert(深圳):深圳 高温 33℃,注意防暑。']
E assert '新增调用' not in ('新增调用', '缺失调用')

test_regressionbaseline.py:16: AssertionError
__
test_call_set_matches_baseline[c5] __
E AssertionError: c5『深圳今天热不热』调用集变了:['+ alert(深圳):深圳 高温 33℃,注意防暑。']
=========================== short test summary info ===========================
FAILED test_regression_baseline.py::test_call_set_matches_baseline[c4] - Asse...
FAILED test_regression_baseline.py::test_call_set_matches_baseline[c5] - Asse...
2 failed, 6 passed, 2 skipped in 0.04s
退出码=1
这个 pytest 小项目的依赖只有标准库加 pytest 本身:python record.py 出基线,python -m pytest -q 出红与绿,两条命令就是全部上手成本。...FFss... 里那两个 s 是第二条测试在无差异用例上的 skip——门禁要能区分“没差异”和“没检查”。失败块里 pytest 自带的源码上下文行我省掉了,断言行与结尾摘要原样照贴;最后那行 退出码=1 就是流水线读到的东西。再补一句评估(Evaluation)视角:回放比的是“和上次是否一致”,不是“好不好”,想让这套东西顺带评质量,得另加评分口径,别把两件事混在一个门禁里。

流水线只读退出码:0 放行,1 进复核,2 拦下

  • name: replay regression baseline
    run: python replay_diff.py
    测试新人作品集做什么?它跟常见项目的差别

结论:面试官挑项目,挑的是“被追问第三层你还有没有话”。这五个文件的深度就藏在两张表里。

这个项目能证明
对应不能证明的那半句
可重跑性:换环境跑同样结论
不能证明线上真实外部能力的表现,响应全构造
差异可判定:三类差异各有动作
不能判定“改写后的说法是不是更好”,那要人复核
基线所有权:基线是文件,进版本库
不能自动翻新基线,改基线要有理由和一次评审
失败可定位:证据报到调用级
不能覆盖你没写进 CASES 的输入,覆盖靠用例集
门禁可交给机器:退出码
不能替代断言业务对错,只替代“再点一遍”
维度
录制回放基线
常见新人三件套(爬虫/接口自动化/纯 UI 脚本)
判据
与上一次确认过的运行比
与“我以为对”比,或只断言状态码
证据
请求响应对 + 分类 + 退出码
截图或一段 print
改坏功能时
调用集变化当场红
往往只是“某条挂了”,说不清改了什么
外部能力变了
归到文本改写,进复核队列
全线飘红或全线看不出,靠肉眼
被追问第三层
有基线、有分类、有门禁可讲
容易停在“我用 XX 框架写了多少条”
面试被追问的那三层,逐层怎么答

先说简历那一行。别写“基于 pytest 的自动化测试框架”,写:为一个调用外部能力的工具函数搭了录制—回放回归基线(5 条用例、7 组请求响应对),比对器把差异分成完全一致/文本改写/调用集变化三类,映射到 0、1、2 三档退出码并交给 CI 做门禁。这句话里每个数字都能被当场打开仓库对上。讲的时候按“判据—动作—代价—验证”四拍走:判据是拿调用签名比而不是拿最终文本比;动作是把请求与响应对钉成基线文件;代价是上游真改版时,改写这一档要人读一遍才能翻新基线;验证是那次把工具版本从 1 改到 2 的动作被退出码 2 当场拦下。

再回答追问。第一层问基线里存了什么。答:存到每次调用的请求体和响应体,不只存最终答案,所以差异能报到调用级。第二层问外部服务换了会怎样。答:会被分到文本改写这一档,退出码 1,进复核而不是直接放行;复核的动作是把新响应读一遍,确认语义没变,再决定翻新基线还是回退代码。第三层问回放全部通过是不是就没事。答:不是,回放全过只说明当前代码与基线一致,它不覆盖基线之外没录过的输入,也替代不了对真实外部能力的探索测试。这三问三答写进 README,项目才算做完——README 是这份作品集的接口文档,比代码更早被人读。

边界:这四步管到哪儿,管不到哪儿

三条划清。第一,fakesvc 的响应全是我构造的数据,不是任何真实接口的录制;换真实上游,第一件事是把它的不确定性(时间戳、随机 id、会话标记)在录制层剥掉,否则基线第一天就漂。第二,本次五条用例的分类结果(两条完全一致、一条文本改写、两条新增调用)都是这份脚本在本机的确定性输出,不外推成任何“回归效率提升百分之几”的结论,也不代表任何真实项目的差异比例。第三,示例目标刻意没上真模型,所以它证明的是链路会搭,不证明你会调模型;给智能体应用做同类回放时,多出来的是工具顺序与终止条件两个要签名的维度。

结语:本周先做一件事

今天就动手:挑你手上那个只会被“再点一遍”的项目,写一个 record.py 把当前行为录成基线文件,再写一个比对器把差异分成三类,最后给它一条退出码。下次被问“你怎么知道没改坏”,你要拿得出一个文件名。

手动再点一遍,只证明你今天心情不错;能重跑的基线,才证明昨天那个行为还在。

想把这条链路补齐到能进面试的厚度,走体系课做的是同一件事的更深一层:真实外部能力、基线翻新策略和流水线门禁。

本文部分内容参考了霍格沃兹测试开发学社整理的相关技术资料,主要涉及软件测试、自动化测试、测试开发及 AI 测试等内容,侧重测试实践、工具应用与工程经验整理。

相关文章
|
6天前
|
云安全 人工智能 运维
当攻击全面Agent化,你的安全防御能否跟上AI Speed?
多起客户侧AK泄露事件应急的真实观察
1303 6
|
5天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
|
10天前
|
存储 人工智能 安全
千问办公官网入口网页版登录:qwenwork.cn 注册送2000积分、登录送100积分
阿里云千问办公(QwenWork)是AI智能工作平台,支持一句话生成PPT、表格、网页、视频及数据分析。个人免费版送2000积分+每日登录赠100分,含1GB存储、5个发布页;企业版198元/席/月起。阿里千问办公QwenWork官网:https://t.aliyun.com/U/0VCTGt 阿里AI工作平台,一句话完成数据分析、PPT 生成、视频剪辑、网页搭建等复杂任务
|
29天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
9928 33
|
5天前
|
人工智能 开发者
Qoder CN 大使计划正式开启|无限创造,等你一起
全年免费积分、优先内测、活动支持与更多共创机会,邀请用 Qoder 把想法变成现实的你。
497 0
|
16天前
|
存储 人工智能 并行计算
大模型本地部署终端选型方法论:以 Qwen3.8-27B 为例的四档分层完整流程
本文提出一套大模型本地部署终端选型方法论:定约束、定档位、定框架、定参数四步决策法,配合入门、主力、质量、无损四档分层模型。以 Qwen3.8-27B 实测数据为例,逐环节解读显存、带宽、存储、散热、系统、预算等要素,给出面向不同预算的优选方案、决策自查清单与市场观察框架。文末前瞻 AI 笔记本的 CPU+GPU 与统一内存两条路线,论证四步决策法在新品类上的延续性。
|
7天前
|
Web App开发 人工智能 JavaScript
太猛了,一个月干到15W+,给大家推荐一个最近爆火的开源逆向工具:REA
REA(Reverse Engineer Anything)是近期爆火的开源AI逆向工具,4月发布,1个月内斩获15万+ Star。它让AI Agent无需源码即可分析二进制、APK、JS/Electron、HAR包等17类目标,自动反编译、生成可运行代码,并强制返回带证据与局限的结论。全本地执行,MIT协议,支持Hopper/Ghidra/IDA等引擎,一行命令即可上手。
729 2
太猛了,一个月干到15W+,给大家推荐一个最近爆火的开源逆向工具:REA
|
14天前
|
人工智能 JSON 自然语言处理
2026 年 Jev 决策模型深度拆解:原理解读、实战测评与保姆级落地教程
有一款特殊AI模型在开发者圈子刷屏,它摒弃传统大模型擅长的对话聊天能力,专注做高速结构化决策,它就是TypeSafe AI推出的Jev模型。该模型由ChatGPT共同发明人Diogo Almeida主导研发,定位为**System One Model(系统一模型)**,对标人类大脑快速直觉判断的思维模式,在响应延迟、调用成本、结构化输出稳定性上相比传统生成式大模型有着巨大差异。本文会完整拆解Jev底层原理、三大核心原语能力、适用业务场景,同时提供可直接运行的curl、Python代码示例,并且结合多组实测数据,客观分析模型优势与能力边界,帮助普通开发者和AI应用从业者快速上手落地。
1052 1