1. 从能跑就行到敢改就崩测试代码的信任危机做前端的时候我写过不少测试但坦白说大部分都是能跑就行的水平。组件渲染出来没报错快照对得上就算过了。那时候测试对我来说更像是一种心理安慰——有总比没有强。直到我开始系统性地学习 AI 工程化真正接触到需要长期维护、多人协作的 Python 项目才发现之前那种能跑就行的测试思路根本撑不住。Day 19 这一天的学习核心就是解决一个很具体的问题当你的代码开始依赖外部服务、依赖随机性、依赖耗时操作时测试怎么写才能既快又稳还真正有意义这其实是三个递进的问题——mock 解决的是隔离依赖集成测试解决的是验证真实协作性能测试解决的是确认改动没有让系统变慢。三者缺一不可但用错了场景测试反而会成为负担。我见过太多项目测试文件写了上千行跑一次要十几分钟结果上线还是出问题。原因往往不是测试写得少而是测试写错了——该 mock 的地方用了真实调用该集成验证的地方只做了单元隔离该关注性能的地方完全没有基准。这一篇就把这三个问题拆开揉碎结合我实际踩过的坑把 pytest 进阶的这套组合拳讲清楚。提示本文假设你已经会用 pytest 写基本的单元测试知道 fixture、assert、参数化这些基础概念。如果这些还不熟建议先把 pytest 官方入门文档过一遍再来看这篇。2. Mock 的本质不是造假而是控制变量的边界2.1 为什么你的 mock 总是 mock 不到点子上很多人对 mock 的理解停留在把外部调用替换掉这个层面于是拿到一个函数就开始 patchpatch 完发现测试是过了但线上还是炸。问题出在你 mock 掉的那个东西可能根本不是问题的根源。我举个实际遇到的例子。当时我在做一个文本分类的小服务里面有个函数负责调用外部的情感分析接口。测试的时候我把这个接口 mock 掉了返回固定结果测试全绿。但上线后发现当输入文本特别长的时候服务会超时。为什么测试没发现因为我 mock 的是接口返回但没有 mock接口耗时——真实场景下这个接口对长文本的处理时间是指数级增长的而我的 mock 永远瞬间返回。这就是典型的 mock 边界选错了。正确的做法不是 mock 返回值而是 mock 整个调用行为包括它的时间特性。在 pytest 里你可以用side_effect来模拟耗时import time from unittest.mock import patch def fake_analyze(text): # 模拟长文本处理耗时 if len(text) 1000: time.sleep(2) return {sentiment: neutral, score: 0.5} patch(my_service.analyzer.analyze, side_effectfake_analyze) def test_long_text_timeout(mock_analyze): with pytest.raises(TimeoutError): my_service.process(x * 2000)这个测试才能真正暴露问题。所以 mock 的第一原则是你要 mock 的是导致问题的那个行为而不是看起来像外部依赖的那个函数。2.2 patch 的位置比 patch 的对象更重要这是新手最容易翻车的地方。Python 的 import 机制决定了你 patch 的路径必须是被测试代码实际引用的那个位置而不是定义的位置。假设你有这样的结构# utils/helper.py def fetch_data(): return real data # service/core.py from utils.helper import fetch_data def process(): return fetch_data()你想 mockfetch_data下面两种写法哪个对# 写法 A patch(utils.helper.fetch_data) def test_a(mock_fetch): ... # 写法 B patch(service.core.fetch_data) def test_b(mock_fetch): ...答案是B。因为service/core.py在 import 的时候已经把fetch_data这个名字绑定到了自己的命名空间里。你 patchutils.helper.fetch_data改的是原始定义但service.core里用的还是它自己那份引用。这个坑我踩过不止一次每次都是测试莫名其妙不生效查半天才发现 patch 错了地方。注意如果你在service/core.py里写的是import utils.helper然后调用utils.helper.fetch_data()那 patch 路径就应该是utils.helper.fetch_data。关键看被测试代码怎么引用而不是怎么定义。2.3 用 fixture 管理 mock 的生命周期直接在每个测试函数上堆patch装饰器写多了会非常乱。更好的方式是用 fixture 统一管理import pytest from unittest.mock import patch, MagicMock pytest.fixture def mock_analyzer(): with patch(my_service.analyzer.analyze) as m: m.return_value {sentiment: positive, score: 0.9} yield m def test_positive_flow(mock_analyzer): result my_service.process(great product) assert result[sentiment] positive mock_analyzer.assert_called_once()这样做的好处是mock 的创建和销毁被 fixture 自动管理测试函数本身只关注断言逻辑。而且你可以在 fixture 里根据不同的测试需求做参数化比如pytest.fixture(params[positive, negative, neutral]) def mock_analyzer_param(request): with patch(my_service.analyzer.analyze) as m: m.return_value {sentiment: request.param, score: 0.5} yield m一个 fixture 就覆盖了三种情感场景测试代码量直接减半。这是我个人非常推荐的一种组织方式。2.4 什么时候不该用 mockMock 不是万能的。有些场景下强行 mock 反而会让测试失去意义纯计算逻辑如果一个函数只做数学运算或字符串处理没有任何外部依赖mock 它就是在自欺欺人。数据结构转换输入输出都是明确的数据结构直接构造真实数据测试更可靠。你正在测试的模块本身不要 mock 你要验证的那个东西否则你测的是 mock 的行为不是代码的行为。我给自己定了一条规矩只有当外部依赖满足以下任一条件时才 mock——网络调用、文件系统读写、数据库操作、随机性、当前时间、耗时超过 100ms 的操作。其他情况优先用真实对象。3. 集成测试验证拼起来能跑比验证每个零件都好更难3.1 单元测试全绿但系统崩溃的经典场景单元测试的假设是每个零件单独看都是好的但现实是零件之间的接口往往才是问题所在。我遇到过一个特别典型的案例两个模块各自测试都通过但拼在一起就报错。原因是模块 A 返回的字典用的是user_id作为键模块 B 期望的是userId。单元测试里各自 mock 了对方所以谁也没发现这个不一致。集成测试要解决的就是这类问题。它的核心思路是用真实的组件、真实的连接、真实的数据流验证端到端的协作是否正确。在 pytest 里集成测试通常通过 fixture 来搭建真实的测试环境。3.2 用 fixture 搭建可复用的集成测试环境假设你在做一个数据处理管道流程是读取文件 → 清洗 → 分析 → 写入结果。集成测试需要真实地跑一遍这个流程。你可以这样组织import pytest import tempfile import os import json pytest.fixture def sample_pipeline_env(): # 创建临时目录 tmpdir tempfile.mkdtemp() input_file os.path.join(tmpdir, input.json) output_file os.path.join(tmpdir, output.json) # 写入测试数据 with open(input_file, w) as f: json.dump({texts: [hello world, foo bar]}, f) yield { input: input_file, output: output_file, tmpdir: tmpdir } # 清理 import shutil shutil.rmtree(tmpdir) def test_full_pipeline(sample_pipeline_env): from my_pipeline import run_pipeline run_pipeline( sample_pipeline_env[input], sample_pipeline_env[output] ) with open(sample_pipeline_env[output]) as f: result json.load(f) assert len(result[processed]) 2 assert all(cleaned in item for item in result[processed])这个测试的价值在于它验证了文件读写、数据格式转换、模块间调用这些接缝处的正确性。这些恰恰是单元测试覆盖不到的地方。3.3 集成测试的粒度控制别让它变成全量回归集成测试最大的敌人是太慢。如果你每个集成测试都跑完整的端到端流程测试套件很快就会变得无法忍受。我的经验是集成测试要分层不要一刀切。我通常把集成测试分成三个层次层次范围耗时运行频率模块集成2-3 个相邻模块秒级每次提交服务集成完整业务链路十秒级每日构建系统集成含外部依赖的完整流程分钟级发布前在 pytest 里可以用 marker 来区分pytest.mark.module_integration def test_data_cleaning_to_analysis(): ... pytest.mark.service_integration def test_full_business_flow(): ... pytest.mark.system_integration def test_with_real_external_service(): ...然后通过命令行选择运行哪一层# 只跑模块集成 pytest -m module_integration # 跑模块和服务集成 pytest -m module_integration or service_integration # 排除系统集成 pytest -m not system_integration这样既保证了日常开发的反馈速度又能在关键节点做全面验证。3.4 集成测试中的数据管理造数据比写断言更费劲写集成测试时最耗时的往往不是写测试逻辑而是准备测试数据。我的做法是建立一个数据工厂fixture把常用的测试数据构造逻辑封装起来pytest.fixture def make_user(): def _make_user(user_idNone, nametest_user, roleviewer): return { id: user_id or uuid.uuid4().hex, name: name, role: role, created_at: datetime.now().isoformat() } return _make_user def test_role_based_access(make_user): admin make_user(roleadmin) viewer make_user(roleviewer) assert check_access(admin, delete) is True assert check_access(viewer, delete) is False这种工厂 fixture的好处是测试数据清晰可控需要特殊字段时可以通过参数覆盖不需要在每个测试里重复写一大坨字典。提示集成测试的数据一定要在测试结束后清理干净。我习惯用tmpdir或tmp_pathfixture 来管理临时文件用数据库事务回滚来管理数据库状态。否则跑几次测试之后环境就脏得没法用了。4. 性能测试不是跑得快而是知道什么时候会变慢4.1 性能测试的目标是建立基准不是追求数字好看很多人对性能测试有个误解觉得是要测出最快能跑多快。实际上性能测试的核心价值在于建立基准线并在后续改动中检测回归。你不需要知道某个函数理论上最快能到多少你需要知道的是昨天它跑 50ms今天跑 200ms这就有问题了。pytest 本身没有内置的性能测试功能但可以通过插件或手动计时来实现。最简单的方式是用time.perf_counter()import time def test_processing_speed(): data list(range(10000)) start time.perf_counter() result process_data(data) elapsed time.perf_counter() - start # 基准处理 10000 条数据应在 1 秒内完成 assert elapsed 1.0, f处理耗时 {elapsed:.2f}s超过基准这种方式简单直接但有个问题它受机器负载影响很大在 CI 环境里经常误报。更稳妥的做法是用pytest-benchmark插件它会多次运行取统计值并自动处理预热和异常值。4.2 用 pytest-benchmark 做可靠的性能基准安装很简单pip install pytest-benchmark使用方式def test_sort_performance(benchmark): data list(range(10000, 0, -1)) result benchmark(sorted, data) assert result list(range(1, 10001))benchmarkfixture 会自动运行多次输出类似这样的报告Name Min Max Mean StdDev Median OPS test_sort_performance 0.8ms 1.2ms 0.9ms 0.1ms 0.9ms 1111关键是看Mean和StdDev。如果 StdDev 很大说明测试环境不稳定结果不可信。我通常会在 CI 里设置一个阈值比如Mean 超过基准的 150% 就失败def test_performance_regression(benchmark): result benchmark(process_data, list(range(10000))) # benchmark.stats.stats.mean 是平均耗时 assert benchmark.stats.stats.mean 0.05 # 50ms 基准4.3 性能测试的常见陷阱你测的可能不是你想测的性能测试有几个特别容易踩的坑第一个坑没有预热。Python 的 JIT 和缓存机制意味着第一次运行往往比后续慢。如果你只跑一次就下结论数据会严重偏高。pytest-benchmark默认会做预热手动计时的话记得先跑几轮再开始计时。第二个坑测试数据太小。处理 10 条数据耗时 0.1ms处理 10000 条耗时 50ms这不代表算法是线性的。你需要用不同规模的数据测试才能看出增长趋势。我通常会准备小、中、大三组数据pytest.mark.parametrize(size, [100, 1000, 10000]) def test_scaling(benchmark, size): data list(range(size)) benchmark(process_data, data)第三个坑忽略了内存。有时候 CPU 时间没变但内存占用暴涨这同样是性能问题。可以用tracemalloc来监控import tracemalloc def test_memory_usage(): tracemalloc.start() process_data(list(range(100000))) current, peak tracemalloc.get_traced_memory() tracemalloc.stop() # 峰值内存不应超过 50MB assert peak 50 * 1024 * 1024, f峰值内存 {peak / 1024 / 1024:.1f}MB4.4 把性能测试纳入日常流程性能测试如果只在发布前跑一次那基本等于没跑。我的做法是在 CI 里对核心路径做轻量级性能检查在发布前做完整基准对比。轻量级检查就是上面说的assert elapsed threshold跑得快能挡住明显的性能退化。完整基准对比则是用pytest-benchmark生成报告和上一次的基准做 diff。pytest-benchmark支持保存和对比历史数据# 保存基准 pytest --benchmark-savebaseline # 对比当前和基准 pytest --benchmark-comparebaseline这样你就能清楚地看到每次改动对性能的影响而不是等到用户投诉才发现问题。5. 三层测试的协作方式别让它们互相打架5.1 测试金字塔在 AI 项目里的变形传统的测试金字塔是单元测试多、集成测试少、端到端测试极少。但在 AI 项目里这个比例需要调整。因为 AI 项目的核心逻辑往往涉及数据处理、模型推理、结果后处理这些环节的单元边界很模糊强行拆成小单元反而会写出大量无意义的 mock。我的经验比例是单元测试 50%覆盖纯函数、工具类、数据转换逻辑集成测试 35%覆盖数据处理管道、模块间协作性能测试 15%覆盖核心算法、高频调用路径这个比例不是固定的但核心原则是越靠近业务逻辑的地方越应该用集成测试而不是单元测试。因为业务逻辑的价值在于组合而不是单个零件。5.2 用 conftest.py 统一管理测试基础设施当测试类型变多之后fixture 的管理就成了问题。我的做法是把通用的 fixture 放在conftest.py里按层次组织# conftest.py import pytest # 第一层基础工具 pytest.fixture def tmp_workspace(tmp_path): return tmp_path # 第二层mock 工具 pytest.fixture def mock_external_api(): with patch(my_service.external.call) as m: m.return_value {status: ok} yield m # 第三层集成环境 pytest.fixture def full_pipeline_env(tmp_workspace, mock_external_api): # 组合前两层搭建完整环境 ...这样组织的好处是每个 fixture 只依赖它需要的上一层不会出现为了用一个 fixture 要拖出一堆无关依赖的情况。5.3 测试执行策略快慢分离按需运行最后说一下执行策略。我的项目里通常配置三套 pytest 命令# 快速反馈只跑单元测试和轻量集成 pytest -m not slow and not system_integration -x # 完整验证跑所有测试 pytest # 性能基准只跑性能测试并生成报告 pytest -m performance --benchmark-only日常开发用第一套提交前用第二套发布前用第三套。这样既保证了开发效率又不会漏掉关键验证。注意-x参数表示遇到第一个失败就停止这在快速反馈阶段很有用可以让你第一时间看到问题。但在完整验证阶段要去掉否则会漏掉后续的失败信息。6. 那些只有踩过才知道的测试心得6.1 mock 的 assert 要具体不要只写 assert_calledmock.assert_called()只能告诉你被调用了但调用的参数对不对、调用了几次、调用顺序对不对这些才是真正重要的。我习惯用更具体的断言# 不够具体 mock_func.assert_called() # 更具体 mock_func.assert_called_once_with(expected_arg, keyexpected_value) # 检查调用顺序 mock_func.assert_has_calls([ call(first), call(second) ])6.2 集成测试的失败信息要足够定位问题集成测试失败时如果只报一个AssertionError你根本不知道是哪个环节出了问题。我的做法是在关键步骤加日志或中间断言def test_pipeline(sample_env): raw read_input(sample_env[input]) assert raw is not None, 读取输入失败 cleaned clean_data(raw) assert len(cleaned) 0, 清洗后数据为空 result analyze(cleaned) assert score in result, 分析结果缺少 score 字段这样失败时你能立刻知道是读取、清洗还是分析出了问题排查时间从半小时缩短到几分钟。6.3 性能测试的阈值要留余量设置性能阈值时不要卡得太死。比如你的函数平均耗时 50ms阈值设 55ms那在 CI 环境里几乎每次都会失败。我的经验是阈值设为基准的 1.5 到 2 倍这样既能检测到明显的性能退化又不会因为环境波动而误报。6.4 测试代码也是代码需要重构最后一条可能最重要测试代码同样需要保持整洁。当你发现多个测试里有重复的 setup 逻辑时就该提取 fixture 了当你发现断言逻辑越来越复杂时就该封装辅助函数了。我见过太多项目测试文件比业务代码还难维护最后没人愿意改测试整个测试套件就慢慢腐烂了。把测试当成产品代码来对待它才会真正成为你的安全网而不是负担。Day 19 这一天的内容本质上就是在训练这种工程化思维——不是能跑就行而是跑得对、跑得快、跑得久。