资讯动态

pytest.approx完全指南:告别浮点数断言噩梦

发布时间:2026/10/15 14:20:15 来源:尧图企业网站定制
1. 一个让新手破防的浮点数断言场景先抛一个所有写pytest的兄弟大概率都撞过的墙。你在测某个计算函数逻辑明明是对的断言却红得发紫。比如你写了一个简单的汇率换算def usd_to_cny(amount, rate7.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)中较大的那个数记为basebase 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, rel1e-6, min_value1e-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, rel0.05)) # 1.3 * 0.05 0.065实际偏差0.1 0.065False print(1.3 approx(1.2, rel0.05)) # 1.3 * 0.05 0.065实际偏差0.1 0.065False如果两个数差距悬殊不对称性更明显。比如100.0 approx(1.0, rel0.5)时100 * 0.5 50实际偏差99大于50所以False。反过来1.0 approx(100.0, rel0.5)时同样的容差计算是100*0.550偏差99还是False。哎换个数值例子print(100.0 approx(60.0, rel0.5)) # base100, tol50, 偏差40 50, True print(60.0 approx(100.0, rel0.5)) # base100, tol50, 偏差40 50, True这个例子里结果一样但我们要注意逻辑approx内部是对称处理的并不是说它完全等价于一个双向的isclose。它先取两个数的最大值作为基准再算相对误差。因此从数学角度看它比“只以期望值为基准”的实现要公平一些但仍然不是严格意义上的等价关系。在实际测试中这意味着什么断言的书写顺序会影响通过与否的边界值情况。尤其当你设置很紧的rel时assert actual approx(expected)和assert expected approx(actual)可能结果不同。所以一个推荐的习惯是始终把被测结果写在左边期望值写在右边保持全项目的统一。这样出问题的时候排查方向也是统一的。3. 容器类型比较直接解构dict、list、tupleapprox最实用的地方还不在于单值断言而是它对容器类型做了深度支持。很多被测函数返回的是一个结构化的数据比如字典、列表、元组。如果靠手写遍历去断言代码又长又容易看漏。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, rel1e-6, abs1e-12)。这里有两个独立参数可以组合控制比较逻辑。它们的优先级和计算关系是取rel * max(abs(expected), abs(actual))和abs参数中的较大者作为最终容差。用人话说就是绝对容差更像是保险丝保证相对容差在某些特殊场景下不会显得不近人情。举个例子测量一个物理量真实值可能在5左右仪器误差可能是0.02。那么容差完全由绝对误差决定更符合直觉def test_measurement(): measured 5.01 expected 5.0 assert measured approx(expected, abs0.02) # 通过 # 若只用默认rel1e-6容差只有5e-6被测值直接失败这种场景下rel设置成多少都无所谓因为abs0.02远大于rel * base实际生效的是abs。反过来如果数值本身跨度很大用绝对误差就不合适了。比如你比较温度传感器在不同量程下的输出量程从0.1到1000都有这时用相对误差更合理因为每个量级的精度需求不同。最佳实践是当你知道这个数值的物理/业务意义时优先设定有意义的绝对误差而不是永远依赖默认的rel。默认的1e-6对很多业务场景其实过于敏感了容易把本来正常的测试跑红。4.2 处理NaN的默认策略nan_okFalse的意义浮点世界里有个特殊值float(nan)表示“不是一个数”。它最诡异的地方在于NaN不等于任何值包括它自己。这导致一个麻烦如果被测函数因为某个异常路径返回了NaN你在断言里期望的数值无论如何都不会匹配测试大概率是失败的这能帮你暴露bug但也可能误伤。approx对外暴露了一个nan_ok参数默认是False。含义是当结果里出现NaN时是否允许它和期望的NaN进行近似比较。def test_nan_policy(): # 默认nan_okFalseNaN与自己比较会失败 assert float(nan) approx(float(nan)) # False # 显式允许NaN可以和NaN成功比较 assert float(nan) approx(float(nan), nan_okTrue)什么时候该开这个参数当你测的API在数据缺失时可能返回NaN而你又希望这个行为本身是被允许的。这时用nan_okTrue的断言实际上是说“要么是一个合法数值要么是NaN都行”。如果不开遇到合法数值时交正常逻辑遇到NaN时直接失败。这个权衡要看你是否把NaN当成合法结果。我的建议是正常情况下保持默认False除非你明确知道被测功能会把NaN当作一个有效状态。4.3 自定义类是否能直接用approxapprox理论上支持实现了长度和序列协议的自定义类因为它的内部比较逻辑依赖长度和迭代能力。比如一个自定义的坐标类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_tol1e-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_tol1e-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, rel1e-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], abs0.01)之后维护成本瞬间降下来因为容差集中写在参数里了。坑二对本来就该精确相等的整数做approx掩盖了真正的bug。比如assert user_count approx(100, abs5)这种断言把“用户数量必须是100”的强约束吹成了“100左右就行”。approx只应用于浮点计算对于整数、字符串、枚举这种精确值老老实实用。否则当代码逻辑错误导致结果变成105时测试还会绿灯通过后半程才发现。坑三默认rel对numpy计算的大误差过于苛刻。有一次跑机器学习模型的验证输出值动辄几百模型本身的数值波动在1e-4级别。用默认rel1e-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, abs0.005) # 四舍五入导致的0.005级偏差这里设置abs0.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里定义一个helperfrom pytest import approx def currency_approx(expected): 金额近似断言绝对误差不超过一分钱。 return approx(expected, abs0.01)这样既保留了approx的结构比较能力又把精度策略收口到一处。代码里到处是approx(30.0, abs0.01)我也写过一开始没什么问题后来发现精度要求变了还是在几十个文件里来回翻。封装一层之后改一处就全生效。这种“多一层自定义函数”的写法对长期维护的项目来说收益非常明显。另外想分享一个在团队里比较受用的约定写浮点断言时必须用注释或参数说明写明这个容差的依据。比如assert distance approx(100.0, rel0.01) # GPS误差约1%。这是我在几次“两个月后回看代码完全不知道当时为什么定这个值”的教训后养成的习惯现在回头看真的很值。pytest.approx是一个看起来只有一行、但背后把浮点比较逻辑讲得比较透的工具。从默认相对误差的机制到容器递归比较再到与numpy、math.isclose的配合每一个使用细节背后都对应着一个具体的测试场景。写这篇文章的时候我一直在想一个合适的比喻——它就是pytest世界里的“公差带”有了它我们终于不用被浮点数的二进制尾巴折磨得怀疑人生。下次再看到红红的assert 724.0000000000001 724.0记得给自己泡杯茶然后直接上approx。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑