pytest + GitHub Actions:给录制回放加一道退出码门禁
新人缺的不是一堆用例,是一条能重跑、能证伪的回归链路。
面试到第十分钟,对方翻回你简历那页“自动化小项目”:改了代码之后,你怎么知道原来能用的功能没被改坏?你想起当时的动作——把那几条请求又点了一次,返回 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[case_id]
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_regression_baseline.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
测试新人作品集做什么?它跟常见项目的差别
结论:面试官挑项目,挑的是“被追问第三层你还有没有话”。这五个文件的深度就藏在两张表里。
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
面试被追问的那三层,逐层怎么答
先说简历那一行。别写“基于 pytest 的自动化测试框架”,写:为一个调用外部能力的工具函数搭了录制—回放回归基线(5 条用例、7 组请求响应对),比对器把差异分成完全一致/文本改写/调用集变化三类,映射到 0、1、2 三档退出码并交给 CI 做门禁。这句话里每个数字都能被当场打开仓库对上。讲的时候按“判据—动作—代价—验证”四拍走:判据是拿调用签名比而不是拿最终文本比;动作是把请求与响应对钉成基线文件;代价是上游真改版时,改写这一档要人读一遍才能翻新基线;验证是那次把工具版本从 1 改到 2 的动作被退出码 2 当场拦下。
再回答追问。第一层问基线里存了什么。答:存到每次调用的请求体和响应体,不只存最终答案,所以差异能报到调用级。第二层问外部服务换了会怎样。答:会被分到文本改写这一档,退出码 1,进复核而不是直接放行;复核的动作是把新响应读一遍,确认语义没变,再决定翻新基线还是回退代码。第三层问回放全部通过是不是就没事。答:不是,回放全过只说明当前代码与基线一致,它不覆盖基线之外没录过的输入,也替代不了对真实外部能力的探索测试。这三问三答写进 README,项目才算做完——README 是这份作品集的接口文档,比代码更早被人读。
边界:这四步管到哪儿,管不到哪儿
三条划清。第一,fakesvc 的响应全是我构造的数据,不是任何真实接口的录制;换真实上游,第一件事是把它的不确定性(时间戳、随机 id、会话标记)在录制层剥掉,否则基线第一天就漂。第二,本次五条用例的分类结果(两条完全一致、一条文本改写、两条新增调用)都是这份脚本在本机的确定性输出,不外推成任何“回归效率提升百分之几”的结论,也不代表任何真实项目的差异比例。第三,示例目标刻意没上真模型,所以它证明的是链路会搭,不证明你会调模型;给智能体应用做同类回放时,多出来的是工具顺序与终止条件两个要签名的维度。
结语:本周先做一件事
今天就动手:挑你手上那个只会被“再点一遍”的项目,写一个 record.py 把当前行为录成基线文件,再写一个比对器把差异分成三类,最后给它一条退出码。下次被问“你怎么知道没改坏”,你要拿得出一个文件名。
手动再点一遍,只证明你今天心情不错;能重跑的基线,才证明昨天那个行为还在。
想把这条链路补齐到能进面试的厚度,走体系课做的是同一件事的更深一层:真实外部能力、基线翻新策略和流水线门禁。
本文部分内容参考了霍格沃兹测试开发学社整理的相关技术资料,主要涉及软件测试、自动化测试、测试开发及 AI 测试等内容,侧重测试实践、工具应用与工程经验整理。
- 点赞
- 收藏
- 关注作者
评论(0)