Floats en pytest.approx — testen of iets "dichtbij genoeg" is
Waarom 0.1 + 0.2 == 0.3 false is, wat pytest.approx standaard toelaat, wanneer je rel en abs nodig hebt, en waarom geld Decimal vereist in plaats van approx. Met NaN, niet-geordende resultaten en timestamps.
Hoe wil je leren?
Het probleem dat we oplossen
Vraag Python iets wat een kind zou kunnen beantwoorden:
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)0.30000000000000004
FalseDat is geen bug in je machine, en het is geen bug in Python. Het is de manier waarop vrijwel elke programmeertaal decimale breuken opslaat. En het belandt rechtstreeks in je tests.
test_total.py:
def total(prices):
return sum(prices)
def test_total():
assert total([0.1, 0.2]) == 0.3Vervolgens 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.01sDe functie klopt. De test is fout. Hij vraagt om een exactheid die floating-pointgetallen niet kunnen bieden. Dit hoofdstuk gaat over het stellen van de juiste vraag: is het dichtbij genoeg? Het gaat ook over de situaties waarin "dichtbij genoeg" de verkeerde vraag is, en geld is daar het belangrijkste voorbeeld van.
Aan het einde van dit hoofdstuk kun je
- In twee zinnen uitleggen waarom
0.1 + 0.2 == 0.3Falseis - Floats in een test vergelijken met
pytest.approx, en de standaardtolerantie ervan lezen - Je eigen tolerantie instellen met
rel=enabs=, en weten welke je nodig hebt rond nul approxgebruiken op lijsten, tuples en dicts, en het rapport lezen dat volgt wanneer ze verschillen- Kiezen tussen
pytest.approx,math.iscloseenDecimal - NaN testen, resultaten waarvan de volgorde niet beloofd is, en timestamps
Voorkennis: Exceptions testen.
Voordat je de test schrijft
Bij getallen neem je de belangrijkste beslissing nog vóórdat er code is: welke vorm van gelijkheid heeft de code voor elk resultaat beloofd? Kies verkeerd en de test is ofwel flaky (te streng) ofwel blind (te soepel). Bepaal eerst drie dingen.
1. Het contract. Neem de kleine module stats.py waar dit hoofdstuk mee afsluit. In gewone woorden belooft die:
mean(values)retourneert het gemiddelde van een lijst floats, ennanbij een lege lijst.shares(counts)zet aantallen om in fracties die optellen tot één.with_vat(price)telt 15% btw op bij eenDecimal-prijs, afgerond op de cent volgens half-up. Eenfloat-prijs wordt geweigerd met eenTypeError.countries(orders)retourneert elk land één keer. Het belooft geen specifieke volgorde.
Elke regel vertelt je al hoe je moet vergelijken. "Gemiddelde van floats" betekent een tolerantie. "Afgerond op de cent" betekent exact. "Geweigerd" betekent pytest.raises. "Zonder vaste volgorde" betekent sorteren of een set gebruiken vóór het vergelijken.
2. De set-up. Niets nieuws om te installeren. Je hebt de venv uit hoofdstuk één nodig met pytest erin, de module moet importeerbaar zijn vanuit het testbestand (beide in één map, voer pytest van daaruit uit), en twee modules uit de standaardbibliotheek: math en decimal. Geen fixtures, geen bestanden, geen netwerk.
3. Het plan. Schrijf de gevallen op voordat je de tests schrijft. Bepaal voor elke rij ook de vergelijking, niet alleen de verwachte waarde:
| Geval | Invoer | Verwacht | Vergelijk met | | --- | --- | --- | --- | | Happy path, float-rekenkunde | mean([0.1, 0.2, 0.3]) | 0.2 | approx (standaardtolerantie) | | Grensgeval: resultaat rond nul | mean([0.1, 0.2, -0.3]) | 0.0 | approx(0.0, abs=1e-9) | | Randgeval: lege invoer | mean([]) | nan | math.isnan | | Container met floats | shares({"tea": 1, "coffee": 2}) | {"tea": 1/3, "coffee": 2/3} | approx op de dict | | Geld, happy path | with_vat(Decimal("19.99")) | Decimal("22.99") | exact == | | Geld, afrondingsgrens | with_vat(Decimal("0.10")) | Decimal("0.12") (0.115 rondt naar boven af) | exact == | | Ongeldige invoer | with_vat(19.99) | TypeError | pytest.raises | | Volgorde niet beloofd | countries(...) met een duplicaat | ["BD", "IN"] | sorted(...) == |
Wat je niet moet testen. Test niet dat 0.1 + 0.2 gelijk is aan 0.30000000000000004. Dat is het gedrag van Python, niet dat van jou, en als je dat vastlegt, test je het float-formaat in plaats van je code. Test niet de volgorde van een resultaat dat uit een set komt, en ook niet de exacte microseconde van een timestamp. Geen van beide is beloofd. En kies een tolerantie nooit door hem net zo lang op te rekken tot de test groen wordt. Een tolerantie is een uitspraak over het probleem ("een halve graad", "één procent"), nooit over de test.
De onderstaande paragrafen behandelen elk gereedschap uit die tabel. Het complete voorbeeld aan het einde implementeert het plan regel voor regel.
Waarom 0.1 + 0.2 niet 0.3 is
Een float wordt binair opgeslagen, in een vaste hoeveelheid ruimte (64 bits). Sommige breuken hebben geen exacte binaire vorm, net zoals 1/3 geen exacte decimale vorm heeft: 0.3333… gaat oneindig door, en op een gegeven moment moet je stoppen met schrijven. 0.1 is in het binaire stelsel zo'n breuk. Python bewaart het dichtstbijzijnde getal dat het kan opslaan.
Je kunt de opgeslagen waarden zien door om meer cijfers te vragen dan Python normaal toont:
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 wordt een fractie te groot opgeslagen, en 0.2 ook. Tel je ze bij elkaar op, dan tel je beide afrondingsfouten bij elkaar op. Ondertussen wordt 0.3 juist een fractie te klein opgeslagen. De twee resultaten komen uit op naburige floats, en omdat == floats bit voor bit vergelijkt, luidt het antwoord False.
Dat leidt tot de enige regel van dit hoofdstuk: gebruik nooit == op een float die uit een berekening komt. Een float die je zelf hebt ingetypt en ongewijzigd hebt doorgegeven is prima. Een float die is opgeteld, gedeeld of vermenigvuldigd, moet met een tolerantie worden vergeleken.
pytest.approx — gelijk, binnen een tolerantie
Pas één regel van de test aan:
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) bouwt een object dat "gelijk" is aan elk getal dat dicht genoeg bij 0.3 ligt. Hoe dichtbij? Druk er een af en het vertelt het je:
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-12De standaardwaarden bestaan uit twee toleranties, en de grootste van de twee wint:
- relatief
rel=1e-6: één deel op een miljoen van de verwachte waarde. Voor0.3is dat0.0000003; voor een miljoen is dat1. - absoluut
abs=1e-12: een vaste ondergrens, zodat de tolerantie nooit helemaal nul wordt.
De relatieve tolerantie is degene die vrijwel overal van belang is, omdat deze meegroeit en meekrimpt met het getal. Een fout van 0.0000003 is ruis naast 0.3; naast 1_000_000 zou het absurd streng zijn.
Rond nul verandert dat beeld. Eén deel op een miljoen van nul is nul, waardoor alleen de piepkleine absolute ondergrens overblijft:
import pytest
print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))False
TrueEen miljoenste lijkt voor een mens op nul, maar niet voor approx(0.0). Wordt een resultaat verwacht dat nul is, of daar dichtbij ligt, geef approx dan zelf een absolute tolerantie mee.
Je eigen tolerantie kiezen: rel= en abs=
De standaardwaarden passen bij "dezelfde berekening, net iets anders uitgevoerd". Wanneer je code afrondt, meet of schat, bepaal dan wat "dichtbij genoeg" betekent en zeg het expliciet:
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.5betekent "binnen een halve eenheid, ongeacht de grootte van het getal".rel=0.01betekent "binnen één procent van de verwachte waarde". Voor100is dat± 1.- Geef je beide mee, dan wint, net als bij de standaardwaarden, de grootste tolerantie: hier wint
abs=5het van één procent van 100.
Kies met een concrete zin in gedachten. "De temperatuurmeting mag er een halve graad naast zitten" is abs=0.5. "De schatting mag er één procent naast zitten" is rel=0.01. En wanneer de verwachte waarde nul is, kan alleen abs helpen.
De verkeerde manier is beginnen bij een falende test en de tolerantie oprekken tot hij groen wordt. Een tolerantie die gekozen is om de test te laten slagen, laat net zo makkelijk een bug door de vingers glippen:
import pytest
print(0.95 == pytest.approx(1.0, rel=0.1))TrueEen resultaat dat vijf procent verkeerd is, telt nu als "gelijk". Als de code nauwkeurig hoort te zijn tot op de afrondingsfout, houd dan de standaardwaarde aan. Een ruime tolerantie vraagt om een reden die je hardop kunt uitspreken, en die reden hoort thuis in een commentaar ernaast.
Lijsten, tuples en dicts
approx accepteert ook een container met getallen en vergelijkt die element voor element, elk met zijn eigen tolerantie:
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])Een lijst vergelijkt op positie. Een dict vergelijkt op key, dus de volgorde van de keys maakt niet uit, maar de keys zelf moeten exact overeenkomen. De lengtes moeten ook overeenkomen.
De echte winst zie je pas wanneer een vergelijking faalt. 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.01sLees dit vanaf de onderste E-regels naar boven. Eén van de drie elementen kwam niet overeen. De tabel laat zien welke (index 1), wat het resultaat was (0.25) en wat er werd verwacht, inclusief tolerantie. Element 0, de 0.30000000000000004, staat er niet bij. Die lag dicht genoeg in de buurt en vormt dus niet het probleem. Bij een dict bevat de kolom Index in plaats daarvan de key.
Waar approx niet voor bedoeld is
approx is bedoeld voor floats. Geef je het iets anders, dan valt het stilletjes terug op gewone ==:
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
TrueVoor een string voegt approx niets toe en valt er geen tolerantie te tonen. Voor een geheel getal voegt het juist iets toe wat je waarschijnlijk niet wilt. Als count_items() geacht wordt 10 te retourneren, dan is 10.000001 een bug — en approx(10) laat die erdoor. Integers, strings, booleans en None vergelijk je met ==.
math.isclose — de versie uit de standaardbibliotheek
Python heeft zijn eigen tolerantiecontrole: math.isclose. De standaardwaarden zijn anders: rel_tol=1e-9 (strenger dan approx) en abs_tol=0.0 (helemaal geen ondergrens):
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
TrueMet abs_tol=0.0 ligt niets behalve exact 0.0 ooit dicht bij nul. Dezelfde les als hiervoor, alleen nog scherper.
In applicatiecode is math.isclose het juiste gereedschap, omdat pytest in productie niet geïnstalleerd is. In een test geef je de voorkeur aan approx, en de reden daarvoor is het 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 vertelt je dat de getallen verschillen. approx vertelt je ook hoeveel, en welke tolerantie was toegestaan. math.isclose heeft bovendien geen oplossing voor lijsten of dicts.
Geld is geen tolerantieprobleem
Wanneer een test op prijzen faalt met 0.30000000000000004, is de verleiding groot om naar approx te grijpen. Kijk eens wat die tolerantie betekent voor een grote factuur:
import pytest
expected = 1_000_000.00
charged = 1_000_000.99
print(charged == pytest.approx(expected))TrueNegenennegentig cent verschil, en de test slaagt. Eén deel op een miljoen van een miljoen is één hele eenheid. Voor geld is "dichtbij genoeg" geen juist antwoord. Een klant bij wie één cent te veel in rekening wordt gebracht, heeft het verkeerde bedrag betaald.
De oplossing ligt niet in de test. De code hoort geld überhaupt niet in een float te bewaren. Pythons decimal.Decimal slaat decimale cijfers exact op zoals ze zijn opgeschreven:
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.1000000000000000055511151231257827021181583404541015625Met Decimal is == weer exact, en dat is wat een test voor geld nodig heeft. De laatste regel is de valkuil. Decimal(0.1) wordt opgebouwd vanuit een float, waardoor het de fout van de float getrouw overneemt. Bouw een Decimal altijd vanuit een string.
Afronden op centen gebeurt expliciet, en je kiest zelf de regel:
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.00De verdeling is dus eenvoudig. Metingen (lengtes, gemiddelden, verhoudingen, sensormetingen) zijn floats, getest met approx. Geldbedragen zijn Decimal, getest met ==.
NaN is niet gelijk aan zichzelf
float("nan") betekent "not a number", het resultaat van iets als 0 * inf of een ontbrekende meting. Per definitie is het aan niets gelijk, ook niet aan zichzelf:
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 volgt die regel, tenzij je nan_ok=True meegeeft, wat zegt: "een NaN wordt hier verwacht en matcht met een NaN". Voor een enkele waarde leest assert math.isnan(result) duidelijker. nan_ok=True verdient zijn plaats bij containers, waar één NaN tussen gewone getallen staat: [1.5, nan, 2.0] == pytest.approx([1.5, nan, 2.0], nan_ok=True) is True.
Volgorde die je niet beloofd hebt, en tijd die niet stilstaat
Floats zijn niet de enige waarden die "bijna" goed uitvallen. Twee andere situaties vragen om dezelfde denkwijze: bepaal wat er beloofd is, en test alleen dat.
Volgorde. Deze functie verzamelt de tags van een aantal berichten via een 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"}De volgorde van strings in een set verschilt van run tot run, omdat string-hashing per proces gerandomiseerd is. Door de seed vast te zetten maak je dat zichtbaar. 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.01sVoer het nogmaals uit met PYTHONHASHSEED=2 en alle drie slagen. Zelfde code, zelfde test, ander resultaat. Dat is een flaky test, en dat is erger dan een falende test. Als de functie geen volgorde belooft, test er dan ook geen. Vergelijk sorted(...), of vergelijk als een set. Eén waarschuwing: een set verbergt ook duplicaten. Als duplicaten van belang zijn, gebruik dan sorted, of collections.Counter, dat ze telt.
Tijd. Een timestamp die binnen je code wordt genomen, kan nooit gelijk zijn aan een timestamp uit de test, omdat er tijd verstrijkt tussen de twee aanroepen. Test in plaats daarvan een tijdsvenster:
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.01sDe test is exact en heeft helemaal geen tolerantie nodig: het tijdstip moet vallen tussen twee momenten die je zelf hebt geregistreerd. Geef je de voorkeur aan een tolerantie, dan accepteert approx een datetime wanneer abs= een timedelta is: order["created_at"] == pytest.approx(now, abs=timedelta(seconds=1)). Wanneer een test tijd moet vastpinnen op één exacte waarde, is het antwoord: de klok manipuleren — en dat komt later in de cursus aan bod met monkeypatch.
Een compleet voorbeeld
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"]Vervolgens 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 ===============================Acht tests, één voor elke rij uit het plan in "Voordat je de test schrijft". Elke test kiest zijn vergelijking doelbewust:
meanis rekenkunde op floats, dusapproxmet zijn standaardwaarden.- Een gemiddelde dat nul hoort te zijn, komt uit op
1.9e-17. Relatieve tolerantie heeft geen zin bij nul, dus de test geeft eenabs=mee die te verantwoorden is: alles onder een miljardste is hier afrondingsruis. - Een leeg gemiddelde is per ontwerp NaN, dus
math.isnan, want==zou nooit kunnen slagen. sharesretourneert een dict van floats, dusapproxop de hele dict. De tweede test vermeldt zijn tolerantie (abs=0.005, een halve honderdste) omdat hij vergelijkt met getallen die op twee decimalen zijn afgerond.with_vatis geld, dusDecimalen exact==.0.10 × 1.15 = 0.115is het geval dat de afrondingsregel bewijst:ROUND_HALF_UPmaakt er0.12van.- Een float-prijs wordt geweigerd in plaats van stilletjes omgezet. Die weigering maakt deel uit van het contract, dus die krijgt zijn eigen test met
pytest.raises, net als in het vorige hoofdstuk. countriesbelooft geen volgorde, dus de test sorteert vóór het vergelijken.
Wanneer het misgaat
assert 0.30000000000000004 == 0.3 Een berekende float vergeleken met ==. Wikkel de verwachte waarde in: == pytest.approx(0.3). Gaat het om geld, pas de code dan aan om Decimal te gebruiken.
AssertionError: approx() is not supported in a boolean context. Je hebt assert pytest.approx(x) geschreven zonder vergelijking. approx heeft alleen betekenis aan één kant van ==, zoals de melding al aangeeft: assert a == approx(b).
TypeError: '>' not supported between instances of 'ApproxScalar' and 'float' approx ondersteunt alleen == en !=. Voor "minstens" of "hoogstens" vergelijk je de gewone getallen: assert result > 0.2.
Impossible to compare lists with different sizes. De lijst en de approx-lijst hebben verschillende lengtes. Een tolerantie geldt voor waarden, nooit voor hoeveel het er zijn. Het rapport vermeldt beide lengtes op de volgende regel.
assert nan == nan ± ??? Een NaN aan beide kanten, en NaN is nooit gelijk aan iets. Gebruik math.isnan(result), of geef nan_ok=True mee als een NaN de verwachte waarde is.
*`TypeError: unsupported operand type(s) for : 'decimal.Decimal' and 'float'** Decimal weigert bewust te mengen met float, zodat een onnauwkeurig getal niet stiekem binnendringt. Schrijf de andere operand ook als een Decimal: Decimal("1.15")`.
Decimal('0.1000000000000000055511151231257827021181583404541015625') in een rapport Een Decimal is opgebouwd vanuit een float, Decimal(0.1), en heeft diens fout geërfd. Bouw hem vanuit een string: Decimal("0.1").
Een test die bij de ene run slaagt en bij de volgende faalt Controleer of de volgorde uit een set komt, of dat een timestamp met == wordt vergeleken. Vergelijk sorted(...) of een set, en test tijd als een tijdsvenster.
Stap 4 van 6 — Voorspellen
Test je begrip
Is één miljoenste "dicht genoeg" bij nul? Wat drukken de twee regels af?
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
Deze test slaagt. Wat is er mis mee?
import pytest
def test_invoice_total():
expected = 1_000_000.00
charged = 1_000_000.99
assert charged == pytest.approx(expected)- AEr is niets mis — approx is de juiste manier om geld te vergelijken
- Bapprox is zo streng dat de test af en toe zal falen
- Capprox werkt niet op grote getallen, dus het resultaat heeft geen betekenis
- DHij slaagt hoewel het bedrag 99 cent te hoog is — geld vereist Decimal en ==
De functie belooft geen vaste volgorde en retourneert elke tag één keer. Welke assert is juist?
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"])
Voor antwoorden heb je een account nodig
Log in om je antwoorden te controleren
De vragen staan hierboven, en ze in je hoofd uitwerken is waar het om draait. Log in om de antwoorden, de uitleg en de hints in drie niveaus te zien.
Jouw beurt
Schrijf geometry.py met drie functies:
circle_area(r), diemath.pi * r * rretourneertsplit_bill(total, people), waarbijtotaleenDecimalis, die een lijst vanDecimal-delen retourneert tot op de cent die exact optellen tottotal(de eerste paar personen betalen één cent meer wanneer het niet gelijk opgaat)normalise(values), die elke waarde deelt door de som ervan en de lijst retourneert
Schrijf vervolgens test_geometry.py:
- Test
circle_area(1)encircle_area(0.1)metapprox. Test daarnacircle_area(0)tegenapprox(0.0), en leg aan jezelf uit waarom die geenabs=nodig heeft. - Test
split_bill(Decimal("10.00"), 3)met exact==, en test datsum(...)van het resultaat gelijk is aanDecimal("10.00"). - Test dat
normalise([1, 1, 1])bij benadering[1/3, 1/3, 1/3]is, en dat de som ervanapprox(1.0)is.
Voer daarna één experiment uit. Schrijf een float-versie van de split-test voor een rekening van 1_000_000.00 tussen drie personen, vergelijk de delen met approx, en laat één verwacht deel een cent afwijken. Merkt de test het op? Het antwoord is de reden waarom geld in Decimal thuishoort.
Oplossing
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)Vervolgens 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 ===============================Waarom elke beslissing:
- De verwachte oppervlakten zijn geschreven als
math.pienmath.pi / 100, niet als lange decimalen gekopieerd uit een testuitvoering. Een verwachte waarde gekopieerd uit de uitvoer van de code zelf test niets: die zou ook akkoord gaan met een bug. circle_area(0)heeft geenabs=nodig omdatmath.pi * 0 * 0exact0.0is. Er is geen afrondingsfout om op te vangen, en de standaard ondergrens van1e-12is ruim voldoende.abs=is voor resultaten die nul horen te zijn maar binnenkomen als een piepklein getal ongelijk aan nul.split_billwerkt in hele centen. Het zet het totaal om in een geheel aantal centen, deelt metdivmod, en deelt de rest cent voor cent uit. Integers zijn exact, dus de delen tellen door de constructie altijd exact op, en de test kan gewoon==gebruiken opDecimal-waarden.- Twee afzonderlijke tests voor de rekening, één voor de delen en één voor de som. Als de verdelingsregel verandert (stel: de laatste persoon betaalt de extra cent), faalt de eerste test en slaagt de tweede nog steeds, wat je precies vertelt welke belofte veranderd is.
normaliseretourneert floats, dus beide tests gebruikenapprox, op de lijst als geheel en op de som.- De laatste test is het experiment, behouden als een test zodat hij zichzelf documenteert. Een deel dat één cent te groot is, slaagt bij deze omvang nog steeds met
approx, omdat één deel op een miljoen van 333.333,33 ongeveer 0,33 is. Hij slaagt, en dát slagen is de bug: dit is waaromsplit_billDecimalen==gebruikt.
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Log in om de quiz te maken