Floats y pytest.approx — probar «lo bastante cerca»
Por qué 0.1 + 0.2 == 0.3 es falso, qué tolera pytest.approx por defecto, cuándo necesitas rel y abs, y por qué el dinero requiere Decimal en lugar de approx. Con NaN, resultados sin orden y marcas de tiempo.
¿Cómo quieres aprender?
El problema que vamos a resolver
Pregúntale a Python algo que cualquier niño sabría responder:
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)0.30000000000000004
FalseNo es un bug en tu equipo ni un fallo en Python. Es la forma en que casi todos los lenguajes de programación almacenan fracciones decimales. Y se cuela directo en tus tests.
test_total.py:
def total(prices):
return sum(prices)
def test_total():
assert total([0.1, 0.2]) == 0.3Luego pytest -q:
F [100%]
=================================== FAILURES ===================================
__________________________________ test_total __________________________________
def test_total():
> assert total([0.1, 0.2]) == 0.3
E assert 0.30000000000000004 == 0.3
E + where 0.30000000000000004 = total([0.1, 0.2])
test_total.py:6: AssertionError
=========================== short test summary info ============================
FAILED test_total.py::test_total - assert 0.30000000000000004 == 0.3
1 failed in 0.01sLa función es correcta. El test está mal. Exige una exactitud que los números de punto flotante no pueden dar. Este capítulo trata sobre hacer la pregunta correcta: ¿está lo bastante cerca? También trata sobre los casos donde «lo bastante cerca» es la pregunta equivocada, y el dinero es el principal de ellos.
Al terminar este capítulo podrás
- Explicar en dos oraciones por qué
0.1 + 0.2 == 0.3esFalse - Comparar floats en un test con
pytest.approxy leer su tolerancia por defecto - Definir tu propia tolerancia con
rel=yabs=, y saber cuál necesitas cerca de cero - Usar
approxen listas, tuplas y diccionarios, y leer el informe que produce cuando difieren - Elegir entre
pytest.approx,math.iscloseyDecimal - Probar NaN, resultados cuyo orden no está prometido y marcas de tiempo (timestamps)
Requisitos previos: probar excepciones.
Antes de escribir el test
Con los números, la decisión más importante se toma antes de escribir cualquier línea de código: para cada resultado, ¿qué tipo de igualdad ha prometido el código? Si te equivocas, el test será o bien inestable (demasiado estricto) o ciego (demasiado permisivo). Aclara tres cosas primero.
1. El contrato. Toma el pequeño módulo stats.py con el que concluye este capítulo. En palabras sencillas, promete:
mean(values)devuelve el promedio de una lista de floats, ynanpara una lista vacía.shares(counts)convierte conteos en fracciones que suman uno.with_vat(price)añade un 15% de IVA a un precio enDecimal, redondeado hacia arriba al centavo más cercano (half-up). Un precio enfloatse rechaza con unTypeError.countries(orders)devuelve cada país una sola vez. No promete ningún orden.
Cada línea ya te indica cómo comparar. «Promedio de floats» implica una tolerancia. «Redondeado al centavo» implica exactitud. «Rechazado» implica pytest.raises. «Sin ningún orden» implica ordenar o usar un conjunto antes de comparar.
2. La preparación. Nada nuevo que instalar. Necesitas el entorno virtual (venv) del capítulo uno con pytest, el módulo importable desde el archivo de test (ambos en la misma carpeta, ejecutando pytest desde allí) y dos módulos de la biblioteca estándar: math y decimal. Sin fixtures, sin archivos, sin red.
3. El plan. Anota los casos antes de escribir los tests. Para cada fila, decide también la comparación, no solo el valor esperado:
| Caso | Entrada | Esperado | Comparar con | | --- | --- | --- | --- | | Caso típico, aritmética con floats | mean([0.1, 0.2, 0.3]) | 0.2 | approx (tolerancia por defecto) | | Límite: resultado cerca de cero | mean([0.1, 0.2, -0.3]) | 0.0 | approx(0.0, abs=1e-9) | | Caso extremo: entrada vacía | mean([]) | nan | math.isnan | | Contenedor de floats | shares({"tea": 1, "coffee": 2}) | {"tea": 1/3, "coffee": 2/3} | approx en el diccionario | | Dinero, caso típico | with_vat(Decimal("19.99")) | Decimal("22.99") | == exacto | | Dinero, límite de redondeo | with_vat(Decimal("0.10")) | Decimal("0.12") (0.115 redondea hacia arriba) | == exacto | | Entrada no válida | with_vat(19.99) | TypeError | pytest.raises | | Orden no prometido | countries(...) con un duplicado | ["BD", "IN"] | sorted(...) == |
Qué no probar. No pruebes que 0.1 + 0.2 sea 0.30000000000000004. Ese es el comportamiento de Python, no el tuyo, y fijarlo convierte tu test en una prueba del formato float. No pruebes el orden de un resultado que proviene de un conjunto, ni el microsegundo exacto de una marca de tiempo. Ninguno de los dos está prometido. Y no elijas una tolerancia ampliándola hasta que el test se ponga en verde. Una tolerancia es una afirmación sobre el problema («medio grado», «uno por ciento»), nunca sobre el test.
Las siguientes secciones enseñan cada herramienta de esa tabla. El ejemplo completo del final implementa el plan fila por fila.
Por qué 0.1 + 0.2 no es 0.3
Un float se almacena en binario, en una cantidad fija de espacio (64 bits). Algunas fracciones no tienen una representación binaria exacta, del mismo modo que 1/3 no tiene una representación decimal exacta: 0.3333… continúa infinitamente, y en algún momento hay que dejar de escribir. 0.1 es una de esas fracciones en binario. Python conserva el número más cercano que puede representar.
Puedes ver los valores almacenados pidiendo más dígitos de los que Python suele mostrar:
print(f"{0.1:.20f}")
print(f"{0.2:.20f}")
print(f"{0.3:.20f}")
print(f"{0.1 + 0.2:.20f}")0.10000000000000000555
0.20000000000000001110
0.29999999999999998890
0.300000000000000044410.1 se guarda una pizca por encima, y lo mismo ocurre con 0.2. Al sumarlos, se suman ambos errores. Mientras tanto, 0.3 se almacena una pizca por debajo. Los dos resultados caen en floats vecinos, y como == compara floats bit a bit, devuelve False.
Esto nos lleva a la regla fundamental de este capítulo: nunca uses == con un float que provenga de una operación aritmética. Un float que escribiste tú mismo y pasaste sin modificar está bien. Un float que fue sumado, dividido o multiplicado debe compararse con una tolerancia.
pytest.approx — igual, dentro de una tolerancia
Cambia una línea del test:
import pytest
def total(prices):
return sum(prices)
def test_total():
assert total([0.1, 0.2]) == pytest.approx(0.3). [100%]
1 passed in 0.01spytest.approx(0.3) crea un objeto que es «igual» a cualquier número lo bastante cercano a 0.3. ¿Qué tan cercano? Imprime uno y te lo dirá:
import pytest
print(pytest.approx(0.3))
print(pytest.approx(250.0))
print(pytest.approx(1_000_000.0))
print(pytest.approx(0.0))0.3 ± 3.0e-07
250.0 ± 2.5e-04
1000000.0 ± 1
0.0 ± 1.0e-12Los valores por defecto son dos tolerancias, y la mayor de las dos gana:
- relativa
rel=1e-6: una parte por millón del valor esperado. Para0.3es0.0000003; para un millón es1. - absoluta
abs=1e-12: un límite inferior fijo, para que la tolerancia nunca llegue a cero.
La tolerancia relativa es la que importa en casi todas partes, porque crece y se reduce junto con el número. Un error de 0.0000003 es insignificante junto a 0.3; junto a 1_000_000 sería absurdamente estricto.
Cerca de cero, el panorama cambia. Una parte por millón de cero es cero, por lo que solo queda el diminuto límite inferior absoluto:
import pytest
print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))False
TrueUna millonésima parece cero para una persona, pero no para approx(0.0). Si se espera que un resultado sea cero, o esté cerca de cero, proporciona tú mismo una tolerancia absoluta a approx.
Elegir tu propia tolerancia: rel= y abs=
Los valores por defecto son adecuados para «el mismo cálculo, realizado de forma ligeramente distinta». Cuando tu código redondee, mida o estime, decide qué significa «lo bastante cerca» y decláralo:
import pytest
print(100.4 == pytest.approx(100, abs=0.5))
print(100.6 == pytest.approx(100, abs=0.5))
print(100.6 == pytest.approx(100, rel=0.01))
print(pytest.approx(100, abs=0.5))
print(pytest.approx(100, rel=0.01))
print(pytest.approx(100, rel=0.01, abs=5))True
False
True
100 ± 0.5
100 ± 1
100 ± 5abs=0.5significa «a media unidad de distancia, sin importar el tamaño del número».rel=0.01significa «dentro del uno por ciento del valor esperado». Para100, eso es± 1.- Si indicas ambos, al igual que con los valores por defecto, la tolerancia mayor gana: aquí
abs=5supera al uno por ciento de 100.
Elige pensando en una frase. «La lectura de temperatura puede desviarse medio grado» es abs=0.5. «La estimación puede desviarse un uno por ciento» es rel=0.01. Y cuando el valor esperado es cero, solo abs puede ayudarte.
La manera incorrecta es partir de un test que falla y ampliar la tolerancia hasta que se ponga en verde. Una tolerancia elegida solo para hacer pasar el test dejará pasar un bug con la misma facilidad:
import pytest
print(0.95 == pytest.approx(1.0, rel=0.1))TrueUn resultado que tiene un error del cinco por ciento ahora cuenta como «igual». Si el código debe ser preciso hasta el error de redondeo, conserva el valor por defecto. Una tolerancia permisiva necesita una razón que puedas expresar con claridad, y esa razón debe ir en un comentario junto a ella.
Listas, tuplas y diccionarios
approx también acepta un contenedor de números y lo compara elemento por elemento, cada uno con su propia tolerancia:
import pytest
shares = [0.1 + 0.2, 0.7]
point = (1 / 3, 2 / 3)
rates = {"tax": 0.1 + 0.05, "tip": 0.1}
print(shares == pytest.approx([0.3, 0.7]))
print(point == pytest.approx((0.333333, 0.666667), rel=1e-5))
print(rates == pytest.approx({"tax": 0.15, "tip": 0.1}))
print(pytest.approx([0.3, 0.7]))True
True
True
approx([0.3 ± 3.0e-07, 0.7 ± 7.0e-07])Una lista se compara por posición. Un diccionario se compara por clave, de modo que el orden de las claves no importa, pero las claves en sí deben coincidir exactamente. Las longitudes también deben coincidir.
La verdadera ventaja se nota cuando una comparación falla. test_shares.py:
import pytest
def test_shares():
assert [0.1 + 0.2, 0.25, 0.6] == pytest.approx([0.3, 0.2, 0.6])F [100%]
=================================== FAILURES ===================================
_________________________________ test_shares __________________________________
def test_shares():
> assert [0.1 + 0.2, 0.25, 0.6] == pytest.approx([0.3, 0.2, 0.6])
E assert [0.3000000000...04, 0.25, 0.6] == approx([0.3 ±....6 ± 6.0e-07])
E
E comparison failed. Mismatched elements: 1 / 3:
E Max absolute difference: 0.04999999999999999
E Max relative difference: 0.19999999999999996
E Index | Obtained | Expected
E 1 | 0.25 | 0.2 ± 2.0e-07
test_shares.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_shares.py::test_shares - assert [0.3000000000...04, 0.25, 0.6] ==...
1 failed in 0.01sLéelo de abajo hacia arriba en las líneas E. Uno de los tres elementos no coincidió. La tabla indica cuál (el índice 1), qué resultado se obtuvo (0.25) y qué se esperaba, con su tolerancia. El elemento 0, el 0.30000000000000004, no aparece en la lista. Estaba lo bastante cerca, así que no es el problema. En un diccionario, la columna Index contiene la clave en su lugar.
Para qué no sirve approx
approx existe para los floats. Si le pasas otra cosa, recurre silenciosamente al == habitual:
import pytest
print("abc" == pytest.approx("abc"))
print(pytest.approx("abc"))
print(10 == pytest.approx(10))
print(10.000001 == pytest.approx(10))True
abc
True
TruePara una cadena, approx no aporta nada y no hay ninguna tolerancia que mostrar. Para un número entero, añade algo que probablemente no querías. Si se supone que count_items() debe devolver 10, entonces 10.000001 es un bug, y approx(10) lo deja pasar. Los enteros, cadenas, booleanos y None se comparan con ==.
math.isclose — la versión de la biblioteca estándar
Python tiene su propia comprobación con tolerancia: math.isclose. Sus valores por defecto son diferentes: rel_tol=1e-9 (más estricto que approx) y abs_tol=0.0 (sin ningún límite inferior):
import math
print(math.isclose(0.1 + 0.2, 0.3))
print(math.isclose(0.000001, 0.0))
print(math.isclose(0.000001, 0.0, abs_tol=1e-5))
print(math.isclose(100.6, 100, rel_tol=0.01))True
False
True
TrueCon abs_tol=0.0, nada excepto exactamente 0.0 está jamás cerca de cero. La misma lección de antes, pero aún más evidente.
En el código de la aplicación, math.isclose es la herramienta adecuada, porque pytest no está instalado en producción. En un test, es preferible approx, y el motivo es el informe. test_close.py:
import math
import pytest
def test_with_isclose():
assert math.isclose(0.3001, 0.3)
def test_with_approx():
assert 0.3001 == pytest.approx(0.3)FF [100%]
=================================== FAILURES ===================================
______________________________ test_with_isclose _______________________________
def test_with_isclose():
> assert math.isclose(0.3001, 0.3)
E assert False
E + where False = <built-in function isclose>(0.3001, 0.3)
E + where <built-in function isclose> = math.isclose
test_close.py:7: AssertionError
_______________________________ test_with_approx _______________________________
def test_with_approx():
> assert 0.3001 == pytest.approx(0.3)
E assert 0.3001 == 0.3 ± 3.0e-07
E
E comparison failed
E Obtained: 0.3001
E Expected: 0.3 ± 3.0e-07
test_close.py:11: AssertionError
=========================== short test summary info ============================
FAILED test_close.py::test_with_isclose - assert False
FAILED test_close.py::test_with_approx - assert 0.3001 == 0.3 ± 3.0e-07
2 failed in 0.01sassert False te indica que los números difieren. approx también te dice por cuánto y qué tolerancia estaba permitida. Además, math.isclose no ofrece ninguna solución para listas o diccionarios.
El dinero no es un problema de tolerancia
Cuando un test sobre precios falla con 0.30000000000000004, recurrir a approx resulta tentador. Observa lo que esa tolerancia implica para una factura grande:
import pytest
expected = 1_000_000.00
charged = 1_000_000.99
print(charged == pytest.approx(expected))TrueNoventa y nueve centavos de diferencia y el test pasa. Una parte por millón de un millón es una unidad entera. Con el dinero, «lo bastante cerca» no es una respuesta válida. A un cliente al que se le cobra un centavo de más se le ha cobrado una cantidad incorrecta.
La solución no está en el test. Para empezar, el código no debería almacenar dinero en un float. decimal.Decimal de Python almacena dígitos decimales exactamente tal como están escritos:
from decimal import Decimal
print(Decimal("0.10") + Decimal("0.20"))
print(Decimal("0.10") + Decimal("0.20") == Decimal("0.30"))
print(Decimal("1000000.99") == Decimal("1000000.00"))
print(Decimal(0.1))0.30
True
False
0.1000000000000000055511151231257827021181583404541015625Con Decimal, == vuelve a ser exacto, que es justo lo que necesita un test monetario. La última línea es la trampa. Decimal(0.1) se construye a partir de un float, por lo que copia fielmente el error del float. Construye siempre un Decimal a partir de una cadena.
El redondeo a centavos es explícito, y tú eliges la regla:
from decimal import ROUND_HALF_UP, Decimal
price = Decimal("19.99")
vat = price * Decimal("0.15")
print(vat)
print(vat.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))2.9985
3.00Así que la distinción es simple. Las mediciones (longitudes, promedios, proporciones, lecturas de sensores) son floats, que se prueban con approx. Las cantidades de dinero son Decimal, que se prueban con ==.
NaN no es igual a sí mismo
float("nan") significa «no es un número» (not a number), el resultado de algo como 0 * inf o una medición faltante. Por definición no es igual a nada, incluido él mismo:
import math
import pytest
missing = float("nan")
print(missing == missing)
print(missing == pytest.approx(float("nan")))
print(missing == pytest.approx(float("nan"), nan_ok=True))
print(math.isnan(missing))False
False
True
Trueapprox sigue esa regla a menos que pases nan_ok=True, lo que indica «aquí se espera un NaN, y coincide con un NaN». Para un solo valor, assert math.isnan(result) se lee con mayor claridad. nan_ok=True se gana su lugar en contenedores, donde un NaN convive con números normales: [1.5, nan, 2.0] == pytest.approx([1.5, nan, 2.0], nan_ok=True) es True.
Orden que no prometiste y tiempo que no se queda quieto
Los floats no son los únicos valores que resultan «casi» correctos. Dos casos más requieren el mismo razonamiento: decide qué está prometido y prueba únicamente eso.
El orden. Esta función recopila las etiquetas de algunas publicaciones mediante un set:
tags.py:
def unique_tags(posts):
tags = set()
for post in posts:
tags.update(post["tags"])
return list(tags)test_tags.py:
from tags import unique_tags
POSTS = [
{"tags": ["python", "testing"]},
{"tags": ["testing", "floats"]},
]
def test_order_is_a_guess():
assert unique_tags(POSTS) == ["floats", "python", "testing"]
def test_sorted():
assert sorted(unique_tags(POSTS)) == ["floats", "python", "testing"]
def test_as_a_set():
assert set(unique_tags(POSTS)) == {"floats", "python", "testing"}El orden de las cadenas en un conjunto cambia de una ejecución a otra, porque el hashing de cadenas se aleatoriza por cada proceso. Fijar la semilla hace que esto sea visible. PYTHONHASHSEED=1 pytest -q:
F.. [100%]
=================================== FAILURES ===================================
____________________________ test_order_is_a_guess _____________________________
def test_order_is_a_guess():
> assert unique_tags(POSTS) == ["floats", "python", "testing"]
E AssertionError: assert ['python', 't...ng', 'floats'] == ['floats', 'p...n', 'testing']
E
E At index 0 diff: 'python' != 'floats'
E Use -v to get more diff
test_tags.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_tags.py::test_order_is_a_guess - AssertionError: assert ['python'...
1 failed, 2 passed in 0.01sEjecútalo de nuevo con PYTHONHASHSEED=2 y los tres tests pasan. Mismo código, mismo test, resultado diferente. Eso es un test inestable (flaky), y es peor que uno que siempre falla. Si la función no promete un orden, no lo pruebes. Compara con sorted(...) o compara como un set. Una advertencia: un set también oculta los duplicados. Si los duplicados importan, usa sorted o collections.Counter, que los cuenta.
El tiempo. Una marca de tiempo (timestamp) tomada dentro de tu código nunca puede ser igual a una tomada en el test, porque transcurre tiempo entre ambas llamadas. Prueba una ventana de tiempo en su lugar:
orders.py:
from datetime import datetime, timezone
def make_order(item):
return {"item": item, "created_at": datetime.now(timezone.utc)}test_orders.py:
from datetime import datetime, timezone
from orders import make_order
def test_created_between_before_and_after():
before = datetime.now(timezone.utc)
order = make_order("pen")
after = datetime.now(timezone.utc)
assert before <= order["created_at"] <= after. [100%]
1 passed in 0.01sEl test es exacto y no necesita ninguna tolerancia: la marca de tiempo debe caer entre dos momentos que registraste. Si prefieres una tolerancia, approx acepta un datetime cuando abs= es un timedelta: order["created_at"] == pytest.approx(now, abs=timedelta(seconds=1)). Cuando un test tiene que fijar el tiempo a un valor exacto, la respuesta es controlar el reloj, y eso vendrá más adelante en el curso con monkeypatch.
Un ejemplo completo
stats.py:
import math
from decimal import ROUND_HALF_UP, Decimal
CENT = Decimal("0.01")
def mean(values):
if not values:
return math.nan
return sum(values) / len(values)
def shares(counts):
total = sum(counts.values())
return {name: count / total for name, count in counts.items()}
def with_vat(price, rate=Decimal("0.15")):
return (price * (1 + rate)).quantize(CENT, rounding=ROUND_HALF_UP)
def countries(orders):
return list({order["country"] for order in orders})test_stats.py:
import math
from decimal import Decimal
import pytest
from stats import countries, mean, shares, with_vat
def test_mean_of_measurements():
# 0.6000000000000001 / 3 -- close, never exact
assert mean([0.1, 0.2, 0.3]) == pytest.approx(0.2)
def test_mean_near_zero():
# (0.1 + 0.2 - 0.3) / 3 is about 1.9e-17, not 0.0
assert mean([0.1, 0.2, -0.3]) == pytest.approx(0.0, abs=1e-9)
def test_mean_of_nothing_is_nan():
assert math.isnan(mean([]))
def test_shares_are_fractions():
result = shares({"tea": 1, "coffee": 2})
assert result == pytest.approx({"tea": 1 / 3, "coffee": 2 / 3})
assert sum(result.values()) == pytest.approx(1.0)
def test_shares_to_two_places():
result = shares({"tea": 1, "coffee": 2})
assert result == pytest.approx({"tea": 0.33, "coffee": 0.67}, abs=0.005)
def test_vat_is_exact_to_the_cent():
assert with_vat(Decimal("19.99")) == Decimal("22.99")
assert with_vat(Decimal("0.10")) == Decimal("0.12")
def test_vat_refuses_a_float():
with pytest.raises(TypeError):
with_vat(19.99)
def test_countries_ignores_order_and_duplicates():
orders = [{"country": "BD"}, {"country": "IN"}, {"country": "BD"}]
assert sorted(countries(orders)) == ["BD", "IN"]Luego pytest -v:
============================= test session starts ==============================
collecting ... collected 8 items
test_stats.py::test_mean_of_measurements PASSED [ 12%]
test_stats.py::test_mean_near_zero PASSED [ 25%]
test_stats.py::test_mean_of_nothing_is_nan PASSED [ 37%]
test_stats.py::test_shares_are_fractions PASSED [ 50%]
test_stats.py::test_shares_to_two_places PASSED [ 62%]
test_stats.py::test_vat_is_exact_to_the_cent PASSED [ 75%]
test_stats.py::test_vat_refuses_a_float PASSED [ 87%]
test_stats.py::test_countries_ignores_order_and_duplicates PASSED [100%]
============================== 8 passed in 0.01s ===============================Ocho tests, uno por cada fila del plan en «Antes de escribir el test». Cada uno elige su comparación a propósito:
meanes aritmética con floats, por lo que usaapproxcon sus valores por defecto.- Una media que debería ser cero resulta en
1.9e-17. La tolerancia relativa no sirve de nada en cero, por lo que el test proporciona unabs=que puede justificar: cualquier valor por debajo de una milmillonésima es ruido de redondeo aquí. - Una media vacía es NaN por diseño, por lo que usa
math.isnan, ya que==nunca podría pasar. sharesdevuelve un diccionario de floats, por lo que usaapproxen todo el diccionario. El segundo test declara su tolerancia (abs=0.005, media centésima) porque compara contra números redondeados a dos decimales.with_vates dinero, por lo que usaDecimaly==exacto.0.10 × 1.15 = 0.115es el caso que comprueba la regla de redondeo:ROUND_HALF_UPlo convierte en0.12.- Un precio en float se rechaza en lugar de convertirse silenciosamente. Ese rechazo forma parte del contrato, por lo que tiene su propio test con
pytest.raises, como en el capítulo anterior. countriesno promete ningún orden, por lo que el test ordena antes de comparar.
Cuando algo falla
assert 0.30000000000000004 == 0.3 Un float calculado comparado con ==. Envuelve el valor esperado: == pytest.approx(0.3). Si el valor es dinero, corrige el código para que use Decimal en su lugar.
AssertionError: approx() is not supported in a boolean context. Escribiste assert pytest.approx(x) sin ninguna comparación. approx solo tiene significado en un lado de ==, tal como sugiere el mensaje: assert a == approx(b).
TypeError: '>' not supported between instances of 'ApproxScalar' and 'float' approx solo admite == y !=. Para «al menos» o «como máximo», compara los números directamente: assert result > 0.2.
Impossible to compare lists with different sizes. La lista y la lista de approx tienen longitudes diferentes. Una tolerancia se aplica a los valores, nunca a cuántos elementos hay. El informe muestra ambas longitudes en la siguiente línea.
assert nan == nan ± ??? Un NaN en ambos lados, y NaN nunca es igual a nada. Usa math.isnan(result) o pasa nan_ok=True si el valor esperado es un NaN.
*`TypeError: unsupported operand type(s) for : 'decimal.Decimal' and 'float'** Decimal se niega a mezclarse con float, a propósito, para que ningún número inexacto pueda colarse. Escribe el otro operando también como Decimal: Decimal("1.15")`.
Decimal('0.1000000000000000055511151231257827021181583404541015625') en un informe Se creó un Decimal a partir de un float, Decimal(0.1), y heredó su error. Constrúyelo a partir de una cadena: Decimal("0.1").
Un test que pasa en una ejecución y falla en la siguiente Busca un orden que provenga de un set, o una marca de tiempo (timestamp) comparada con ==. Compara con sorted(...) o un set, y prueba el tiempo como una ventana.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
¿Es una millonésima «suficientemente cercana» a cero? ¿Qué imprimen las dos líneas?
import pytest
print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))- AFalse True
- BTrue True
- CFalse False
- DTrue False
Este test pasa. ¿Qué problema tiene?
import pytest
def test_invoice_total():
expected = 1_000_000.00
charged = 1_000_000.99
assert charged == pytest.approx(expected)- ANo tiene nada de malo — `approx` es la forma correcta de comparar dinero
- B`approx` es tan estricto que el test fallará de vez en cuando
- C`approx` no funciona con números grandes, por lo que el resultado no tiene sentido
- DPasa aunque el cobro tiene 99 centavos de más — el dinero necesita Decimal y ==
La función no promete ningún orden y devuelve cada etiqueta una sola vez. ¿Qué assert es el correcto?
def unique_tags(posts):
tags = set()
for post in posts:
tags.update(post["tags"])
return list(tags)- Aassert unique_tags(POSTS) == ["floats", "python"]
- Bassert set(unique_tags(POSTS)) is {"floats", "python"}
- Cassert sorted(unique_tags(POSTS)) == ["floats", "python"]
- Dassert unique_tags(POSTS) == pytest.approx(["floats", "python"])
Para responder necesitas una cuenta
Inicia sesión para comprobar tus respuestas
Las preguntas están arriba, y resolverlas mentalmente es lo que de verdad importa. Inicia sesión para ver las respuestas, las explicaciones y las pistas de tres niveles.
Tu turno
Escribe geometry.py con tres funciones:
circle_area(r), que devuelvemath.pi * r * rsplit_bill(total, people), dondetotales unDecimal, que devuelve una lista de partes enDecimalal centavo que suman exactamentetotal(las primeras personas pagan un centavo más cuando no se divide de forma exacta)normalise(values), que divide cada valor entre su suma y devuelve la lista
Luego escribe test_geometry.py:
- Prueba
circle_area(1)ycircle_area(0.1)conapprox. Luego pruebacircle_area(0)contraapprox(0.0)y explícate a ti mismo por qué ese caso no necesitaabs=. - Prueba
split_bill(Decimal("10.00"), 3)con==exacto, y comprueba quesum(...)del resultado sea igual aDecimal("10.00"). - Prueba que
normalise([1, 1, 1])sea aproximadamente[1/3, 1/3, 1/3], y que su suma seaapprox(1.0).
Luego realiza un experimento. Escribe una versión con floats del test de división para una cuenta de 1_000_000.00 entre tres personas, compara las partes con approx y haz que una parte esperada tenga un error de un centavo. ¿Lo nota el test? La respuesta es el motivo por el cual el dinero se maneja en Decimal.
Solución
geometry.py:
import math
from decimal import Decimal
def circle_area(r):
return math.pi * r * r
def split_bill(total, people):
cents = int(total * 100)
base, extra = divmod(cents, people)
return [Decimal(base + (1 if i < extra else 0)) / 100 for i in range(people)]
def normalise(values):
total = sum(values)
return [value / total for value in values]test_geometry.py:
import math
from decimal import Decimal
import pytest
from geometry import circle_area, normalise, split_bill
def test_area_of_unit_circle():
assert circle_area(1) == pytest.approx(math.pi)
def test_area_of_small_circle():
# 0.1 * 0.1 is not exactly 0.01, so the result needs a tolerance
assert circle_area(0.1) == pytest.approx(math.pi / 100)
def test_area_of_point_is_zero():
# 0 * anything is exactly 0.0, so the tiny default abs floor is enough
assert circle_area(0) == pytest.approx(0.0)
def test_split_is_exact_to_the_cent():
assert split_bill(Decimal("10.00"), 3) == [
Decimal("3.34"),
Decimal("3.33"),
Decimal("3.33"),
]
def test_split_adds_up_to_the_bill():
assert sum(split_bill(Decimal("10.00"), 3)) == Decimal("10.00")
def test_normalise_gives_fractions():
assert normalise([1, 1, 1]) == pytest.approx([1 / 3, 1 / 3, 1 / 3])
def test_normalised_values_sum_to_one():
assert sum(normalise([1, 1, 1])) == pytest.approx(1.0)
def test_float_split_misses_a_cent():
# The experiment: a share that is one cent wrong still "passes" with floats
wrong_share = 333_333.33 + 0.01
assert wrong_share == pytest.approx(333_333.33)Luego pytest -v:
============================= test session starts ==============================
collecting ... collected 8 items
test_geometry.py::test_area_of_unit_circle PASSED [ 12%]
test_geometry.py::test_area_of_small_circle PASSED [ 25%]
test_geometry.py::test_area_of_point_is_zero PASSED [ 37%]
test_geometry.py::test_split_is_exact_to_the_cent PASSED [ 50%]
test_geometry.py::test_split_adds_up_to_the_bill PASSED [ 62%]
test_geometry.py::test_normalise_gives_fractions PASSED [ 75%]
test_geometry.py::test_normalised_values_sum_to_one PASSED [ 87%]
test_geometry.py::test_float_split_misses_a_cent PASSED [100%]
============================== 8 passed in 0.01s ===============================Por qué cada decisión:
- Las áreas esperadas se escriben como
math.piymath.pi / 100, no como decimales largos copiados de una ejecución. Un valor esperado copiado de la propia salida del código no prueba nada: coincidiría también con un bug. circle_area(0)no necesitaabs=porquemath.pi * 0 * 0es exactamente0.0. No hay ningún error de redondeo que absorber, y el límite inferior por defecto de1e-12es más que suficiente.abs=sirve para resultados que deberían ser cero pero llegan como un número diminuto distinto de cero.split_billopera en centavos enteros. Convierte el total en un número entero de centavos, divide condivmody reparte el resto centavo a centavo. Los enteros son exactos, por lo que las partes suman el total por construcción, y el test puede usar un==simple en valoresDecimal.- Dos tests independientes para la cuenta, uno para las partes y otro para la suma. Si la regla de división cambia (por ejemplo, la última persona paga el centavo sobrante), el primer test falla y el segundo sigue pasando, lo que te dice con precisión qué promesa cambió.
normalisedevuelve floats, por lo que sus dos tests usanapprox, tanto en la lista completa como en la suma.- El último test es el experimento, conservado como test para que se documente a sí mismo. Una parte un centavo más grande todavía pasa
approxa esta escala, porque una parte por millón de 333,333.33 es aproximadamente 0.33. El test pasa, y que pase es precisamente el bug: por esosplit_billusaDecimaly==.
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Inicia sesión para hacer el quiz