用 pytest monkeypatch 模拟 time.time 测试缓存 TTL

举报
yd_229231784 发表于 2026/10/27 00:13:12 2026/10/27
【摘要】 缓存 TTL 逻辑依赖真实时间,直接 sleep 会让测试变慢且不稳定。本文用一个最小可运行的 TTLCache 类配合 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 的做法稳定得多。

边界与限制

  1. 补丁范围:monkeypatch.setattr(time, "time", fake_time) 修改的是 time 模块属性,补丁生效期间同进程内所有调用 time.time() 的代码都会受影响,被测模块自然也不例外。如果同一用例里还调用了其他依赖真实时间的库,就会一起被“冻住”,此时更稳妥的写法是只给被测模块打补丁,例如 monkeypatch.setattr("ttl_cache.time", fake_time)。
  2. 导入方式决定补丁位置:模块若写成 from time import time,则必须在模块命名空间上打补丁,否则改 time.time 不会生效。设计被测代码时刻意保留一个可替换的时间入口,测试成本会低很多。
  3. 时钟选择:time.time() 不是单调时钟,受系统时间调整(如 NTP 校正、手工改时间)影响,可能回拨,导致缓存提前失效或长期不过期。生产代码更推荐 time.monotonic(),测试中同样可以替换它,只要被测代码只通过一个入口读取时间。
  4. 并发场景覆盖不到:把时间变成变量只解决了时间这一维度,多线程同时读写、过期瞬间的竞态仍需额外的同步设计与并发测试来覆盖。
  5. 浮点与边界约定:示例用 >= expire_at 判定过期,因此“恰好等于 TTL”算作失效。若业务希望多留余量,可改成 > 并同步调整用例断言,测试的期望值必须与实现约定保持一致。
  6. 假时钟不能替代集成验证:monkeypatch 验证的是逻辑分支,无法替代对真实时钟、序列化、实际存储介质等行为的验证。

小结

思路只有三步:让被测代码从单一入口读时间,用 monkeypatch 把它替换成可控的假时钟,再在用例里手动推进这个时钟。这样“TTL 内命中、到期失效、重写刷新”三类分支都能被确定性地覆盖,测试也不会再依赖真实等待。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。