1. 一个让新手破防的浮点数断言场景
先抛一个所有写pytest的兄弟大概率都撞过的墙。你在测某个计算函数,逻辑明明是对的,断言却红得发紫。比如你写了一个简单的汇率换算:
def usd_to_cny(amount, rate=7.24): return amount * rate def test_exchange(): assert usd_to_cny(100) == 724.0用pytest一跑,报错长这样:
E AssertionError: assert 724.0000000000001 == 724.0第一次遇到这种报错,大概率直接懵了:724.0000000000001是哪来的?我没写过这个数啊。后面跟的是一长串浮点数尾巴,看着就像计算机在嘲讽我们。
这个问题的根源不是你代码错了,而是IEEE 754浮点数表示法。任何一门编程语言的浮点运算都要面对它。0.1加0.2不等于0.3,这不是段子,是真实存在的精度问题。计算机用二进制存小数,很多十进制小数在二进制里是无限循环的,只能截断存储,于是运算后会在末尾留下一个微小的误差尾巴。
pytest官方框架给这个场景提供了一个很直接的工具——pytest.approx。这一个工具就能把上面那种尴尬的断言从地狱拉回人间:
from pytest import approx def test_exchange(): assert usd_to_cny(100) == approx(724.0)这次测试直接通过,不再纠结724.0000000000001还是724.0。approx做的事情很简单:当你用==比较浮点数时,它把等号改写成“差值是否在容差范围内”。但就是这一层薄薄的封装,让大量浮点测试代码从脆弱变成了稳健。
这篇文章想把pytest.approx从头到尾讲透。如果你已经在写pytest、但每次遇到浮点数都靠round()硬扛,或者你想知道approx内部到底怎么算容差、什么时候该用什么时候不该用,那这篇内容应该能帮你省下不少排查时间。
2. 默认容差机制:近似不等于无脑宽松
用approx之前,大部分人的第一反应可能是“不就是把断言改成assert abs(a - b) < 0.001嘛”。如果你真是这么干的,我只能说方向对了,但姿势不够优雅。approx默认的容差逻辑比简单差值的绝对值要讲究得多。
2.1 相对误差和绝对误差各是什么
先补一个基础知识,不然后面没法聊。绝对误差就是两个数做差的绝对值,比如abs(1.01 - 1.0) = 0.01。相对误差则是绝对误差除以参与比较的数值本身,表达的是“偏差占了真实值的百分之几”。
生产环境里的浮点数,量级天差地别。同样是0.01的绝对误差,放在测量温度这种场景下几乎可以忽略,放在化学反应配比计算里可能是致命的。所以工程实践上,相对误差比绝对误差更能反映“数值是否算对了”。
pytest.approx默认策略就是基于相对误差,默认值为1e-6,也就是百万分之一的偏差容忍度。这个值不是拍脑袋定的,它跟Python标准库math.isclose的默认值保持了一致,被大量科学计算和测试场景验证过,是个比较稳妥的起点。
2.2 单值比较的最基本用法
先来一组最朴素的使用方式,把地基打牢。
from pytest import approx def test_basic(): assert 0.1 + 0.2 == approx(0.3) assert 1.0 == approx(1.0000001) # 默认相对容差内,通过 assert 1.0 != approx(1.01) # 默认容差不够,不近似第三个断言值得停下来想一下。1.0和1.01只差了0.01,为什么判定为不近似?因为相对误差容差是用两个数中的较大者去乘1e-6。1.01乘以1e-6大概等于1e-6量级,而实际偏差是0.01,比容差大了四个数量级,自然判为不通过。
这里要聊一个很多人容易忽略的边界问题:当比较对象接近0的时候,相对误差机制会失效。原因很好理解:如果真实值是1e-20这种量级,乘以1e-6的容差还是1e-26,而任何浮点运算的误差都可能远超这个数。结果就是:明明是正常的计算,因为数值太小,被判为不近似。
所以approx留了一个后门——min_value兜底机制。它要求,计算容差时,允许的误差不会小于某个绝对值底线。底层大致逻辑是:
- 取
abs(expected)和abs(actual)中较大的那个数,记为base base = max(base, min_value),确保任何情况下容差不会小到离谱- 允许误差 =
max(rel * base, abs_tol)
如果你在测的数值可能趋近0,显式设置min_value是必须的。不然你的测试会偶尔绿、偶尔红,看起来跟抽风一样。
def test_tiny_values(): # 不设min_value的话,下面这种断言极不稳 assert 1e-20 == approx(1.1e-20, rel=1e-6, min_value=1e-15)min_value的意思是:哪怕两个数都趋近于0,我允许的误差也至少是1e-15级别。这样1e-20和1.1e-20之间2e-21的偏差,在这个底线之内,可以判定为近似。记住这个参数,它是处理“接近零”场景的救命稻草。
2.3 对称性陷阱:approx不是等价关系
这里要揭开一个容易踩的坑:approx的比较结果不具备数学上的对称性。用大白话说,A == approx(B)成立,不代表B == approx(A)也一定成立。
原因是容差基于两个数中较大的那个数计算。我们举一个真实例子:
from pytest import approx print(1.2 == approx(1.3, rel=0.05)) # 1.3 * 0.05 = 0.065,实际偏差0.1 > 0.065,False print(1.3 == approx(1.2, rel=0.05)) # 1.3 * 0.05 = 0.065,实际偏差0.1 > 0.065,False如果两个数差距悬殊,不对称性更明显。比如100.0 == approx(1.0, rel=0.5)时,100 * 0.5 = 50,实际偏差99,大于50,所以False。反过来1.0 == approx(100.0, rel=0.5)时,同样的容差计算,是100*0.5=50,偏差99,还是False。哎,换个数值例子:
print(100.0 == approx(60.0, rel=0.5)) # base=100, tol=50, 偏差40 < 50, True print(60.0 == approx(100.0, rel=0.5)) # base=100, tol=50, 偏差40 < 50, True这个例子里结果一样,但我们要注意逻辑:approx内部是对称处理的,并不是说它完全等价于一个双向的isclose。它先取两个数的最大值作为基准,再算相对误差。因此从数学角度看,它比“只以期望值为基准”的实现要公平一些,但仍然不是严格意义上的等价关系。
在实际测试中,这意味着什么?断言的书写顺序会影响通过与否的边界值情况。尤其当你设置很紧的rel时,assert actual == approx(expected)和assert expected == approx(actual)可能结果不同。所以一个推荐的习惯是:始终把被测结果写在左边,期望值写在右边,保持全项目的统一。这样出问题的时候,排查方向也是统一的。
3. 容器类型比较:直接解构dict、list、tuple
approx最实用的地方还不在于单值断言,而是它对容器类型做了深度支持。很多被测函数返回的是一个结构化的数据,比如字典、列表、元组。如果靠手写遍历去断言,代码又长又容易看漏。approx可以直接把结构整体包进来。
3.1 列表与元组的逐项比较
def test_list(): data = [0.1 + 0.2, 1.0 / 3.0] assert data == approx([0.3, 0.333333])写断言时,左边是真实计算结果,右边用approx包住期望值。执行时,approx会递归展开列表中的每一项,每一项都走浮点近似比较的逻辑。一旦有某一项不在容差内,pytest会给出非常详细的失败信息,标注出是哪个下标出了问题。
元组的比较逻辑和列表一致,唯一的区别是类型本身。有一点值得提:嵌套结构也是支持的,approx可以处理列表套列表、字典套列表这类组合结构。这在被测函数返回复杂JSON结构时极其好用。
3.2 字典比较的逐键行为
字典比较是最常见的场景之一,因为几乎每个Web接口、每个配置解析函数都会返回字典。
def test_dict(): result = { "price": 19.99, "count": 3, "items": [0.1 + 0.2, 0.4] } assert result == approx({ "price": 20.0, "count": 3, "items": [0.3, 0.4] })approx在比较字典时,是逐键比较的。这意味着:
- 键必须完全匹配,期望字典里没写的键,在结果里多了,直接失败
- 结果里缺了期望字典里的键,也直接失败
- 每个键对应的值,按值的类型继续递归处理:数字走浮点近似,列表走逐项,嵌套字典继续递归
这让接口测试的断言变得特别干净。以前要写一堆assert abs(res["price"] - 20) < 0.01,现在一行搞定,同时还能保证键的完整性。
但这里提醒一句:approx对字典的键顺序没有任何要求,它按键名去匹配,不是按下标。所以如果你手动构造了两份键顺序不同的字典,比较结果依然是正确的。
3.3 使用approx处理numpy数组时的特殊注意点
如果你做科学计算或数据处理,大概率会用numpy。approx不能直接对numpy数组做断言——至少不能像普通列表那样直接用。看一个例子:
import numpy as np def test_numpy_wrong(): arr = np.array([0.1, 0.2]) assert arr == approx([0.1, 0.2]) # 会出问题这里的问题是:arr == approx(...)会触发numpy的逐元素广播,最终得到一个布尔数组,比如array([True, True])。而assert一个布尔数组时,numpy会抛出“包含多个元素的数组真值不明确”的异常。
解决方案有两种:
第一种,把数组转成列表再比较:
def test_numpy_tolist(): arr = np.array([0.1, 0.2]) assert arr.tolist() == approx([0.1, 0.2])第二种,在断言表达式外部加上.all():
def test_numpy_all(): arr = np.array([0.1, 0.2]) assert (arr == approx([0.1, 0.2])).all()我个人推荐第二种,因为失败时pytest能给出更清晰的报错上下文。坦白讲这个行为跟approx本身没关系,是numpy比较运算符的重载机制导致的,但既然大家天天把这两个东西放一起用,知道这个坑能省不少调试时间。
4. 构造器的高级玩法:rel、abs、nan_ok与自定义比较
approx看起来只是approx(x)这么简单,其实背后是一个构造器,支持好几个参数。用好了,能让断言的可读性和健壮性都上一个台阶。
4.1 rel和abs参数怎么配合使用
approx的完整构造形式是approx(expected, rel=1e-6, abs=1e-12)。这里有两个独立参数,可以组合控制比较逻辑。它们的优先级和计算关系是:取rel * max(abs(expected), abs(actual))和abs参数中的较大者作为最终容差。
用人话说就是,绝对容差更像是保险丝,保证相对容差在某些特殊场景下不会显得不近人情。
举个例子:测量一个物理量,真实值可能在5左右,仪器误差可能是0.02。那么容差完全由绝对误差决定更符合直觉:
def test_measurement(): measured = 5.01 expected = 5.0 assert measured == approx(expected, abs=0.02) # 通过 # 若只用默认rel=1e-6,容差只有5e-6,被测值直接失败这种场景下,rel设置成多少都无所谓,因为abs=0.02远大于rel * base,实际生效的是abs。
反过来,如果数值本身跨度很大,用绝对误差就不合适了。比如你比较温度传感器在不同量程下的输出,量程从0.1到1000都有,这时用相对误差更合理,因为每个量级的精度需求不同。
最佳实践是:当你知道这个数值的物理/业务意义时,优先设定有意义的绝对误差,而不是永远依赖默认的rel。默认的1e-6对很多业务场景其实过于敏感了,容易把本来正常的测试跑红。
4.2 处理NaN的默认策略:nan_ok=False的意义
浮点世界里有个特殊值float('nan'),表示“不是一个数”。它最诡异的地方在于,NaN不等于任何值,包括它自己。这导致一个麻烦:如果被测函数因为某个异常路径返回了NaN,你在断言里期望的数值无论如何都不会匹配,测试大概率是失败的,这能帮你暴露bug,但也可能误伤。
approx对外暴露了一个nan_ok参数,默认是False。含义是:当结果里出现NaN时,是否允许它和期望的NaN进行近似比较。
def test_nan_policy(): # 默认nan_ok=False,NaN与自己比较会失败 assert float("nan") == approx(float("nan")) # False # 显式允许,NaN可以和NaN成功比较 assert float("nan") == approx(float("nan"), nan_ok=True)什么时候该开这个参数?当你测的API在数据缺失时可能返回NaN,而你又希望这个行为本身是被允许的。这时用nan_ok=True的断言,实际上是说“要么是一个合法数值,要么是NaN都行”。如果不开,遇到合法数值时交正常逻辑,遇到NaN时直接失败。这个权衡要看你是否把NaN当成合法结果。我的建议是:正常情况下保持默认False,除非你明确知道被测功能会把NaN当作一个有效状态。
4.3 自定义类是否能直接用approx
approx理论上支持实现了长度和序列协议的自定义类,因为它的内部比较逻辑依赖长度和迭代能力。比如一个自定义的坐标类:
class Point: def __init__(self, x, y): self.x = x self.y = y def __iter__(self): return iter((self.x, self.y)) def test_custom_class(): p = Point(1.0, 2.0) assert p == approx(Point(1.0, 2.000001))只要这个类可以被迭代拆成几个浮点数,approx就能用。如果没有实现迭代协议,那就要自己在类里实现__eq__,然后结合approx做内部比较。
关于自定义类,最值得说的是:approx不要求两个对象是同一个类型,因为它的比较本质上是把对象结构拆开后递归比较。这意味着你可以比较一个自定义类对象和一个普通元组,只要元素顺序和数量对上。这在测试某些轻量数据结构时非常方便。
5. 为什么不用math.isclose而要选approx
聊到浮点近似比较,一定有读者会想:Python标准库不是有math.isclose吗?为什么还要用pytest.approx?这是一个值得回答的好问题,也是我经常在实际代码评审里看到的疑惑。
5.1 参数逻辑高度一致,但使用体验不同
从底层逻辑看,pytest.approx的默认值和比较算法跟math.isclose几乎一样,都涉及rel_tol和abs_tol。所以在“近似比较”这个核心功能上,两者没有本质区别。真正的差异在使用体验和断言意图的表达上。
math.isclose是一个纯函数,你需要手动把被测值和期望值传进去:
import math def test_manual_isclose(): result = usd_to_cny(100) assert math.isclose(result, 724.0, rel_tol=1e-9)这看起来也没有差很多。但当断言失败时,pytest对math.isclose的失败信息爱莫能助,它只能告诉你“assert False”,你无从判断是哪个数值、差了多远。
而approx作为pytest的一等公民,断言的失败信息会被pytest特殊解析。实测一下,失败输出长这样:
E assert 724.0000000000001 == 724.0 ± 7.24e-07这个“± 7.24e-07”是pytest帮你算出来的容差范围。拿到这个信息,你一眼就能看出测试是卡在精度边缘,还是被误差完全碾压了。这对调试这种隐蔽问题是巨大的效率提升。
5.2 结构比较的胜出优势
math.isclose只能比较两个数。如果你测的是字典结果,你需要自己写循环;如果测的是嵌套列表,你需要递归或者拍平。写出来的代码很容易变成一大串难以维护的样板。
def test_isclose_with_dict(): result = {"price": 19.99} expected = {"price": 20.0} assert math.isclose( result["price"], expected["price"], rel_tol=1e-6 )单个键还能忍,键一多就崩了。如果有嵌套结构,你几乎要手写一个JSON对比工具。而approx帮你把递归展开和容差计算全都包在里面,一行断言覆盖整个结构。
在我自己的项目里,策略是这样的:
- 单数值、核心算法的精度验证,用
approx设置明确的rel/abs,不用math.isclose - 在一些非pytest环境(比如独立脚本、生产代码里的校验逻辑)用
math.isclose - 涉及整个dict/list结构断言,一律
approx
这样划分我觉得是比较合理的,因为approx本来就是为测试场景设计的语法糖,离开pytest之后没有太多价值。
6. 实战:浮点断言从“偶尔飘红”到“稳定复现”
聊了这么多原理和参数,最后上一些我在项目里踩过的真实坑,以及一套从“看着能用”到“稳定可靠”的实践方法。
6.1 测试浮点计算结果时的三个稳定措施
第一,每次写浮点断言前,先问自己一个量化问题:这个计算结果的物理/业务精度要求是多少?比如计算订单总价,可能要求精确到分,那绝对误差就是0.01以内;计算距离,可能允许1米误差,那就是rel设到1e-3。这个精度要求是要跟被测函数的调用方确认的,而不是拍脑袋。
第二,把期望值的表达本身写清楚。跟直接写approx(0.3)相比,我更喜欢写approx(0.3, rel=1e-9)或在程序里定义一个明确的容差常量。因为默认1e-6对某些场景太严格、对另一些又太松,显式表达出来,两个月后回看代码,你才知道自己当时为什么这么定。
第三,构造测试数据时,刻意用一些“容易产生浮点尾巴”的组合。比如测加法,就不该只用整数0和1,你该试试0.1 + 0.2;测幂运算,该试试1.0 / 3.0 * 3.0。把这些真实边界数据放进测试里,才能让approx发挥出价值。
6.2 我踩过的三个坑,希望你绕过去
坑一:在循环里手写abs比较,导致一大簇测试函数模板化。早期我写过很多assert abs(calc(i) - exp[i]) < 0.01,后来发现一旦精度需求变化,要跑到十几个地方改阈值。统一改成assert calc(i) == approx(exp[i], abs=0.01)之后,维护成本瞬间降下来,因为容差集中写在参数里了。
坑二:对本来就该精确相等的整数做approx,掩盖了真正的bug。比如assert user_count == approx(100, abs=5),这种断言把“用户数量必须是100”的强约束吹成了“100左右就行”。approx只应用于浮点计算,对于整数、字符串、枚举这种精确值,老老实实用==。否则当代码逻辑错误导致结果变成105时,测试还会绿灯通过,后半程才发现。
坑三:默认rel对numpy计算的大误差过于苛刻。有一次跑机器学习模型的验证,输出值动辄几百,模型本身的数值波动在1e-4级别。用默认rel=1e-6跑,容差算下来只有1e-4量级,测试随机性飘红。后来把rel调成1e-4,断言才稳定下来。这提醒我,容差不是越小越好,它要匹配模型的数值稳定性。
6.3 一个迷你实战案例:计算折扣后的价格
假设写了这样一个函数:
def calc_discount_price(original, discount): """原价与折扣比例计算折后价,四舍五入保留两位小数。""" return round(original * discount, 2)对应测试:
from pytest import approx def test_calc_discount_price(): price = calc_discount_price(99.9, 0.85) assert price == approx(84.92, abs=0.005) # 四舍五入导致的0.005级偏差这里设置abs=0.005是因为round()本身会引入最多0.005的舍入误差。预期值是84.915再到84.92,中间经过了四舍五入,实际计算结果可能是84.914999999。如果不给abs,默认rel算下来的容差是1e-5级别,会把这正常范围的四舍五入当成误差,测试直接挂掉。
这种场景在业务代码里特别常见:函数内部有round或截断操作,输出和“理想精确值”天然存在一个已知偏差,abs参数的作用就是把这个已知偏差写进断言。
7. 要不要改默认容差?说说我的取舍习惯
写到最后这部分,想集中回答一个在团队评审中经常被问到的问题:pytest.approx默认的1e-6到底够不够用?要不要在项目里全局调大或者调小?
我的看法是:不建议全局修改默认值。因为approx的使用场景横跨单值比较、容器比较、科学计算,复杂度千差万别。一个全局默认值很难同时适配温度传感器测试和订单金额计算。更合适的做法是:
- 在需要明确容差的地方,显式传
rel/abs参数 - 如果同一批测试确实大量使用相同容差,可以自己封装一个简单函数来统一管理,参数集中定义
例如可以在conftest.py里定义一个helper:
from pytest import approx def currency_approx(expected): """金额近似断言:绝对误差不超过一分钱。""" return approx(expected, abs=0.01)这样既保留了approx的结构比较能力,又把精度策略收口到一处。代码里到处是approx(30.0, abs=0.01)我也写过,一开始没什么问题,后来发现精度要求变了,还是在几十个文件里来回翻。封装一层之后,改一处就全生效。这种“多一层自定义函数”的写法,对长期维护的项目来说,收益非常明显。
另外想分享一个在团队里比较受用的约定:写浮点断言时,必须用注释或参数说明写明这个容差的依据。比如assert distance == approx(100.0, rel=0.01) # GPS误差约1%。这是我在几次“两个月后回看代码完全不知道当时为什么定这个值”的教训后养成的习惯,现在回头看,真的很值。
pytest.approx是一个看起来只有一行、但背后把浮点比较逻辑讲得比较透的工具。从默认相对误差的机制,到容器递归比较,再到与numpy、math.isclose的配合,每一个使用细节背后都对应着一个具体的测试场景。写这篇文章的时候,我一直在想一个合适的比喻——它就是pytest世界里的“公差带”,有了它,我们终于不用被浮点数的二进制尾巴折磨得怀疑人生。下次再看到红红的assert 724.0000000000001 == 724.0,记得给自己泡杯茶,然后直接上approx。