如果你维护过一套有一定规模的pytest回归测试包,大概率会遇到这个问题:用例越来越多,很多用例重复请求同一个接口、重复申请同一个授权令牌、重复加载同一份基础配置,每次全量跑下来,一大半时间都烧在"请求外部资源"上。我之前维护某个接口测试项目的时候,全量跑一次要二十多分钟,后来发现仅仅把授权令牌和一份基础配置缓存下来,时间直接砍掉接近一半。而实现这个效果用的不是第三方库,就是pytest自带的cache fixture。
这篇文章就把cache fixture的来龙去脉、set/get的存储原理、两个实战案例,以及我在生产环境里踩过的坑完整讲一遍。无论你是刚开始写pytest的小白,还是已经在维护大型测试工程的老手,这篇都值得花几分钟看完。
1. 缓存fixture到底缓存的是什么:从--lf的lastfailed说起
1.1 你第一次接触缓存,多半是那行神秘的--lf
很多人第一次意识到pytest有缓存功能,并不是因为读了文档,而是某天测试挂了之后,有人丢过来一句"用pytest --lf只跑失败的"。跑完你发现,pytest确实记住了"上一次哪些用例失败",并且精准地只运行了这几个。这时候你大概会想:它把失败记录存在哪了?
答案就在项目根目录下的.pytest_cache目录里。你随手tree .pytest_cache看一下,会发现一个类似这样的结构:
.pytest_cache/ ├── CACHEDIR.TAG ├── README.md └── v/ └── cache/ ├── lastfailed └── nodeidslastfailed文件里存的是上次失败用例的nodeid列表,nodeids文件里存的是所有用例的nodeid。--lf和--ff这两个命令之所以好用,本质就是先读lastfailed文件,再决定这一次要筛出哪些用例来跑。换句话说,你早就用过pytest的缓存了,只是没意识到它的实现载体是一个叫cache的内置fixture。
1.2 cache fixture的定位:一套跨session的迷你KV存储
搞清楚--lf的原理后,下一个问题自然就是:我能不能自己往这个缓存里塞点东西?答案是能,而且pytest给了一个非常顺手的入口——cachefixture。
这里要强调一个"跨session"的概念。普通的scope="session"fixture,缓存数据只在"当前这一次pytest进程"内有效,跑完进程一退,数据就没了。而cachefixture操作的是磁盘上的.pytest_cache目录,也就是说,它能在两次、三次、十次独立的pytest运行之间保留数据。这两者有本质区别。
所以,cache fixture适合存放那些"跨运行仍然有意义的小型状态",比如:上次测试的时间戳、上次失败时的环境参数、已经拉取过的授权令牌、某份基础配置的版本标记。不适合放的是大型响应体、二进制数据、复杂对象——因为它是用JSON序列化落盘的,塞不进JSON的东西都存不了。
2. set/get两个方法的背后:一个JSON文件就是一个缓存桶
2.1 API极其简单,但有三个隐藏细节
先看最基础的用法。cachefixture暴露两个方法:get(key, default=None)和set(key, value)。示例代码长这样:
def test_write_and_read(cache): # 写入 cache.set("demo/counter", 42) # 读取,拿不到时返回默认值 value = cache.get("demo/counter", 0) assert value == 42表面上看就是一个字典式的读写,但有三个隐藏细节很容易翻车:
第一,set内部会做json.dumps(value),所以只能存JSON能序列化的基本类型。字符串、数字、布尔、字典、列表都可以;元组存进去再读出来会变成列表;datetime对象直接报错。你如果确实需要存时间,存time.time()或datetime.isoformat()的字符串更稳妥。
第二,get和set不会报"key不存在"的错。get取不到时返回第二个参数指定的默认值,默认是None。所以判断逻辑要围绕None展开,不要用"抛异常"来感知缓存缺失。
第三,key不是随便一个字符串,它的命名规则直接对应到文件路径。这是我下面要单独讲的一点。
2.2 key里的斜杠,决定缓存文件目录结构
cache.set("auth/token", "...")不是把数据塞进一个叫auth/token的统一字典里,而是拆成路径层级去写文件。写入这条数据后,目录会多出一个文件:
.pytest_cache/ └── v/ └── cache/ └── auth/ └── tokenauth/token这个key中的斜杠,被当作文件系统的路径分隔符。同理,如果你用cache.set("case_status/alpha", {...}),就会生成v/cache/case_status/alpha文件。
理解这层映射关系很有用,因为你可以直接去.pytest_cache/v/cache/下面翻文件,看到底缓存了什么。我在本地复查缓存内容时,不会傻乎乎去写一套内部API,直接打开目录看就行。文件的内容就是一段JSON文本,非常直观。
2.3 在conftest.py里写入第一个真正有用的缓存
只看API会觉得很抽象,不如直接写一个能感知到"跨session"的小例子。在conftest.py里加这么一段:
import time import pytest @pytest.fixture(scope="session", autouse=True) def record_last_run_time(cache): last = cache.get("meta/last_run_time", None) now = time.time() if last is None: print(f"[首次运行] 本次开始于 {now:.2f}") else: print(f"[上次运行] {last:.2f},本次开始于 {now:.2f}") yield cache.set("meta/last_run_time", now)第一次跑测试,输出"首次运行";第二次再跑,就能读到上一次的时间戳。这就证明cache确实是跨pytest session持久化的。这个模式可以延伸出很多用法:比如记录上次全量测试的耗时,用来对比性能波动;或者记录上次同步数据的版本号,用来判断这次需不需要重新拉数据。
3. 实战案例一:授权令牌与接口数据跨session复用,回归时间砍半
3.1 场景:每个用例都去申请一次令牌,纯属浪费
我接手某接口测试项目时,看了一圈fixture差点没绷住。每个需要鉴权的用例,都依赖一个access_tokenfixture,而这个fixture压根没有缓存概念,跑一个用例就向鉴权服务申请一次令牌。一次申请要调两层接口、做一次加密签名,耗时将近两秒。整个测试包里三千多个用例,光重复申请令牌就平白多出不少时间。
正确的姿势是:用cache把令牌存下来,让同一时间段内所有用例共享同一份。注意,这里不能用普通的scope="session"fixture,因为session fixture只在单次pytest进程内复用;一旦测试分片、多进程执行,或者你分几次跑同一批用例,它就帮不上忙了。cache存在磁盘上,天然跨进程、跨运行。
3.2 实现:令牌与过期时间一起存,快过期就自动重置
令牌通常有有效期,所以不能只存一个裸token字符串。我建议存成字典,把"令牌"和"过期时间戳"一起放进去。读取的时候判断当前时间是否已超过过期时间,超过了就直接重新申请。
import time import pytest def request_token(): """模拟向鉴权服务申请token,耗时较长""" time.sleep(1.5) return { "value": "fake-token-abc123", "expire_at": time.time() + 3600 } @pytest.fixture(scope="session") def access_token(cache): cached = cache.get("auth/access_token", None) if cached is not None: value = cached.get("value") expire_at = cached.get("expire_at", 0) if value is not None and expire_at > time.time(): return value token = request_token() cache.set("auth/access_token", token) return token["value"]这个fixture的逻辑很简单:先看缓存里有没有可用令牌,有就用;没有或已过期就重新申请、重新写入。由于request_token()本身在scope="session"的fixture里,同一次运行内只调用一次;加上cache落盘,下一次运行只要没过期,就直接从磁盘读回来了。
3.3 加一个--refresh参数,强制刷新缓存
缓存虽好,但有人会说"改了鉴权接口或环境切换后,缓存里的旧令牌岂不就废了?"所以顺手加一个强制刷新入口。用pytest_addoption注册一个--refresh命令行选项:
def pytest_addoption(parser): parser.addoption("--refresh", action="store_true", default=False, help="忽略缓存并强制刷新远程数据") @pytest.fixture(scope="session") def access_token(cache, request): if not request.config.getoption("--refresh"): cached = cache.get("auth/access_token", None) if cached is not None: value = cached.get("value") expire_at = cached.get("expire_at", 0) if value is not None and expire_at > time.time(): return value token = request_token() cache.set("auth/access_token", token) return token["value"]这样的话,日常回归直接跑,令牌走缓存;发现数据和远程不一致了,pytest --refresh强制拉一次新的。同样的模式可以复制到"基础配置数据""字典表数据""环境地址列表"上。我在项目里给三四个核心fixture统一加了--refresh开关,用起来非常顺手。
3.4 缓存接口响应前,先问自己三个问题
把接口响应整个缓存下来,是很多人看到cachefixture后的第一反应。这里建议先问自己三个问题:
第一,这个响应是不是一段时间内稳定不变?如果不是,比如包含实时库存、实时余额,那缓存的意义就不大,还容易测出假阳性。第二,响应体有多大?几百KB甚至几MB的数据,落盘在.pytest_cache下,每次读写都有IO成本,反而拖慢测试。第三,缓存内容会不会被测试用例修改?如果用例本身会写数据,那缓存的数据只适合在"独立于用例写入逻辑的只读场景"下使用。
我的经验是,真正值得用cache去存的远程资源,大多数是"鉴权信息、配置快照、版本标记"这类小而稳的数据。大响应体更适合用"本地临时文件+session级fixture"处理,或者干脆一次全量请求后直接放在session级内存里,比写进.pytest_cache更合理。
4. 实战案例二:失败用例的现场记录与定向重跑,告别"重跑只能看脸"
4.1 痛点:--lf虽然能筛出失败用例,但现场信息丢了
--lf只跑上次失败的用例,这个功能本身很强大。但它做不到一件事:把"当时为什么失败"的场景还原出来。比如你的接口测试挂了,你想知道挂的时候用的哪个token、哪个请求参数、哪个前置数据版本。这些信息通常都印在日志里,可一旦你重新跑,日志就刷新了,你想回头看一眼现场只能翻历史日志文件,费劲。
这时候可以用cache手动记录"失败现场"。pytest每次跑到setup/call/teardown阶段时,都会触发一个叫pytest_runtest_makereport的钩子。在这个钩子里,我们能拿到当前用例节点item、当前阶段call,以及失败异常信息。判断到用例失败时,就把关键上下文写进缓存。
这里要提一个关键点:钩子函数不是fixture,不能直接注入cache参数。但它有一个等价物——item.config.cache。cachefixture本质就是config.cache的封装,所以钩子里用item.config.cache.set(...),和fixture里用cache.set(...)访问的是同一个存储。
4.2 实现:用pytest_runtest_makereport记录失败上下文
在conftest.py里加上这样一个钩子:
import time import pytest @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == "call" and report.failed: extra = { "failed_at": time.time(), "phase": call.when, "error_msg": str(call.excinfo.value) if call.excinfo else "", "node_id": item.nodeid } item.config.cache.set("failures/" + item.nodeid, extra)注意我用的是failures/前缀加item.nodeid作为key。因为key里的斜杠会映射成目录结构,所以每个失败用例都会被存为.pytest_cache/v/cache/failures/<nodeid>这个路径下的一个文件,互不干扰。
这样跑完一次测试,只要哪个用例挂了,它的失败现场就被记录到磁盘。下次不管是执行--lf还是全量跑,我们都能把上次现场信息读出来。
4.3 实现:用fixture把现场信息吐给用例
记录只是第一步,第二步是让用例或夹具能方便地读到"上次失败信息"。写一个last_failurefixture:
@pytest.fixture def last_failure(cache, request): key = "failures/" + request.node.nodeid return cache.get(key, None)然后在测试函数里就能这样用:
def test_place_order(last_failure): if last_failure: print(f"上次失败信息:{last_failure['error_msg']}") # 正常测试逻辑...这在排错时特别有用。比如我遇到过一个偶发问题,用例在特定请求参数下才会失败,日志又不完整。改造后,失败现场会自动记录参数快照,下次重跑时直接打印出来对比,问题很快就定位了。
4.4 两个需要注意的细节
第一个细节:failures/目录下的缓存文件不会被自动清除。也就是说,如果一个用例上次失败了、这次修复通过了,failures/<nodeid>这个文件还会躺在磁盘里。你下次读到的是"历史失败记录",而不是"最近一次运行的结果"。解决办法有两个:要么在用例通过的时候主动cache.set(key, None),要么把失败时间和本次运行绑定,读的时候判断时间戳是否在最近一次运行窗口内。我一般会在钩子里同时记录"最近一次运行是否失败",这样读的时候可以判断。
第二个细节:item.nodeid里包含::、/等字符,在Windows上可能遇到路径过深、名称过长的问题。如果你的测试文件名特别长、目录层级特别深,缓存文件路径可能会触及文件系统上限。我建议给key加一层哈希或缩写,比如直接用hashlib.md5(item.nodeid.encode()).hexdigest()作后缀。
5. 缓存目录治理:清空时机、三个易翻车的坑与调试技巧
5.1 什么时候必须主动清缓存
cachefixture给你持久化能力的同时,也把"清理"这个问题丢给了你。最笨也最有效的清空方式就是命令行跑一次:
pytest --cache-clear--cache-clear会在本次测试开始时清空.pytest_cache里的缓存内容,注意它不是在结束时清空,是开始时。这意味着你用一个"干净大脑"跑完整轮测试。
我建议在以下三种场景强制清缓存:
第一,CI流水线里跑全量回归时。流水线每次构建环境基本都是全新的,如果还带着上一个构建的缓存,里面可能是旧环境的token、旧数据版本标记,容易串味。第二,被测系统做了版本升级、接口数据结构大改之后。旧的缓存内容是"旧世界的真相",留着只会误导。第三,排查"测试结果不稳定"问题时。先清一次缓存跑全量,排除缓存侧数据影响,再继续定位。
5.2 三个容易翻车的坑:明文存储、并发覆盖、JSON序列化
先看最严重的一个坑:缓存文件是明文JSON。前面已经说过,.pytest_cache/v/cache/下的文件就是原原本本的JSON文本,拿文本编辑器打开就能看。如果你图省事把某个高权限token、数据库口令塞进cache.set,那这些敏感信息就会静静地躺在项目目录里,任何能访问目录的人都能看到。我在重构测试工程时,专门排查过历史项目里有没有人把密钥用cache.set存进去,结果真让我翻出一个。所以安全红线就一条:cache只存非敏感数据。token要存就存那种有效期极短、权限范围最小的临时凭证,并且配合--cache-clear频繁清理。
第二个坑是并发覆盖。测试工程一旦上了pytest-xdist分布式执行,多个worker进程同时读写同一个key,就会出现"后写覆盖先写"的情况。比如每个worker在结束时都往meta/run_status里写自己的状态,最终这个key存的是最后一个结束的worker的状态,而不是整个分布式任务的状态。我的解决方案是:让缓存内容只由主进程或单一fixture写入;如果实在需要各worker独立缓存,把key设计成带worker后缀的形式,例如f"per_worker/{worker_id}/xxx"。
第三个坑是JSON序列化边界。前面提过,datetime、set、自定义对象都塞不进JSON。最坑的是tuple,写入后读出来变list,如果你的后续逻辑里有isinstance(data, tuple)的判断,直接踩雷。我在代码review时定的规矩是:缓存值只用dict、list、str、int、float、bool、None这几种基础类型;任何复杂对象都先手工转成基础类型再存。
5.3 快速查看缓存内容的小工具
调试缓存问题的时候,最痛苦的是"不知道当前缓存里到底有什么"。虽然可以手动进.pytest_cache/v/cache/翻文件,但文件一多就眼花。我习惯在项目里放一个独立脚本,专门打印缓存内容:
import json from pathlib import Path cache_root = Path(".pytest_cache/v/cache") for path in sorted(cache_root.rglob("*")): if path.is_file(): rel = path.relative_to(cache_root).as_posix() try: content = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: content = path.read_text(encoding="utf-8")[:200] print(f"{rel}: {content}")运行一下,所有key和value一览无余。配合上文说的"key映射文件路径"规则,你能很直观地看到缓存的结构,也能快速发现"某个key目录怎么膨胀到几MB了""哪个缓存文件已经过时了"。排查缓存相关问题时,这个小脚本比任何调试工具都快。
我在实际使用中还摸索出一个习惯:每次版本发布前,抽一分钟看一遍cache的key列表,把不再使用的旧前缀清掉,给.pytest_cache减负。因为缓存这个东西,不清理就会像仓库角落的杂物一样越堆越多。保持"够用就好"的心态,它才能长期稳定地加速你的测试工程。