pytest cache fixture详解:跨session缓存令牌与失败现场,回归时间砍半
如果你维护过一套有一定规模的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的概念。普通的scopesessionfixture缓存数据只在当前这一次pytest进程内有效跑完进程一退数据就没了。而cachefixture操作的是磁盘上的.pytest_cache目录也就是说它能在两次、三次、十次独立的pytest运行之间保留数据。这两者有本质区别。所以cache fixture适合存放那些跨运行仍然有意义的小型状态比如上次测试的时间戳、上次失败时的环境参数、已经拉取过的授权令牌、某份基础配置的版本标记。不适合放的是大型响应体、二进制数据、复杂对象——因为它是用JSON序列化落盘的塞不进JSON的东西都存不了。2. set/get两个方法的背后一个JSON文件就是一个缓存桶2.1 API极其简单但有三个隐藏细节先看最基础的用法。cachefixture暴露两个方法get(key, defaultNone)和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(scopesession, autouseTrue) 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把令牌存下来让同一时间段内所有用例共享同一份。注意这里不能用普通的scopesessionfixture因为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(scopesession) 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()本身在scopesession的fixture里同一次运行内只调用一次加上cache落盘下一次运行只要没过期就直接从磁盘读回来了。3.3 加一个--refresh参数强制刷新缓存缓存虽好但有人会说改了鉴权接口或环境切换后缓存里的旧令牌岂不就废了所以顺手加一个强制刷新入口。用pytest_addoption注册一个--refresh命令行选项def pytest_addoption(parser): parser.addoption(--refresh, actionstore_true, defaultFalse, help忽略缓存并强制刷新远程数据) pytest.fixture(scopesession) 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(hookwrapperTrue) 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_failurefixturepytest.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后缀的形式例如fper_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(encodingutf-8)) except json.JSONDecodeError: content path.read_text(encodingutf-8)[:200] print(f{rel}: {content})运行一下所有key和value一览无余。配合上文说的key映射文件路径规则你能很直观地看到缓存的结构也能快速发现某个key目录怎么膨胀到几MB了哪个缓存文件已经过时了。排查缓存相关问题时这个小脚本比任何调试工具都快。我在实际使用中还摸索出一个习惯每次版本发布前抽一分钟看一遍cache的key列表把不再使用的旧前缀清掉给.pytest_cache减负。因为缓存这个东西不清理就会像仓库角落的杂物一样越堆越多。保持够用就好的心态它才能长期稳定地加速你的测试工程。