用 pytest monkeypatch 模拟 time.time 测试缓存 TTL
问题场景
给热点数据加一层带 TTL 的进程内缓存,是后端开发里很常见的需求。但 TTL 逻辑一旦引入真实时间,测试就变得别扭:如果用 time.sleep(61) 等缓存过期,单个用例就要跑一分钟,CI 上既慢又不稳定;如果干脆不测过期分支,缓存就可能一直返回陈旧数据。
pytest 的 monkeypatch 提供了一个干净的解法:把被测代码读时间的入口替换成一个可手动控制的假时钟,让“时间流逝”变成一次普通的变量自增。
被测代码
新建 ttl_cache.py,用 time.time() 作为唯一的时间来源:
# ttl_cache.py
import time
class TTLCache:
def __init__(self, ttl: float):
self.ttl = ttl
self._store = {}
def set(self, key, value):
# 占位值,实际以调用方传入为准
expire_at = time.time() + self.ttl
self._store[key] = (expire_at, value)
def get(self, key):
item = self._store.get(key)
if item is None:
return None
expire_at, value = item
if time.time() >= expire_at:
del self._store[key]
return None
return value
def __len__(self):
return len(self._store)
关键点:模块里写的是 import time 后再调用 time.time(),而不是 from time import time。前者让补丁可以打在 time 模块上,一次生效;后者会把函数对象绑定到本模块命名空间,必须改成给 ttl_cache.time 打补丁。
测试代码
新建 test_ttl_cache.py,用一个 fake_clock fixture 承载虚拟时间:
# test_ttl_cache.py
import time
import pytest
from ttl_cache import TTLCache
@pytest.fixture
def fake_clock(monkeypatch):
# 占位起始时间,任意固定值即可
state = {"now": 1_700_000_000.0}
def fake_time():
return state["now"]
monkeypatch.setattr(time, "time", fake_time)
return state
def test_hit_within_ttl(fake_clock):
cache = TTLCache(ttl=60)
cache.set("user:1", {"name": "a"})
fake_clock["now"] += 59.9 # 还差一点才到期
assert cache.get("user:1") == {"name": "a"}
assert len(cache) == 1
def test_miss_after_ttl(fake_clock):
cache = TTLCache(ttl=60)
cache.set("user:1", {"name": "a"})
fake_clock["now"] += 60 # 恰好到达过期时刻
assert cache.get("user:1") is None
assert len(cache) == 0 # 顺带验证惰性删除
def test_set_again_refreshes_expire_at(fake_clock):
cache = TTLCache(ttl=60)
cache.set("k", "v1")
fake_clock["now"] += 50
cache.set("k", "v2") # 重写应刷新过期时间
fake_clock["now"] += 50 # 距第一次写入已 100 秒,距第二次只有 50 秒
assert cache.get("k") == "v2"
def test_missing_key_returns_none(fake_clock):
cache = TTLCache(ttl=60)
assert cache.get("not-exist") is None
def test_fake_clock_is_active(fake_clock):
# fixture 生效期间读到的是假时钟
assert time.time() == 1_700_000_000.0
fake_clock 采用函数作用域,用例结束后 pytest 会自动还原 time.time,所以不需要手写 try/finally。上面最后一个用例只是用来确认补丁确实生效;如果断言“补丁已还原”,就只能在另一个不依赖该 fixture 的用例里比较真实时间,这类断言本身依赖真实时钟,通常只在本地调试时使用。
运行方法
python -m pip install pytest
pytest -q test_ttl_cache.py
想看每个用例的名字与结果可加 -v,定位失败时用 pytest -v -x --tb=short。整套用例只是普通函数调用,不产生真实等待,因此比用 sleep 的做法稳定得多。
边界与限制
- 补丁范围:
monkeypatch.setattr(time, "time", fake_time)修改的是time模块属性,补丁生效期间同进程内所有调用time.time()的代码都会受影响,被测模块自然也不例外。如果同一用例里还调用了其他依赖真实时间的库,就会一起被“冻住”,此时更稳妥的写法是只给被测模块打补丁,例如monkeypatch.setattr("ttl_cache.time", fake_time)。 - 导入方式决定补丁位置:模块若写成
from time import time,则必须在模块命名空间上打补丁,否则改time.time不会生效。设计被测代码时刻意保留一个可替换的时间入口,测试成本会低很多。 - 时钟选择:
time.time()不是单调时钟,受系统时间调整(如 NTP 校正、手工改时间)影响,可能回拨,导致缓存提前失效或长期不过期。生产代码更推荐time.monotonic(),测试中同样可以替换它,只要被测代码只通过一个入口读取时间。 - 并发场景覆盖不到:把时间变成变量只解决了时间这一维度,多线程同时读写、过期瞬间的竞态仍需额外的同步设计与并发测试来覆盖。
- 浮点与边界约定:示例用
>= expire_at判定过期,因此“恰好等于 TTL”算作失效。若业务希望多留余量,可改成>并同步调整用例断言,测试的期望值必须与实现约定保持一致。 - 假时钟不能替代集成验证:monkeypatch 验证的是逻辑分支,无法替代对真实时钟、序列化、实际存储介质等行为的验证。
小结
思路只有三步:让被测代码从单一入口读时间,用 monkeypatch 把它替换成可控的假时钟,再在用例里手动推进这个时钟。这样“TTL 内命中、到期失效、重写刷新”三类分支都能被确定性地覆盖,测试也不会再依赖真实等待。
- 点赞
- 收藏
- 关注作者
评论(0)