Floats und pytest.approx — „nah genug“ testen
Warum 0.1 + 0.2 == 0.3 False ist, was pytest.approx standardmäßig toleriert, wann du rel und abs brauchst und warum Geld Decimal statt approx verlangt. Mit NaN, ungeordneten Ergebnissen und Zeitstempeln.
Wie möchtest du lernen?
Das Problem, das wir lösen
Frag Python etwas, das jedes Kind beantworten könnte:
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)0.30000000000000004
FalseDas ist kein Fehler auf deinem Rechner und kein Bug in Python. Fast jede Programmiersprache speichert Dezimalbrüche auf diese Weise. Und das schlägt direkt in deinen Tests auf.
test_total.py:
def total(prices):
return sum(prices)
def test_total():
assert total([0.1, 0.2]) == 0.3Dann 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.01sDie Funktion ist korrekt. Der Test ist falsch. Er verlangt eine Exaktheit, die Fließkommazahlen nicht bieten können. In diesem Kapitel geht es darum, stattdessen die richtige Frage zu stellen: Ist es nah genug dran? Es geht auch um die Fälle, in denen „nah genug“ die falsche Frage ist — und Geld ist dabei das wichtigste Beispiel.
Am Ende dieses Kapitels kannst du
- In zwei Sätzen erklären, warum
0.1 + 0.2 == 0.3Falseist - Floats in einem Test mit
pytest.approxvergleichen und die Standard-Toleranz ablesen - Eigene Toleranzen mit
rel=undabs=festlegen und wissen, welche du nahe null benötigst approxauf Listen, Tupel und Dictionaries anwenden und den Bericht bei Abweichungen verstehen- Zwischen
pytest.approx,math.iscloseundDecimalwählen - NaN, Ergebnisse ohne garantierte Reihenfolge und Zeitstempel testen
Voraussetzungen: Exceptions testen.
Bevor du den Test schreibst
Bei Zahlen fällt die wichtigste Entscheidung vor jeder Zeile Code: Welche Art von Gleichheit hat der Code für jedes Ergebnis versprochen? Wählst du falsch, ist der Test entweder unzuverlässig (flaky, weil zu streng) oder blind (weil zu großzügig). Kläre zuerst drei Dinge.
1. Der Vertrag. Nimm das kleine Modul stats.py, mit dem dieses Kapitel endet. In einfachen Worten verspricht es:
mean(values)gibt den Durchschnitt einer Liste von Floats zurück undnanbei einer leeren Liste.shares(counts)wandelt Anzahlen in Anteile um, die sich zu eins summieren.with_vat(price)schlägt 15 % Mehrwertsteuer auf einenDecimal-Preis auf, kaufmännisch gerundet auf den Cent. Einfloat-Preis wird mit einemTypeErrorverweigert.countries(orders)gibt jedes Land einmal zurück. Es verspricht keine bestimmte Reihenfolge.
Jede Zeile verrät dir bereits, wie du vergleichen musst. „Durchschnitt von Floats“ bedeutet eine Toleranz. „Auf den Cent gerundet“ bedeutet exakt. „Verweigert“ bedeutet pytest.raises. „Keine feste Reihenfolge“ bedeutet sortieren oder vor dem Vergleich ein Set verwenden.
2. Das Setup. Nichts Neues zu installieren. Du brauchst die virtuelle Umgebung (venv) aus Kapitel eins mit pytest, das aus der Testdatei importierbare Modul (beide im selben Ordner, starte pytest von dort) und zwei Module aus der Standardbibliothek: math und decimal. Keine Fixtures, keine Dateien, kein Netzwerk.
3. Der Plan. Notiere die Fälle, bevor du die Tests schreibst. Entscheide für jede Zeile nicht nur den erwarteten Wert, sondern auch die Art des Vergleichs:
| Fall | Eingabe | Erwartet | Vergleichen mit | | --- | --- | --- | --- | | Normalfall, Float-Arithmetik | mean([0.1, 0.2, 0.3]) | 0.2 | approx (Standard-Toleranz) | | Grenzfall: Ergebnis nahe null | mean([0.1, 0.2, -0.3]) | 0.0 | approx(0.0, abs=1e-9) | | Randfall: leere Eingabe | mean([]) | nan | math.isnan | | Container von Floats | shares({"tea": 1, "coffee": 2}) | {"tea": 1/3, "coffee": 2/3} | approx auf dem Dictionary | | Geld, Normalfall | with_vat(Decimal("19.99")) | Decimal("22.99") | exaktes == | | Geld, Rundungsgrenze | with_vat(Decimal("0.10")) | Decimal("0.12") (0.115 rundet auf) | exaktes == | | Ungültige Eingabe | with_vat(19.99) | TypeError | pytest.raises | | Keine Reihenfolge versprochen | countries(...) mit Duplikat | ["BD", "IN"] | sorted(...) == |
Was du nicht testen solltest. Teste nicht, dass 0.1 + 0.2 gleich 0.30000000000000004 ist. Das ist Pythons Verhalten, nicht deines, und darauf zu testen macht deinen Test zu einem Test des Float-Formats. Teste weder die Reihenfolge eines Ergebnisses, das aus einem Set stammt, noch die exakte Mikrosekunde eines Zeitstempels. Beides ist nicht versprochen. Und wähle eine Toleranz nicht dadurch aus, dass du sie so lange vergrößerst, bis der Test grün wird. Eine Toleranz ist eine Aussage über das Problem („ein halbes Grad“, „ein Prozent“), niemals über den Test.
Die folgenden Abschnitte erklären jedes Werkzeug aus dieser Tabelle. Das vollständige Beispiel am Ende setzt den Plan Zeile für Zeile um.
Warum 0.1 + 0.2 nicht 0.3 ist
Ein float wird binär in einem festen Speicherbereich (64 Bit) abgelegt. Manche Brüche haben keine exakte binäre Darstellung, genauso wie 1/3 keine exakte Dezimaldarstellung hat: 0.3333… geht endlos weiter, und irgendwann muss man mit dem Schreiben aufhören. 0.1 ist im Binärsystem einer dieser Brüche. Python speichert die am nächsten liegende Zahl, die es darstellen kann.
Du kannst die tatsächlich gespeicherten Werte sehen, indem du mehr Nachkommastellen anforderst, als Python normalerweise anzeigt:
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 wird einen Hauch zu groß gespeichert und 0.2 ebenso. Rechnet man sie zusammen, addieren sich die beiden Fehler. Währenddessen wird 0.3 einen Hauch zu klein gespeichert. Die beiden Ergebnisse landen auf benachbarten Float-Werten, und da == Floats Bit für Bit vergleicht, lautet das Ergebnis False.
Das führt zur zentralen Regel dieses Kapitels: Verwende niemals == für einen Float, der aus einer Rechenoperation stammt. Ein Float, den du selbst eingegeben hast und der unverändert durchgereicht wird, ist in Ordnung. Ein Float, der addiert, dividiert oder multipliziert wurde, sollte mit einer Toleranz verglichen werden.
pytest.approx — gleich, innerhalb einer Toleranz
Ändere eine Zeile des Tests:
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) erzeugt ein Objekt, das „gleich“ jeder Zahl ist, die nah genug an 0.3 liegt. Wie nah? Gib eines mit print aus, und es verrät es 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-12Standardmäßig gibt es zwei Toleranzen, und die größere von beiden gewinnt:
- relativ
rel=1e-6: ein Millionstel des erwarteten Werts. Für0.3ist das0.0000003; für eine Million ist es1. - absolut
abs=1e-12: eine feste Untergrenze, damit die Toleranz nie ganz null erreicht.
Die relative Toleranz ist diejenige, auf die es fast überall ankommt, weil sie mit der Zahl wächst und schrumpft. Ein Fehler von 0.0000003 ist neben 0.3 bloßes Rauschen; neben 1_000_000 wäre er absurd streng.
Nahe null ändert sich das Bild. Ein Millionstel von null ist null, sodass nur die winzige absolute Untergrenze übrig bleibt:
import pytest
print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))False
TrueEin Millionstel sieht für einen Menschen wie null aus, nicht aber für approx(0.0). Wenn ein Ergebnis null sein soll oder nahe daran liegt, gib approx selbst eine absolute Toleranz mit.
Eigene Toleranzen wählen: rel= und abs=
Die Standardwerte passen für „dieselbe Berechnung, nur minimal anders durchgeführt“. Wenn dein Code rundet, misst oder schätzt, entscheide, was „nah genug“ bedeutet, und gib es explizit an:
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.5bedeutet „innerhalb einer halben Einheit, unabhängig von der Größe der Zahl“.rel=0.01bedeutet „innerhalb eines Prozents des erwarteten Werts“. Für100ist das± 1.- Übergibst du beides, gewinnt wie bei den Standardwerten die größere Toleranz: Hier schlägt
abs=5ein Prozent von 100.
Wähle die Toleranz mit einem konkreten Satz im Kopf. „Der Temperaturmesswert darf um ein halbes Grad abweichen“ entspricht abs=0.5. „Die Schätzung darf um ein Prozent abweichen“ entspricht rel=0.01. Und wenn der erwartete Wert null ist, hilft nur abs.
Der falsche Weg ist, bei einem fehlschlagenden Test anzufangen und die Toleranz so lange zu vergrößern, bis er grün wird. Eine Toleranz, die nur gewählt wurde, damit der Test besteht, lässt genauso bereitwillig Fehler durchgehen:
import pytest
print(0.95 == pytest.approx(1.0, rel=0.1))TrueEin Ergebnis, das um fünf Prozent danebenliegt, gilt nun als „gleich“. Wenn der Code bis auf Rundungsfehler genau sein soll, behalte den Standardwert bei. Eine großzügige Toleranz braucht eine Begründung, die du laut aussprechen kannst — und diese Begründung gehört als Kommentar direkt daneben.
Listen, Tupel und Dictionaries
approx akzeptiert auch Container von Zahlen und vergleicht diese Element für Element, jedes mit seiner eigenen Toleranz:
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])Eine Liste vergleicht nach Position. Ein Dictionary vergleicht nach Schlüsseln, daher spielt die Reihenfolge der Schlüssel keine Rolle, aber die Schlüssel selbst müssen exakt übereinstimmen. Auch die Längen müssen übereinstimmen.
Der wahre Vorteil zeigt sich, wenn ein Vergleich fehlschlägt. 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.01sLies den Bericht von den E-Zeilen unten nach oben. Eines von drei Elementen stimmte nicht überein. Die Tabelle zeigt, welches (Index 1), was herauskam (0.25) und was erwartet wurde — samt Toleranz. Element 0, die 0.30000000000000004, wird nicht aufgeführt. Es war nah genug dran und ist daher nicht das Problem. Bei einem Dictionary enthält die Spalte Index stattdessen den Schlüssel.
Wofür approx nicht gedacht ist
approx existiert für Floats. Übergibst du etwas anderes, fällt es stillschweigend auf ein einfaches == zurück:
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
TrueFür einen String bringt approx keinen Mehrwert, und es gibt keine Toleranz anzuzeigen. Für eine ganze Zahl bringt es etwas mit, das du wahrscheinlich nicht wolltest. Wenn count_items() eigentlich 10 zurückgeben soll, dann ist 10.000001 ein Bug — und approx(10) lässt ihn durchgehen. Integer, Strings, Booleans und None werden mit == verglichen.
math.isclose — die Version der Standardbibliothek
Python verfügt über eine eigene Toleranzprüfung: math.isclose. Ihre Standardwerte unterscheiden sich: rel_tol=1e-9 (strenger als approx) und abs_tol=0.0 (überhaupt keine Untergrenze):
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
TrueMit abs_tol=0.0 ist niemals etwas nah an null, außer exakt 0.0. Dieselbe Erkenntnis wie zuvor, nur noch deutlicher.
Im Anwendungscode ist math.isclose das richtige Werkzeug, weil pytest in der Produktion nicht installiert ist. In einem Test solltest du approx bevorzugen — und der Grund dafür ist der Fehlerbericht. 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 sagt dir nur, dass sich die Zahlen unterscheiden. approx verrät dir zusätzlich, um wie viel und welche Toleranz zulässig war. Außerdem hat math.isclose keine Lösung für Listen oder Dictionaries parat.
Geld ist kein Toleranzproblem
Wenn ein Test für Preise mit 0.30000000000000004 fehlschlägt, ist die Versuchung groß, zu approx zu greifen. Schau dir an, was diese Toleranz für eine hohe Rechnung bedeutet:
import pytest
expected = 1_000_000.00
charged = 1_000_000.99
print(charged == pytest.approx(expected))TrueNeunundneunzig Cent Unterschied, und der Test besteht. Ein Millionstel von einer Million ist eine ganze Einheit. Bei Geld ist „nah genug“ keine richtige Antwort. Einem Kunden, dem ein Cent zu viel berechnet wurde, wurde der falsche Betrag in Rechnung gestellt.
Die Lösung liegt nicht im Test. Der Code sollte Geld von vornherein gar nicht in einem float speichern. Pythons decimal.Decimal speichert Dezimalstellen exakt so, wie sie geschrieben wurden:
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.1000000000000000055511151231257827021181583404541015625Mit Decimal ist == wieder exakt — genau das, was ein Geld-Test braucht. Die letzte Zeile ist die Falle: Decimal(0.1) wird aus einem Float erstellt und übernimmt dessen Fehler getreu. Erstelle ein Decimal immer aus einem String.
Das Runden auf Cent-Beträge geschieht explizit, und du wählst die Rundungsregel:
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.00Die Aufteilung ist also einfach. Messwerte (Längen, Durchschnitte, Verhältnisse, Sensorwerte) sind Floats und werden mit approx getestet. Geldbeträge sind Decimal und werden mit == getestet.
NaN ist nicht gleich sich selbst
float("nan") bedeutet „not a number“ (keine Zahl) — das Ergebnis von Operationen wie 0 * inf oder ein fehlender Messwert. Per Definition ist es gleich nichts, sich selbst eingeschlossen:
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 folgt dieser Regel, es sei denn, du übergibst nan_ok=True. Das bedeutet: „Ein NaN wird hier erwartet und stimmt mit einem NaN überein“. Bei einem einzelnen Wert liest sich assert math.isnan(result) verständlicher. nan_ok=True hat seine Stärke bei Containern, in denen ein NaN zwischen gewöhnlichen Zahlen liegt: [1.5, nan, 2.0] == pytest.approx([1.5, nan, 2.0], nan_ok=True) ist True.
Reihenfolge, die du nicht versprochen hast, und Zeit, die nicht stillsteht
Floats sind nicht die einzigen Werte, die „fast“ richtig herauskommen. Zwei weitere Fälle erfordern dieselbe Denkweise: Entscheide, was versprochen ist, und teste nur genau das.
Reihenfolge. Diese Funktion sammelt die Tags einiger Beiträge über ein 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"}Die Reihenfolge von Strings in einem Set ändert sich von Durchlauf zu Durchlauf, weil das Hashing von Strings für jeden Prozess randomisiert wird. Das Festlegen des Seeds macht das sichtbar. 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.01sFühre es noch einmal mit PYTHONHASHSEED=2 aus, und alle drei bestehen. Derselbe Code, derselbe Test, ein anderes Ergebnis. Das ist ein flaky (unzuverlässiger) Test — und das ist schlimmer als ein fehlschlagender. Wenn die Funktion keine Reihenfolge verspricht, teste auch keine. Vergleiche sorted(...) oder vergleiche als set. Eine Warnung: Ein set verschluckt auch Duplikate. Wenn Duplikate eine Rolle spielen, verwende sorted oder collections.Counter, das sie zählt.
Zeit. Ein Zeitstempel, der innerhalb deines Codes erfasst wird, kann niemals gleich einem Zeitstempel sein, der im Test erfasst wird, weil zwischen den beiden Aufrufen Zeit vergeht. Teste stattdessen ein Zeitfenster:
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.01sDer Test ist exakt und benötigt überhaupt keine Toleranz: Der Zeitstempel muss zwischen zwei Zeitpunkten liegen, die du festgehalten hast. Wenn du eine Toleranz bevorzugst, akzeptiert approx auch ein datetime, wenn abs= ein timedelta ist: order["created_at"] == pytest.approx(now, abs=timedelta(seconds=1)). Wenn ein Test die Zeit auf einen exakten Wert festlegen muss, lautet die Lösung, die Uhr zu kontrollieren — und das behandeln wir später im Kurs mit monkeypatch.
Ein vollständiges Beispiel
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"]Dann 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, einer für jede Zeile des Plans aus „Bevor du den Test schreibst“. Jeder wählt seinen Vergleich ganz bewusst:
meanist Arithmetik auf Floats, daherapproxmit seinen Standardwerten.- Ein Mittelwert, der null sein sollte, kommt als
1.9e-17heraus. Eine relative Toleranz nützt bei null nichts, daher vergibt der Test ein begründetesabs=: Alles unter einem Milliardstel ist hier Rundungsrauschen. - Ein leerer Mittelwert ist per Definition NaN, daher
math.isnan, weil==niemals bestehen könnte. sharesgibt ein Dictionary von Floats zurück, daherapproxauf dem gesamten Dictionary. Der zweite Test gibt seine Toleranz explizit an (abs=0.005, ein halbes Hundertstel), weil er mit auf zwei Nachkommastellen gerundeten Zahlen vergleicht.with_vatist Geld, alsoDecimalund exaktes==.0.10 × 1.15 = 0.115ist der Fall, der die Rundungsregel beweist:ROUND_HALF_UPmacht daraus0.12.- Ein Float-Preis wird verweigert, statt ihn stillschweigend zu konvertieren. Diese Verweigerung ist Teil des Vertrags, bekommt also wie im vorherigen Kapitel einen eigenen Test mit
pytest.raises. countriesverspricht keine Reihenfolge, daher sortiert der Test vor dem Vergleich.
Wenn etwas schiefgeht
assert 0.30000000000000004 == 0.3 Ein berechneter Float wurde mit == verglichen. Packe den erwarteten Wert ein: == pytest.approx(0.3). Handelt es sich um Geld, passe den Code so an, dass er stattdessen Decimal verwendet.
AssertionError: approx() is not supported in a boolean context. Du hast assert pytest.approx(x) ohne Vergleich geschrieben. approx hat nur auf einer Seite von == eine Bedeutung, wie die Meldung nahelegt: assert a == approx(b).
TypeError: '>' not supported between instances of 'ApproxScalar' and 'float' approx unterstützt nur == und !=. Für „mindestens“ oder „höchstens“ vergleiche die reinen Zahlen: assert result > 0.2.
Impossible to compare lists with different sizes. Die Liste und die approx-Liste haben unterschiedliche Längen. Eine Toleranz gilt für Werte, niemals für die Anzahl der Elemente. Der Bericht nennt beide Längen in der nächsten Zeile.
assert nan == nan ± ??? Ein NaN auf beiden Seiten, und NaN ist niemals gleich irgendetwas. Verwende math.isnan(result) oder übergib nan_ok=True, falls ein NaN der erwartete Wert ist.
*`TypeError: unsupported operand type(s) for : 'decimal.Decimal' and 'float'** Decimal weigert sich absichtlich, mit float gemischt zu werden, damit sich keine ungenaue Zahl einschleichen kann. Schreibe den anderen Operanden ebenfalls als Decimal: Decimal("1.15")`.
Decimal('0.1000000000000000055511151231257827021181583404541015625') in einem Bericht Ein Decimal wurde aus einem Float erzeugt, Decimal(0.1), und hat dessen Fehler übernommen. Erstelle es aus einem String: Decimal("0.1").
Ein Test, der in einem Durchlauf besteht und im nächsten fehlschlägt Suche nach einer Reihenfolge, die aus einem set stammt, oder nach einem mit == verglichenen Zeitstempel. Vergleiche mit sorted(...) oder einem set und teste Zeit als Zeitfenster.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Ist ein Millionstel „nah genug“ an null? Was geben die beiden Zeilen aus?
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
Dieser Test besteht. Was ist daran falsch?
import pytest
def test_invoice_total():
expected = 1_000_000.00
charged = 1_000_000.99
assert charged == pytest.approx(expected)- ANichts ist falsch — `approx` ist der richtige Weg, um Geldbeträge zu vergleichen
- B`approx` ist so streng, dass der Test ab und zu fehlschlägt
- C`approx` funktioniert bei großen Zahlen nicht, das Ergebnis ist also bedeutungslos
- DEr besteht, obwohl der Betrag um 99 Cent zu hoch ist — Geld verlangt `Decimal` und `==`
Die Funktion verspricht keine Reihenfolge und gibt jedes Tag einmal zurück. Welches assert ist richtig?
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"])
Zum Antworten brauchst du ein Konto
Melde dich an, um deine Antworten zu prüfen
Die Fragen stehen oben, und sie im Kopf durchzuarbeiten ist der wichtige Teil. Melde dich an, um die Antworten, die Erklärungen und die dreistufigen Hinweise zu sehen.
Jetzt bist du dran
Schreibe geometry.py mit drei Funktionen:
circle_area(r), diemath.pi * r * rzurückgibtsplit_bill(total, people), wobeitotaleinDecimalist; sie gibt eine Liste vonDecimal-Anteilen auf den Cent genau zurück, die sich exakt zutotalsummieren (die ersten Personen zahlen einen Cent mehr, wenn es sich nicht glatt teilen lässt)normalise(values), die jeden Wert durch die Summe aller Werte teilt und die Liste zurückgibt
Schreibe dann test_geometry.py:
- Teste
circle_area(1)undcircle_area(0.1)mitapprox. Teste danncircle_area(0)gegenapprox(0.0)und erkläre dir selbst, warum dieser Fall keinabs=benötigt. - Teste
split_bill(Decimal("10.00"), 3)mit dem exakten==und teste, dasssum(...)des Ergebnisses gleichDecimal("10.00")ist. - Teste, dass
normalise([1, 1, 1])ungefähr[1/3, 1/3, 1/3]ist und dass die Summeapprox(1.0)entspricht.
Führe dann ein Experiment durch: Schreibe eine Float-Version des Split-Tests für eine Rechnung über 1_000_000.00, aufgeteilt auf drei Personen, vergleiche die Anteile mit approx und weiche bei einem erwarteten Anteil um einen Cent ab. Bemerkt der Test das? Die Antwort ist der Grund, warum Geldbeträge in Decimal gehören.
Lösung
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)Dann 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 ===============================Warum jede Entscheidung so getroffen wurde:
- Die erwarteten Flächen sind als
math.piundmath.pi / 100geschrieben, nicht als lange Dezimalzahlen, die aus einem Programmdurchlauf kopiert wurden. Ein erwarteter Wert, der aus der eigenen Programmausgabe kopiert wird, testet nichts: Er würde auch mit einem Bug übereinstimmen. circle_area(0)benötigt keinabs=, weilmath.pi * 0 * 0exakt0.0ist. Es gibt keinen Rundungsfehler abzufangen, und die standardmäßige Untergrenze von1e-12reicht völlig aus.abs=ist für Ergebnisse gedacht, die eigentlich null sein sollten, aber als winzige Zahl ungleich null ankommen.split_billarbeitet in ganzen Cent. Die Funktion wandelt den Gesamtbetrag in eine ganzzahlige Anzahl von Cent um, teilt mitdivmodund verteilt den Rest Cent für Cent. Ganze Zahlen sind exakt, sodass die Anteile rein rechnerisch aufgehen und der Test ein einfaches==aufDecimal-Werten verwenden kann.- Zwei getrennte Tests für die Rechnung, einer für die Anteile und einer für die Summe. Ändert sich die Aufteilungsregel (zahlt etwa die letzte Person den zusätzlichen Cent), schlägt der erste Test fehl und der zweite besteht weiterhin — was dir genau sagt, welches Versprechen sich geändert hat.
normaliseliefert Floats zurück, daher verwenden beide Testsapprox— sowohl auf der gesamten Liste als auch auf der Summe.- Der letzte Test ist das Experiment, festgehalten als Test, damit er sich selbst dokumentiert. Ein Anteil, der um einen Cent zu groß ist, besteht bei dieser Größenordnung mit
approximmer noch, weil ein Millionstel von 333.333,33 etwa 0,33 entspricht. Er besteht, und dieses Bestehen ist der Bug: Genau deshalb verwendetsplit_billDecimalund==.
Schritt 6 von 6
Herausforderung — das Kapitel-Quiz
Zehn Fragen von leicht bis schwer. Die letzten sind absichtlich knifflig.
Melde dich an, um das Quiz zu machen