AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

日志、pytest 与 CI:让“这次能跑”变成可以复查的证据

我的学习 →

正在读取学习记录…

测试是把一组输入和期望结果写成可重复运行的检查;日志记录程序实际走过的关键步骤;CI 是在约定的环境里自动运行这些检查的流程。三者职责不同,却能接成一条证据链:改代码、运行测试、定位结果,再交给另一台机器复查。

先让资料助手的 normalize_limit(3) 返回 3,写一条测试确认它。接着故意传入 True,看测试怎样暴露错误,再用日志和退出码定位问题。

从一次正常调用开始:返回值告诉调用者结果,测试检查约定,日志帮助定位过程。

先写一个小函数,再用测试检查它

创建 agentdepth-quality 学习文件夹,用编辑器在里面新建 quality_lab.py,复制并保存下面的完整代码。函数接收一个条数,合法时原样返回,类型或范围不对时抛出异常。

Python
def normalize_limit(raw):
    if type(raw) is not int:
        raise TypeError("limit must be an integer")
    if not 1 <= raw <= 5:
        raise ValueError("limit must be between 1 and 5")
    return raw

在这个文件夹打开 PowerShell。pytest 是帮助我们运行测试的工具,需要先安装。依次创建环境、选择当前窗口的解释器、安装 pytest、确认安装成功:

PowerShell
py -3.13 -m venv .venv
$env:Path = "$PWD\.venv\Scripts;$env:Path"
python -c "import sys; print(sys.executable)"
python -m pip install pytest
python -m pytest --version
macOS 终端命令
Bash
python3.13 -m venv .venv
source .venv/bin/activate
python -c "import sys; print(sys.executable)"
python -m pip install pytest
python -m pytest --version

安装成功后,终端会显示 pytest 的版本号。安装需要联网;连接失败时检查网络或包源。后面的命令直接使用这个虚拟环境里的 Python。

安装完成后,在同一目录执行下面这条命令:

Bash
python -c "from quality_lab import normalize_limit; print(normalize_limit(3))"

输出 3,说明函数接受了这条合法输入。接着在同一文件夹新建 test_first.py,复制并保存:

Python
from quality_lab import normalize_limit

def test_three_is_accepted():
    assert normalize_limit(3) == 3

运行 python -m pytest test_first.py -q,预期 1 passed。assert 检查左边计算出的结果是不是右边的期望值;改成 == 4 再运行,就会看到实际为3、期望为4的失败报告。恢复为3后继续。基础收集规则可核对 pytest 入门说明。

1. 先把“完成”写成可检查的约定

“限制必须正确”不是测试目标。可以改成:输入必须是精确的整数,范围 1 到 5;True 被拒绝;合法值返回相同整数;失败要区分类型错误和值错误。约定越具体,测试越不容易只测到实现表面。

三格插画:数字3通过,True意外漏过,增加反例测试后红字指出校验缺口
“正常输入能跑”与“错误输入会被拦住”是两件事。红色失败信息不是坏消息,它告诉我们哪条约定还没实现。
代码约定经过pytest断言和日志记录,在CI中用同样命令重复验证
图1:质量回路的职责分开:测试回答是否违反约定,日志帮助定位经过哪一步,CI负责在另一环境重复命令。

一个好的测试名称能告诉读者失败意味着什么;一个好的日志字段能帮助定位输入经过了哪一层。二者不是重复记录:测试是机器可判定的断言,日志是运行时的观察证据。

2. 给函数加入日志和返回结果

刚才的函数已经能检查条数。现在加入日志,再增加一个 run 函数,把成功和失败整理成字典,方便调用者读取。用下面的完整代码替换 quality_lab.py 的内容并保存:

Python
# 按固定输入观察日志、pytest 与 CI的处理过程和返回结果。
import logging

logger = logging.getLogger(__name__)


def normalize_limit(raw: object) -> int:
    logger.info("validate limit type=%s", type(raw).__name__)
    if type(raw) is not int:
        raise TypeError("limit must be an integer")
    if not 1 <= raw <= 5:
        raise ValueError("limit must be between 1 and 5")
    return raw


def run(raw: object) -> dict[str, object]:
    try:
        value = normalize_limit(raw)
    except (TypeError, ValueError) as exc:
        logger.warning("validation rejected category=%s", type(exc).__name__)
        return {"status": "rejected", "error": type(exc).__name__}
    logger.info("validation accepted")
    return {"status": "ready", "limit": value}

为什么不把日志当作结果?日志可能被级别过滤、被采集系统延迟,也可能因为配置错误没有输出。调用方仍应通过返回值或异常判断行为。日志的价值在于补充阶段、耗时、请求 ID 和分类,而不是承担程序契约。

type(raw) is not int 有意拒绝布尔值;Python 中 bool 是 int 的子类,isinstance(True, int) 会通过。生产系统是否允许这种值要由业务契约决定,本例用测试把决定固定下来。

这条语言规则见 Python 标准类型层次;本例的拒绝行为由后面的 True 测试实际检查,而不是仅凭文档推断。

3. pytest 先写能失败的测试

断言是一条机器可检查的期望,例如 assert normalize_limit(5) == 5:先调用函数,再检查返回值是不是5。如果不是,测试失败,并展示预期与实际差异。它不是把所有输入自动验证一遍,只检查你写出的条件。

在同一文件夹新建 test_quality_lab.py,复制下面的完整测试代码并保存:

Python
# 按固定输入观察日志、pytest 与 CI的处理过程和返回结果。
import pytest

from quality_lab import normalize_limit, run


@pytest.mark.parametrize(
    "raw, expected_error",
    [(True, TypeError), (0, ValueError), (6, ValueError)],
)
def test_invalid_limits_are_rejected(raw, expected_error):
    with pytest.raises(expected_error):
        normalize_limit(raw)


def test_boundary_five_is_accepted():
    assert normalize_limit(5) == 5


def test_runner_returns_a_classified_result():
    assert run(0) == {"status": "rejected", "error": "ValueError"}

现在你的文件夹里有三个文件:程序 quality_lab.py、刚才的一条小测试 test_first.py、这份更完整的测试 test_quality_lab.py。在终端指定后一份测试文件运行:

Bash
python -m pytest test_quality_lab.py -q

参数化用例把 True、0 和 6 放进同一条行为规则;边界值 5 单独验证可接受。测试不检查内部变量名,而检查调用者能观察到的结果或异常。pytest 会收集失败测试的日志;也可以使用 --log-cli-level=INFO 观察运行中的日志。pytest 日志文档

打开完整测试文件,按下面顺序看:

代码位置作用这里如何计数
from quality_lab import ...从同目录的程序导入待测试函数导入失败时还没开始验证业务
@pytest.mark.parametrize(...)同一测试分别传入多个值True、0、6各运行一次,共3项
with pytest.raises(...)期望调用抛出指定类别的异常没有抛出,或抛出别的类别,都会失败
test_boundary_five_is_accepted验证边界5可以使用1项
test_runner_returns_a_classified_result验证调用边界把失败转成明确结果1项

因此原始文件共运行5项,而不是按函数数量算3项。正常输出应包含 5 passed。-q 让输出简短;想看每个名字,可执行 python -m pytest test_quality_lab.py -v。这里显式指定测试文件,避免学习目录中的其他测试改变计数;[100%]表示执行进度到末尾,不表示全部通过。

先故意把 type(raw) is not int 改成 not isinstance(raw, int),运行测试,True 用例应失败。这个红色阶段证明测试真的覆盖了你关心的边界;恢复实现后再运行,才是绿色。不要为了让测试绿而删掉边界用例。

只在自己的副本修改,并先保存原文件。预测是 True 被当作整数接受,5项里只有布尔值用例失败,退出码为1。运行上面的同一命令,失败报告中应找到 test_invalid_limits_are_rejected[True-TypeError] 和 DID NOT RAISE;这意味着该用例期待的异常没有出现。恢复精确类型检查后再次保存、运行,预期回到 5 passed、退出码0。这一次失败有明确原因,而不是靠“把某行改坏”随机制造报错。

想增加保护范围,就增加新的输入和对应断言。覆盖率统计哪些代码运行过,用例质量则取决于是否检查了重要行为。例如 True、0、6 都触及校验规则,但应该分别检查类型错误和范围错误。

4. 读懂退出码和失败位置

pytest 的退出码把“测试失败”“没有收集到测试”“命令参数错误”等情况区分开。CI 最少要检查命令退出码,不能只搜索输出里的“passed”。如果测试进程本身无法启动,绿色徽章不能被保留成昨天的结果。pytest 退出码参考

排查顺序建议固定为:

阶段你要问的问题证据
环境当前 Python 和 pytest 是哪一个?python --version、python -m pytest --version
收集文件是否被 pytest 找到?收集数量和路径
执行哪个输入违反了哪条约定?失败断言、参数、日志
外部是否依赖网络、时区、文件顺序?命令、环境、输入版本

“找不到模块”通常发生在收集或导入阶段,还没进入业务断言;“断言不相等”才是执行阶段的行为差异。把它们都叫“代码有问题”,会让排查方向错位。

在PowerShell中,运行测试后紧接着执行 $LASTEXITCODE,查看上一条原生程序的退出码。0表示收集到的测试通过,1表示存在失败,5表示没有收集到测试;不能把5当作“没有失败所以成功”。其他代码按官方说明定位。这个变量会被后续命令改变,所以不要过很久才查询。

5. 日志字段怎样服务 Agent 排错

打开程序文件会看到 logging.getLogger(__name__),它取得以当前模块名命名的日志记录器。取得记录器不等于所有级别都会立刻打印;应用还需要决定显示级别和输出位置。不要只运行 python quality_lab.py 然后困惑为什么没输出:这个文件定义函数,没有在入口主动调用它。

先主动调用 run,同时打开 INFO 级别日志:

Bash
python -c "import logging; logging.basicConfig(level=logging.INFO); from quality_lab import run; print(run(3)); print(run(0))"

run(3) 返回 ready,日志包含 validation accepted;run(0) 返回 rejected,日志包含 validation rejected category=ValueError。日志通常写到错误输出通道,和 print 的显示顺序可能不同,按调用与类别核对即可。运行测试查看日志也可以用 python -m pytest test_quality_lab.py -v --log-cli-level=INFO;原测试的合法输入直接调用 normalize_limit,因此它记录类型检查,而接受日志来自刚才的 run(3)。

看到的信息能帮助判断不能替代
validate limit type=bool实际输入类型是布尔不说明这次校验通过
validation rejected category=ValueError当前输入被按值错误拒绝不能当作服务超时或网络失败
validation accepted代码走到接受分支不替代调用方读取返回值

代码中 %s 与后面的参数分别传入日志函数,属于日志库支持的格式化方式;它不会自动脱敏。日志记录类型与类别,不写查询内容或密钥。

Agent 一次任务可能经过模型请求、工具调用、解析和业务写入。建议每个阶段都有稳定的请求 ID、阶段名、工具名、开始/结束时间、结果类别和耗时。参数只记录脱敏摘要或字段名,不直接保存令牌、密码和完整私人输入。Python 日志库支持级别、格式和处理器;pytest 也能捕获日志,方便将失败上下文附在报告中。Python Logging HOWTO

日志级别不是“越详细越好”:DEBUG 适合本地诊断,INFO 记录正常阶段,WARNING 表示可处理的异常,ERROR 表示当前操作失败。不要在每次重试都打印完整 prompt;重复内容会增加成本并扩大泄露面。日志也不是数据库,长期指标、追踪和审计要根据保留期与访问权限单独设计。

6. 用 CI 自动运行测试

本地测试跑通后,可以让 GitHub 在每次提交时自动帮你再检查一遍。

CI(持续集成)可以理解为代码变更后,由另一台执行机器自动重复约定的检查。这个机器称为runner。它不会读到你电脑上所有文件,通常只取得仓库中指定提交的内容。

在学习文件夹里创建 .github/workflows 文件夹,再新建 ci.yml,复制并保存下面的完整配置:

YAML
# 自动检出代码、安装依赖,再运行测试。
name: tests
on: [push, pull_request]
jobs:
  pytest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: '3.13.15'}
      - run: python -m pip install --upgrade pip pytest
      - run: python -m pytest -q
配置项一句话理解对本例的影响
on: [push, pull_request]指定什么时候触发配置存在但没有相应事件,不会凭空执行
runs-on: ubuntu-latest选择执行环境与本机Windows不同,需注意路径与大小写
actions/checkout取得仓库文件未提交的本地文件不会自动出现
actions/setup-python准备指定Python不是使用你电脑的虚拟环境
pip install ...安装测试依赖下载失败会阻止测试开始
python -m pytest -q运行检查退出码决定步骤是否通过

把文件保存到仓库根目录的 .github/workflows/ci.yml,两份 Python 文件也放在根目录。YAML 用缩进表示层级,保留示例空格,确认扩展名为 .yml。如果你只想先学习本地测试,可以在跑通上面的红绿变化后,继续下一篇。

想实际触发 CI,可在 GitHub 新建自己的空练习仓库,不勾选自动添加 README 或许可证,这样远端还没有提交。当前学习目录也需要先成为 Git 仓库;若已经初始化过,就保留现有配置,跳过下面的初始化命令:

Bash
git init --initial-branch=main
git config --local user.name "Learning Example"
git config --local user.email "learning@example.invalid"
git config --local commit.gpgsign false

仓库中的 .gitignore 写入 .venv/ 与 __pycache__/,避免把环境和缓存上传。确认上面的配置已保存到 .github/workflows/ci.yml。将下面的 URL 换成刚建的仓库地址,逐条执行:

Bash
git add quality_lab.py test_quality_lab.py .github/workflows/ci.yml
git commit -m "Add validation tests and CI"
git remote add origin https://github.com/你的账号/你的练习仓库.git
git push -u origin main

已有远端先用 git remote -v 查看,不覆盖它。首次推送按 GitHub 提示完成登录;推送成功后打开仓库的 Actions,点这次提交对应的运行,展开测试步骤查看结果。这个远端 runner 会重新取代码、安装依赖和运行测试,与本机终端是两个执行环境。Git 命令的含义可回看 Git 篇。

读这份配置时,先看“取代码→装解释器→装依赖→跑测试”的顺序。示例使用 action 主版本引用、ubuntu-latest 和未锁定的 pytest;如果你的团队需要每次得到可复现的环境,还要固定这些版本,并按团队规则核验依赖来源。远端运行需要你把配置放进自己的仓库后触发。

on、jobs、runs-on 和 steps 的格式可对照 GitHub 官方工作流语法;checkout 与 setup-python 的用途分别见 actions/checkout 和 actions/setup-python。

CI 失败时,先保存提交号、Python/pytest 版本、完整命令和错误日志,再判断是环境差异还是行为回归。若测试依赖当前目录里的未跟踪文件,CI 通常不会拥有它;若测试读取系统时间、随机数或网络,结果可能不稳定。让测试依赖显式输入,通常比在 CI 里不断重试更可靠。

7. 从测试到 Agent 的边界

pytest 验证固定样例,日志解释本次执行,CI 在指定提交和另一环境重跑;三种证据不能互相替代
图 3:三栏分别回答“样例对不对”“这次发生什么”“另一个环境能否重复”。即便三栏都是绿色,也还需要单独评估模型回答的事实性。

如果 CI 失败,先看右栏的提交和执行环境,再用中栏日志定位阶段,最后回到左栏复现具体断言。反过来,单元测试通过但模型回答错误时,不要只补一条 assert True;应先保存真实输入和可核对答案,再设计评测样例。

工具调用的单元测试可以使用固定响应检查解析和错误分类;集成测试再验证真实协议;端到端测试才检查一条完整任务链。不要让每个单元测试都调用真实模型,否则速度、费用和波动会让失败难以解释。真实模型评测需要独立数据集、版本和评分规则,不能由一次 pytest 通过替代。

同样,测试通过不等于模型回答真实。我们可以测试“收到非法 JSON 时拒绝”,却不能仅凭这条断言证明模型永远输出合法 JSON。测试保护的是程序在已定义输入下的行为;未知输入仍需评测、监控和人工抽查。

8. 失败案例:三种“绿灯误导”

第一种是只测正常路径(happy path):合法的 3 通过,却没有 True、边界和错误分支。第二种是测试 mock 而不是实际解析器:mock 按预设返回成功,真实响应格式改变时测试仍然绿色。第三种是 CI 只运行静态检查,没运行关键测试;格式正确不能推出业务正确。

遇到偶发失败的测试(flaky test),记录随机种子、时间、并发数、外部响应和失败阶段,找到同一测试有时成功、有时失败的原因。若确实是外部服务不稳定,应把外部集成测试与离线回归分开,并公开两者的通过条件。

9. 四道持久化练习

课后练习展开习题收起习题

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.0.0

选择题 · 测试应该保护什么?

一个测试只断言函数没有抛异常,最可能遗漏什么?

对应知识点:L0-testing

选择一个答案
练习 2 / 4 · v1.0.0

简答题 · 日志里该留下什么?

一次 Agent 工具调用失败时,请列出至少四类有助于排查的日志字段,并指出一类不应直接记录的内容。

对应知识点:L0-testing

练习 3 / 4 · v1.0.0

代码题 · 为边界补一条回归测试

给一个要求 limit 为 1..5 且拒绝 bool 的函数,写出 pytest 测试参数,覆盖 True、0、5 和 6。

对应知识点:L0-testing

练习 4 / 4 · v1.0.0

场景题 · 本地通过,CI 失败

本地 pytest 通过,CI 报找不到模块。请按环境、路径、依赖、配置的顺序设计排查,并说明应保存什么证据。

对应知识点:L0-testing

10. 再改一个条件,检查测试是否能发现变化

实践作品:给资料入口再加一个 category 参数,只允许 policy 或 manual。原来的条数规则仍是 1~5。先写下三种预期:

调用预期结果
run(3, "policy"){"status":"ready","limit":3,"category":"policy"}
run(3, "unknown"){"status":"rejected","error":"ValueError"}
run(3, 1){"status":"rejected","error":"TypeError"}

在 test_quality_lab.py 中先加这三条断言,看到新增测试失败,再改 quality_lab.py:类型不对时抛 TypeError,字符串不在允许列表时抛 ValueError,由现有 run 把它们转成拒绝结果。原测试里的 run(0) 还要通过;你可以给 category 默认值 policy,并在说明里写清接口选择。

执行 python -m pytest test_quality_lab.py -q,对照三个新调用的状态。先看到缺少参数接口的失败,再让实现接受合法分类、拒绝错误分类,就完成一次“用约定推动实现”的练习。

参考实现:分类检查接到原来的 run

保留 normalize_limit,将 run 替换为下面的函数:

Python
# 按固定输入观察日志、pytest 与 CI的处理过程和返回结果。
def run(raw, category="policy"):
    try:
        value = normalize_limit(raw)
        if not isinstance(category, str):
            raise TypeError("category must be text")
        if category not in ("policy", "manual"):
            raise ValueError("unknown category")
    except (TypeError, ValueError) as exc:
        logger.warning("validation rejected category=%s", type(exc).__name__)
        return {"status": "rejected", "error": type(exc).__name__}
    logger.info("validation accepted")
    return {"status": "ready", "limit": value, "category": category}

在测试文件末尾新增一条测试函数,分别断言表中的三个调用。原测试若比较了完整 ready 字典,也要加入新字段 "category": "policy";这是接口增加字段带来的明确变化。类型错误和值错误仍使用原来的拒绝字典。

11. 回顾测试、日志和CI

最后回顾三个问题:测试检查了哪条约定?失败日志指出哪一步?CI 在哪个提交上运行了检查?

加入布尔值反例后,一项测试失败、四项通过;修正后五项通过。把自己的提交号、命令和输出写进记录,遇到不同退出码时查 pytest 退出码。下一篇读 SQL、事务与数据基础。

继续读源码,可以先看 pytest 如何收集用例和生成报告,再看 GitHub Actions runner 如何执行工作流。

参考资料

以下官方文档和源码仓库可用于进一步了解日志、测试和 CI 配置。

读者问题原始资料文中对应位置
pytest 怎样收集、参数化、报告退出码?pytest 入门 · 参数化 · 退出码第3—4节
测试日志为何有时不显示?pytest 日志捕获 · Python Logging HOWTO第5节
CI 示例如何读取?GitHub 工作流语法 · actions/runner 源码第 6 节;在自己的仓库运行后查看 Actions 记录
图片查看器
100%

文章配图 · 按 Esc 关闭