测试最怕「不确定」:现在几点、随机数是多少、网络通不通。这三样不受你控制,测试就会间歇失败。这篇把三类问题各用一个真实实验讲清:怎么让时间、随机、网络变得可控,以及 patch 最容易踩的那个坑。
shop.py def is_business_hours(): h = datetime.datetime.fromtimestamp(time.time()).hour return 9 <= h < 17
不打补丁的测试 assert is_business_hours() 在一天 24 个整点里会通过的时刻: [9, 10, 11, 12, 13, 14, 15, 16] → 24 小时里 8 小时通过, 16 小时失败: 同一份代码、同一个测试, 结果取决于你在几点跑
这里为了统计,我用 patch 把时间依次设到 24 个整点。结论是:一个直接断言 is_business_hours() 为 True 的测试,只在 9 到 16 点之间通过,其余 16 个小时都会失败——CI 半夜跑就红了。
patch('time.time') 之后: shop.is_business_hours() 看到的 time.time() = 1234.0 (生效, 因为它写的是 time.time())
但 from time import time as time_fn 的那份: 1790690773.009121 ← 没有被替换, 仍是真实时间戳(≈ 现在)
改成 patch('shop.time_fn') 之后: 1234.0 (patch 要打在「使用它的地方」)
patch('datetime.datetime.now') → TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'这是最常见的 patch 陷阱:patch("time.time") 替换的是 time 模块里的名字,代码里写 time.time() 的地方会生效,但如果是 from time import time as time_fn 那样把名字拿到自己模块里,拿走的那份引用不会变,得 patch("shop.time_fn")——要 patch「被使用的地方」,而不是「被定义的地方」。另外 datetime.datetime.now 是内置类型上的属性,不能直接替换,会得到 TypeError(常见做法是 patch 整个 datetime 名字,或者用专门的库)。
clock.py def is_business_hours(now=None): now = now or datetime.datetime.now() return 9 <= now.hour < 17
is_business_hours(datetime(2026,9,29,10,0)) = True is_business_hours(datetime(2026,9,29,23,0)) = False (不需要任何 patch, 也不依赖真实时钟)
如果时间是函数的参数,测试就直接传一个固定时间——不需要 patch、不依赖真实时钟、不会有「打在哪里」的困扰。生产代码不传参数时走默认。这种「依赖注入」比 patch 更直接,代价是需要改接口。
全局 random 无种子,连跑 8 次 roll(): [4, 1, 3, 4, 1, 4, 2, 5] random.seed(42) 两次: [6, 1, 1, 6, 3, 2] [6, 1, 1, 6, 3, 2] 相同: True 注入独立的 Random(7) 实例: [3, 2, 4, 6, 1, 1] [3, 2, 4, 6, 1, 1] 全局 seed(1) 与 Random(1) 产生同一个序列: True seed(99) 后另造并使用一个 Random(5), 全局序列是否完全不受影响? True (seed(0) 之后依次得到 50, 98;test_a 断言第一个值,test_b 没设种子却断言第二个值) 两个测试共用全局随机状态: 顺序 a → b: 2 passed in 0.07s 顺序 b → a: 1 failed, 1 passed in 0.08s
random.seed(42) 两次能复现同一串数;更稳妥的是给函数传一个独立的 random.Random(种子) 实例,它和全局状态互不干扰。全局状态的坏处在最后两行:test_a 设了种子并断言第一个值,test_b 没设种子,「碰巧」断言了第二个值。按 a→b 运行,两个都通过;按 b→a 运行,b 失败——它的结果取决于测试的执行顺序。这类 bug 在开启随机测试顺序或并行运行时才会暴露。
conftest.py(节选) @pytest.fixture def no_network(monkeypatch): real = socket.socket.connect def guard(self, addr, *a, **k): host = addr[0] if isinstance(addr, tuple) else addr if host not in ("127.0.0.1", "::1", "localhost"): raise RuntimeError(f"测试里不允许访问外部网络: {addr}") return real(self, addr, *a, **k) monkeypatch.setattr(socket.socket, "connect", guard)
[无保护] TimeoutError 用时 0.60 s
[有保护] RuntimeError: 测试里不允许访问外部网络: ('203.0.113.1', 80) 用时 0.0003 s
[有保护] 连接本机回环地址仍然可以
3 passed in 0.71s用一个 fixture 替换 socket.connect:除回环地址外一律抛异常。实验里连的是 203.0.113.1(文档保留地址,不会有真实主机应答,不会对任何人发流量):没有保护时要等 0.60 s 才超时;有保护时 0.3 毫秒就报错,而且报错信息直接说明「测试里试图访问外网」——比一个莫名其妙的超时好排查得多,也让测试不再因为网络抖动而随机失败。
test_real_sleep → 1 passed in 1.12s test_patched_sleep → 1 passed in 0.07s
带重试的函数每次失败后 time.sleep(delay)。把 time.sleep 换成「往列表里记一下」的函数,测试从 1.12 s 降到 0.07 s,而且可以顺便断言「一共等了 [0.5, 0.5]」——既快又验证了重试的间隔逻辑。
unittest.mock;网络实验只访问文档保留地址 203.0.113.1(不会有应答)和本机回环loop.time)、时区与夏令时相关的问题