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

举报
霍格沃兹测试开发学社 发表于 2026/10/21 14:20:36 2026/10/21
【摘要】 新人缺的不是一堆用例,是一条能重跑、能证伪的回归链路。面试到第十分钟,对方翻回你简历那页“自动化小项目”:改了代码之后,你怎么知道原来能用的功能没被改坏?你想起当时的动作——把那几条请求又点了一次,返回 200 就算没事。说白了,这里没有回归基线,只有你的印象。2026 年 10 月,27 届秋招还在进行中,多批次陆续开;这段时间往作品集里补东西,补一条能重跑的链路,比再多写十个断言值钱。据...
新人缺的不是一堆用例,是一条能重跑、能证伪的回归链路。

面试到第十分钟,对方翻回你简历那页“自动化小项目”:改了代码之后,你怎么知道原来能用的功能没被改坏?你想起当时的动作——把那几条请求又点了一次,返回 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

测试新人作品集做什么?它跟常见项目的差别 

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

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

面试被追问的那三层,逐层怎么答 

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

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

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

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

结语:本周先做一件事 

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

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

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

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

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。