Les floats et pytest.approx — tester « suffisamment proche »
Pourquoi 0.1 + 0.2 == 0.3 est faux, ce que pytest.approx tolère par défaut, quand utiliser rel et abs, et pourquoi l'argent exige Decimal plutôt qu'approx. Avec NaN, résultats sans ordre garanti et horodatages.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Demandez à Python quelque chose auquel un enfant saurait répondre :
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)0.30000000000000004
FalseCe n'est pas un bug de votre machine, et ce n'est pas un bug de Python. C'est ainsi que presque tous les langages de programmation stockent les fractions décimales. Et cela s'invite directement dans vos tests.
test_total.py :
def total(prices):
return sum(prices)
def test_total():
assert total([0.1, 0.2]) == 0.3Puis 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 fonction est correcte. Le test est erroné. Il exige une exactitude que les nombres à virgule flottante ne peuvent pas offrir. Ce chapitre explique comment poser la bonne question à la place : est-ce suffisamment proche ? Il aborde également les cas où « suffisamment proche » n'est pas la bonne question, l'argent étant le principal d'entre eux.
À la fin de ce chapitre, vous saurez
- Expliquer en deux phrases pourquoi
0.1 + 0.2 == 0.3vautFalse - Comparer des floats dans un test avec
pytest.approx, et lire sa tolérance par défaut - Définir votre propre tolérance avec
rel=etabs=, et savoir de laquelle vous avez besoin près de zéro - Utiliser
approxsur des listes, des tuples et des dictionnaires, et lire le rapport généré lorsqu'ils diffèrent - Choisir entre
pytest.approx,math.iscloseetDecimal - Tester NaN, des résultats dont l'ordre n'est pas garanti, et des horodatages
Prérequis : tester les exceptions.
Avant d'écrire le test
Avec les nombres, la décision la plus importante précède le moindre code : pour chaque résultat, quel type d'égalité le code a-t-il promis ? Faites le mauvais choix et le test sera soit instable (trop strict), soit aveugle (trop permissif). Réglez d'abord trois éléments.
1. Le contrat. Prenez le petit module stats.py qui conclut ce chapitre. En termes simples, il promet :
mean(values)renvoie la moyenne d'une liste de floats, etnanpour une liste vide.shares(counts)transforme des effectifs en fractions dont la somme vaut un.with_vat(price)ajoute 15 % de TVA à un prixDecimal, arrondi au demi supérieur le plus proche (half-up) au centime près. Un prix sous forme defloatest refusé avec uneTypeError.countries(orders)renvoie chaque pays une seule fois. Il ne garantit aucun ordre.
Chaque ligne vous indique déjà comment comparer. « Moyenne de floats » signifie une tolérance. « Arrondi au centime » signifie exact. « Refusé » signifie pytest.raises. « Aucun ordre garanti » signifie trier ou utiliser un ensemble avant de comparer.
2. La mise en place. Rien de nouveau à installer. Vous avez besoin de l'environnement virtuel du premier chapitre contenant pytest, du module importable depuis le fichier de test (tous deux dans le même dossier, exécutez pytest depuis là), et de deux modules de la bibliothèque standard, math et decimal. Pas de fixtures, pas de fichiers, pas de réseau.
3. Le plan. Couchez les cas par écrit avant d'écrire les tests. Pour chaque ligne, décidez également de la comparaison, pas seulement de la valeur attendue :
| Cas | Entrée | Valeur attendue | Comparer avec | | --- | --- | --- | --- | | Cas nominal, arithmétique de floats | mean([0.1, 0.2, 0.3]) | 0.2 | approx (tolérance par défaut) | | Limite : résultat proche de zéro | mean([0.1, 0.2, -0.3]) | 0.0 | approx(0.0, abs=1e-9) | | Cas limite : entrée vide | mean([]) | nan | math.isnan | | Conteneur de floats | shares({"tea": 1, "coffee": 2}) | {"tea": 1/3, "coffee": 2/3} | approx sur le dictionnaire | | Argent, cas nominal | with_vat(Decimal("19.99")) | Decimal("22.99") | == exact | | Argent, cas limite d'arrondi | with_vat(Decimal("0.10")) | Decimal("0.12") (0.115 s'arrondit au supérieur) | == exact | | Entrée invalide | with_vat(19.99) | TypeError | pytest.raises | | Ordre non garanti | countries(...) avec un doublon | ["BD", "IN"] | sorted(...) == |
Ce qu'il ne faut pas tester. Ne testez pas que 0.1 + 0.2 vaut 0.30000000000000004. C'est le comportement de Python, pas le vôtre, et le figer transforme votre test en un test du format des floats. Ne testez pas l'ordre d'un résultat issu d'un ensemble, ni la microseconde exacte d'un horodatage. Aucun des deux n'est garanti. Et ne choisissez pas une tolérance en l'élargissant jusqu'à ce que le test devienne vert. Une tolérance est une affirmation relative au problème (« un demi-degré », « un pour cent »), jamais relative au test.
Les sections ci-dessous présentent chaque outil de ce tableau. L'exemple complet à la fin met en œuvre ce plan ligne par ligne.
Pourquoi 0.1 + 0.2 ne vaut pas 0.3
Un float est stocké en binaire, dans un espace fixe (64 bits). Certaines fractions n'ont pas de forme binaire exacte, tout comme 1/3 n'a pas de forme décimale exacte : 0.3333… continue à l'infini, et à un moment donné, il faut s'arrêter d'écrire. 0.1 fait partie de ces fractions en binaire. Python conserve le nombre le plus proche qu'il peut représenter.
Vous pouvez visualiser les valeurs stockées en demandant plus de chiffres que ce que Python affiche habituellement :
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 est stocké un tout petit peu trop grand, tout comme 0.2. Les additionner additionne les deux erreurs. De son côté, 0.3 est stocké un tout petit peu trop petit. Les deux résultats atterrissent sur des floats voisins, et == comparant les floats bit à bit, il répond False.
Cela nous amène à la règle unique de ce chapitre : n'utilisez jamais == sur un float issu d'un calcul. Un float saisi par vous-même et transmis sans modification ne pose aucun problème. Un float ayant été additionné, divisé ou multiplié doit être comparé avec une tolérance.
pytest.approx — égal, à une tolérance près
Modifiez une seule ligne du 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) construit un objet qui est « égal » à n'importe quel nombre suffisamment proche de 0.3. À quel point ? Affichez-en un et il vous l'indique :
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-12Les valeurs par défaut comportent deux tolérances, et la plus grande des deux l'emporte :
- relative
rel=1e-6: un millionième de la valeur attendue. Pour0.3, cela correspond à0.0000003; pour un million, cela vaut1. - absolue
abs=1e-12: un plancher fixe, de sorte que la tolérance n'atteint jamais tout à fait zéro.
La tolérance relative est celle qui importe presque partout, car elle s'adapte à la taille du nombre. Une erreur de 0.0000003 est négligeable à côté de 0.3 ; à côté de 1_000_000, elle serait absurdement stricte.
Près de zéro, la situation change. Un millionième de zéro vaut zéro, il ne reste donc que le minuscule plancher absolu :
import pytest
print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))False
TrueUn millionième ressemble à zéro pour un être humain, mais pas pour approx(0.0). Si un résultat est censé valoir zéro, ou s'en approcher, fournissez vous-même une tolérance absolue à approx.
Choisir votre propre tolérance : rel= et abs=
Les valeurs par défaut conviennent pour « le même calcul, effectué légèrement différemment ». Quand votre code arrondit, mesure ou estime, déterminez ce que signifie « suffisamment proche » et explicitez-le :
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.5signifie « à un demi-point près, quelle que soit la taille du nombre ».rel=0.01signifie « à un pour cent près de la valeur attendue ». Pour100, cela donne± 1.- Fournissez les deux et, comme avec les réglages par défaut, la plus grande tolérance l'emporte : ici,
abs=5dépasse un pour cent de 100.
Faites votre choix en pensant à une phrase concrète. « Le relevé de température peut varier d'un demi-degré » correspond à abs=0.5. « L'estimation peut dévier d'un pour cent » correspond à rel=0.01. Et quand la valeur attendue est zéro, seul abs peut vous aider.
La mauvaise approche consiste à partir d'un test qui échoue et à élargir la tolérance jusqu'à ce qu'il passe au vert. Une tolérance choisie dans le seul but de faire réussir le test laissera tout aussi facilement passer un bug :
import pytest
print(0.95 == pytest.approx(1.0, rel=0.1))TrueUn résultat faux de cinq pour cent compte désormais comme « égal ». Si le code doit être précis aux erreurs d'arrondi près, conservez la valeur par défaut. Une tolérance plus souple exige une raison que vous pouvez énoncer clairement, et cette raison doit figurer dans un commentaire à côté d'elle.
Listes, tuples et dictionnaires
approx accepte également un conteneur de nombres et le compare élément par élément, chacun avec sa propre tolérance :
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])Une liste se compare par position. Un dictionnaire se compare par clé, de sorte que l'ordre des clés n'a pas d'importance, mais les clés elles-mêmes doivent correspondre exactement. Les longueurs doivent également coïncider.
Le véritable gain apparaît lorsqu'une comparaison échoue. 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.01sLisez-le en remontant depuis le bas des lignes E. Un élément sur trois ne correspondait pas. Le tableau indique lequel (index 1), ce qui a été obtenu (0.25) et ce qui était attendu, avec sa tolérance. L'élément 0, le 0.30000000000000004, n'apparaît pas. Il était suffisamment proche, il n'est donc pas la cause du problème. Dans le cas d'un dictionnaire, la colonne Index contient la clé à la place.
Ce pour quoi approx n'est pas fait
approx existe pour les floats. Donnez-lui autre chose et il se rabat discrètement sur un simple == :
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
TruePour une chaîne de caractères, approx n'apporte rien, et il n'y a aucune tolérance à afficher. Pour un nombre entier, il ajoute quelque chose dont vous ne voulez probablement pas. Si count_items() est censé renvoyer 10, alors 10.000001 est un bug, et approx(10) le laisse passer. Les entiers, les chaînes, les booléens et None se comparent avec ==.
math.isclose — la version de la bibliothèque standard
Python possède sa propre vérification de tolérance : math.isclose. Ses valeurs par défaut sont différentes : rel_tol=1e-9 (plus strict qu'approx) et abs_tol=0.0 (aucun plancher) :
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
TrueAvec abs_tol=0.0, rien d'autre qu'exactement 0.0 n'est jamais proche de zéro. La même leçon que précédemment, mais encore plus nette.
Dans le code applicatif, math.isclose est le bon outil, car pytest n'est pas installé en production. Dans un test, préférez approx, et la raison en est le rapport. 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 vous indique que les nombres diffèrent. approx vous précise également de combien, et quelle tolérance était autorisée. De plus, math.isclose ne propose aucune solution pour les listes ou les dictionnaires.
L'argent n'est pas un problème de tolérance
Lorsqu'un test sur des prix échoue avec 0.30000000000000004, recourir à approx est tentant. Regardez ce que signifie cette tolérance pour une facture importante :
import pytest
expected = 1_000_000.00
charged = 1_000_000.99
print(charged == pytest.approx(expected))TrueQuatre-vingt-dix-neuf centimes de différence, et le test réussit. Un millionième d'un million représente une unité entière. Pour l'argent, « suffisamment proche » n'est pas une réponse acceptable. Un client à qui l'on facture un centime de trop a été facturé d'un mauvais montant.
La solution ne réside pas dans le test. Dès le départ, le code ne devrait pas stocker de l'argent dans un float. La classe decimal.Decimal de Python stocke les chiffres décimaux exactement comme ils sont écrits :
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.1000000000000000055511151231257827021181583404541015625Avec Decimal, == redevient exact, ce qui est précisément ce dont un test financier a besoin. La dernière ligne constitue le piège : Decimal(0.1) est construit à partir d'un float, il copie donc fidèlement l'erreur du float. Construisez toujours un Decimal à partir d'une chaîne de caractères.
L'arrondi au centime est explicite, et vous choisissez la règle :
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.00La distinction est donc simple. Les mesures (longueurs, moyennes, ratios, relevés de capteurs) sont des floats, testés avec approx. Les montants d'argent sont des Decimal, testés avec ==.
NaN n'est pas égal à lui-même
float("nan") signifie « not a number » (pas un nombre), le résultat d'une opération telle que 0 * inf ou d'une mesure manquante. Par définition, il n'est égal à rien, pas même à lui-même :
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 suit cette règle sauf si vous passez nan_ok=True, qui indique « un NaN est attendu ici, et correspond à un NaN ». Pour une valeur isolée, assert math.isnan(result) est plus clair. nan_ok=True prend tout son sens sur les conteneurs, où un NaN se trouve parmi des nombres ordinaires : [1.5, nan, 2.0] == pytest.approx([1.5, nan, 2.0], nan_ok=True) donne True.
Un ordre que vous n'avez pas promis et un temps qui ne s'arrête jamais
Les floats ne sont pas les seules valeurs qui ressortent « presque » exactes. Deux autres cas nécessitent la même réflexion : déterminez ce qui est promis, et testez uniquement cela.
L'ordre. Cette fonction rassemble les étiquettes de plusieurs articles à travers 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"}L'ordre des chaînes dans un ensemble change d'une exécution à l'autre, car le hachage des chaînes de caractères est aléatoire pour chaque processus. Fixer la graine rend cela 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.01sExécutez-le à nouveau avec PYTHONHASHSEED=2 et les trois tests réussissent. Même code, même test, résultat différent. C'est un test instable (flaky), et c'est pire qu'un test qui échoue franchement. Si la fonction ne promet aucun ordre, n'en testez aucun. Comparez avec sorted(...), ou comparez sous forme de set. Un avertissement toutefois : un set masque également les doublons. Si les doublons comptent, utilisez sorted, ou collections.Counter, qui les dénombre.
Le temps. Un horodatage capturé dans votre code ne peut jamais être égal à celui capturé dans le test, car du temps s'écoule entre les deux appels. Testez plutôt un intervalle :
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.01sLe test est exact et ne requiert aucune tolérance : l'horodatage doit se situer entre deux instants que vous avez enregistrés. Si vous préférez une tolérance, approx accepte un datetime quand abs= est un timedelta : order["created_at"] == pytest.approx(now, abs=timedelta(seconds=1)). Quand un test doit figer le temps sur une valeur précise, la solution consiste à contrôler l'horloge, ce qui sera abordé plus loin dans le cours avec monkeypatch.
Un exemple complet
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"]Puis 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 ===============================Huit tests, un pour chaque ligne du plan dans « Avant d'écrire le test ». Chacun choisit délibérément sa méthode de comparaison :
meaneffectue un calcul arithmétique sur des floats, doncapproxavec ses paramètres par défaut.- Une moyenne qui devrait être égale à zéro donne
1.9e-17. La tolérance relative est inutile à zéro, le test fournit donc unabs=qu'il peut justifier : ici, tout ce qui se trouve en dessous d'un milliardième est un bruit d'arrondi. - Une moyenne vide donne NaN par conception, donc
math.isnan, car==ne pourrait jamais réussir. sharesrenvoie un dictionnaire de floats, doncapproxsur l'ensemble du dictionnaire. Le second test précise sa tolérance (abs=0.005, un demi-centième) car il compare à des nombres arrondis à deux décimales.with_vatmanipule de l'argent, doncDecimalet un==exact.0.10 × 1.15 = 0.115est le cas qui valide la règle d'arrondi :ROUND_HALF_UPen fait0.12.- Un prix sous forme de float est refusé plutôt que converti silencieusement. Ce refus fait partie du contrat, il fait donc l'objet de son propre test avec
pytest.raises, comme dans le chapitre précédent. countriesne promet aucun ordre, le test effectue donc un tri avant de comparer.
En cas de problème
assert 0.30000000000000004 == 0.3 Un float calculé comparé avec ==. Enveloppez la valeur attendue : == pytest.approx(0.3). S'il s'agit d'argent, corrigez le code pour utiliser Decimal à la place.
AssertionError: approx() is not supported in a boolean context. Vous avez écrit assert pytest.approx(x) sans aucune comparaison. approx n'a de sens que d'un côté de ==, comme le message le suggère : assert a == approx(b).
TypeError: '>' not supported between instances of 'ApproxScalar' and 'float' approx ne prend en charge que == et !=. Pour « au moins » ou « au plus », comparez des nombres simples : assert result > 0.2.
Impossible to compare lists with different sizes. La liste et la liste approx ont des longueurs différentes. Une tolérance s'applique aux valeurs, jamais à leur nombre. Le rapport indique les deux longueurs sur la ligne suivante.
assert nan == nan ± ??? Un NaN des deux côtés, et NaN n'est jamais égal à rien. Utilisez math.isnan(result), ou passez nan_ok=True si un NaN est la valeur attendue.
*`TypeError: unsupported operand type(s) for : 'decimal.Decimal' and 'float'** Decimal refuse de se mélanger avec float, volontairement, afin d'éviter qu'un nombre inexact ne s'infiltre. Écrivez l'autre opérande sous forme de Decimal également : Decimal("1.15")`.
Decimal('0.1000000000000000055511151231257827021181583404541015625') dans un rapport Un Decimal a été créé à partir d'un float, Decimal(0.1), et a hérité de son erreur. Construisez-le à partir d'une chaîne de caractères : Decimal("0.1").
Un test qui réussit lors d'une exécution et échoue à la suivante Cherchez un ordre provenant d'un set, ou un horodatage comparé avec ==. Comparez avec sorted(...) ou un set, et testez le temps sous la forme d'un intervalle.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Un millionième est-il « suffisamment proche » de zéro ? Qu'affichent les deux lignes ?
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
Ce test réussit. Quel est le problème ?
import pytest
def test_invoice_total():
expected = 1_000_000.00
charged = 1_000_000.99
assert charged == pytest.approx(expected)- ARien ne cloche — `approx` est la bonne façon de comparer de l'argent
- B`approx` est tellement strict que le test échouera de temps en temps
- C`approx` ne fonctionne pas avec les grands nombres, le résultat n'a donc aucun sens
- DIl réussit alors que la facture a 99 centimes de trop — l'argent nécessite `Decimal` et `==`
La fonction ne garantit aucun ordre et renvoie chaque étiquette une seule fois. Quel assert est correct ?
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"])
Répondre nécessite un compte
Connectez-vous pour vérifier vos réponses
Les questions sont ci-dessus, et les résoudre de tête est ce qui compte vraiment. Connectez-vous pour voir les réponses, les explications et les indices à trois niveaux.
À vous de jouer
Écrivez geometry.py avec trois fonctions :
circle_area(r), qui renvoiemath.pi * r * rsplit_bill(total, people), oùtotalest unDecimal, renvoyant une liste de parts enDecimalau centime près qui donnent exactementtotalune fois additionnées (les premières personnes paient un centime de plus si la division ne tombe pas juste)normalise(values), qui divise chaque valeur par leur somme et renvoie la liste
Écrivez ensuite test_geometry.py :
- Testez
circle_area(1)etcircle_area(0.1)avecapprox. Testez ensuitecircle_area(0)par rapport àapprox(0.0), et expliquez-vous pourquoi celui-ci ne nécessite pasabs=. - Testez
split_bill(Decimal("10.00"), 3)avec un==exact, et vérifiez quesum(...)du résultat est égal àDecimal("10.00"). - Testez que
normalise([1, 1, 1])vaut approximativement[1/3, 1/3, 1/3], et que sa somme vautapprox(1.0).
Faites ensuite une expérience. Écrivez une version utilisant des floats du test de partage pour une facture de 1_000_000.00 divisée entre trois personnes, comparez les parts avec approx, et faussez l'une des parts attendues d'un centime. Le test s'en aperçoit-il ? La réponse explique pourquoi l'argent est représenté avec Decimal.
Solution
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)Puis 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 ===============================Pourquoi chaque décision :
- Les aires attendues sont écrites sous la forme
math.pietmath.pi / 100, et non sous forme de longues décimales copiées depuis une exécution. Une valeur attendue copiée depuis la propre sortie du code ne teste rien : elle s'accorderait tout autant avec un bug. circle_area(0)ne nécessite pas deabs=carmath.pi * 0 * 0vaut exactement0.0. Il n'y a aucune erreur d'arrondi à absorber, et le plancher par défaut de1e-12est amplement suffisant.abs=sert pour les résultats qui devraient valoir zéro mais qui arrivent sous la forme d'un minuscule nombre non nul.split_billtravaille en centimes entiers. Il convertit le total en un nombre entier de centimes, divise avecdivmod, et distribue le reste un centime par un. Les entiers étant exacts, la somme des parts correspond par construction, et le test peut utiliser un simple==sur des valeursDecimal.- Deux tests distincts pour la facture, un pour les parts et un pour la somme. Si la règle de répartition change (par exemple si la dernière personne paie le centime supplémentaire), le premier test échoue et le second continue de réussir, ce qui vous indique précisément quelle promesse a été modifiée.
normaliserenvoie des floats, ses deux tests utilisent doncapprox, tant sur la liste dans son ensemble que sur la somme.- Le dernier test constitue l'expérience, conservé sous forme de test pour s'auto-documenter. Une part supérieure d'un centime passe quand même avec
approxà cet ordre de grandeur, car un millionième de 333 333.33 vaut environ 0.33. Le test réussit, et cette réussite constitue précisément le bug : voilà pourquoisplit_billutiliseDecimalet==.
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Connectez-vous pour faire le quiz