Float e pytest.approx — testare valori "abbastanza vicini"
Perché 0.1 + 0.2 == 0.3 è falso, cosa tollera pytest.approx per impostazione predefinita, quando servono rel e abs e perché per il denaro serve Decimal anziché approx. Con NaN, risultati non ordinati e timestamp.
Come vuoi imparare?
Il problema che stiamo risolvendo
Chiedi a Python qualcosa a cui anche un bambino saprebbe rispondere:
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)0.30000000000000004
FalseNon si tratta di un bug del tuo computer, né di un bug di Python. È il modo in cui quasi tutti i linguaggi di programmazione memorizzano le frazioni decimali. E questo comportamento finisce dritto nei tuoi test.
test_total.py:
def total(prices):
return sum(prices)
def test_total():
assert total([0.1, 0.2]) == 0.3Poi 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 funzione è corretta. È il test a essere sbagliato. Richiede un'esattezza che i numeri a virgola mobile non possono garantire. Questo capitolo spiega come porsi invece la domanda giusta: è abbastanza vicino? Tratta anche i casi in cui "abbastanza vicino" è la domanda sbagliata, e il denaro ne è l'esempio principale.
Alla fine di questo capitolo sarai in grado di
- Spiegare in due frasi perché
0.1 + 0.2 == 0.3èFalse - Confrontare i float in un test con
pytest.approxe interpretare la sua tolleranza predefinita - Impostare una tolleranza personalizzata con
rel=eabs=, sapendo quale serve vicino allo zero - Usare
approxsu liste, tuple e dizionari, leggendo il report prodotto in caso di differenze - Scegliere tra
pytest.approx,math.iscloseeDecimal - Testare NaN, risultati il cui ordine non è garantito e timestamp
Prerequisiti: testare le eccezioni.
Prima di scrivere il test
Con i numeri, la decisione più importante precede qualsiasi riga di codice: per ciascun risultato, quale tipo di uguaglianza ha promesso il codice? Sbagliare questa scelta rende il test instabile (flaky, se troppo rigido) oppure cieco (se troppo permissivo). Definisci prima di tutto tre aspetti.
1. Il contratto. Prendi il piccolo modulo stats.py con cui si conclude questo capitolo. In parole semplici, promette:
mean(values)restituisce la media di una lista di float, enanper una lista vuota.shares(counts)converte i conteggi in frazioni la cui somma fa uno.with_vat(price)aggiunge il 15% di IVA a un prezzoDecimal, arrotondato per eccesso al centesimo più vicino (half-up). Un prezzo passato comefloatviene rifiutato con unTypeError.countries(orders)restituisce ogni paese una sola volta. Non promette alcun ordine.
Ciascuna riga indica già come effettuare il confronto. "Media di float" richiede una tolleranza. "Arrotondato al centesimo" richiede un'uguaglianza esatta. "Rifiutato" significa pytest.raises. "Senza alcun ordine" significa ordinare o usare un set prima del confronto.
2. La configurazione. Nulla di nuovo da installare. Ti serve l'ambiente virtuale del capitolo uno contenente pytest, il modulo importabile dal file di test (entrambi nella stessa cartella, eseguendo pytest da lì) e due moduli della libreria standard: math e decimal. Nessuna fixture, nessun file, nessuna rete.
3. Il piano. Annota i casi prima di scrivere i test. Per ciascuna riga, decidi anche il tipo di confronto, non solo il valore atteso:
| Caso | Input | Atteso | Confronta con | | --- | --- | --- | --- | | Happy path, aritmetica con float | mean([0.1, 0.2, 0.3]) | 0.2 | approx (tolleranza predefinita) | | Caso limite: risultato vicino a zero | mean([0.1, 0.2, -0.3]) | 0.0 | approx(0.0, abs=1e-9) | | Caso estremo: input vuoto | mean([]) | nan | math.isnan | | Contenitore di float | shares({"tea": 1, "coffee": 2}) | {"tea": 1/3, "coffee": 2/3} | approx sul dizionario | | Denaro, happy path | with_vat(Decimal("19.99")) | Decimal("22.99") | == esatto | | Denaro, limite di arrotondamento | with_vat(Decimal("0.10")) | Decimal("0.12") (0.115 arrotonda per eccesso) | == esatto | | Input non valido | with_vat(19.99) | TypeError | pytest.raises | | Ordine non garantito | countries(...) con un duplicato | ["BD", "IN"] | sorted(...) == |
Cosa non testare. Non testare che 0.1 + 0.2 sia 0.30000000000000004. Questo è il comportamento di Python, non del tuo codice, e fissarlo rende il tuo test una verifica del formato dei float. Non testare l'ordine di un risultato che proviene da un set, né il microsecondo esatto di un timestamp. Nessuno dei due è promesso. E non scegliere una tolleranza allargandola finché il test non diventa verde. Una tolleranza è un'affermazione sul dominio del problema ("mezzo grado", "un percento"), mai sul test stesso.
Le sezioni seguenti spiegano ciascuno strumento presente nella tabella. L'esempio completo alla fine implementa il piano riga per riga.
Perché 0.1 + 0.2 non è 0.3
Un float viene memorizzato in binario, in una quantità fissa di spazio (64 bit). Alcune frazioni non hanno una rappresentazione binaria esatta, esattamente come 1/3 non ha una forma decimale esatta: 0.3333… prosegue all'infinito, e a un certo punto bisogna smettere di scrivere. 0.1 è una di queste frazioni in binario. Python conserva il numero più vicino che riesce a rappresentare.
Puoi vedere i valori memorizzati richiedendo più cifre decimali di quante Python ne mostri normalmente:
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 viene memorizzato leggermente più grande del dovuto, e lo stesso vale per 0.2. Sommando i due numeri, i due errori si sommano. Nel frattempo, 0.3 viene memorizzato leggermente più piccolo. I due risultati finiscono su float adiacenti, e poiché == confronta i float bit per bit, restituisce False.
Questo porta all'unica vera regola di questo capitolo: non usare mai == su un float derivante da un calcolo aritmetico. Un float digitato direttamente da te e passato inalterato va benissimo. Un float che è stato sommato, diviso o moltiplicato deve essere confrontato con una tolleranza.
pytest.approx — uguale, entro una tolleranza
Modifica una sola riga del test:
import pytest
def total(prices):
return sum(prices)
def test_total():
assert total([0.1, 0.2]) == pytest.approx(0.3). [100%]
1 passed in 0.01spytest.approx(0.3) crea un oggetto che risulta "uguale" a qualsiasi numero sufficientemente vicino a 0.3. Quanto vicino? Stampane uno e te lo mostrerà:
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-12I valori predefiniti prevedono due tolleranze, e la maggiore tra le due ha la precedenza:
- relativa
rel=1e-6: una parte su un milione del valore atteso. Per0.3equivale a0.0000003; per un milione è pari a1. - assoluta
abs=1e-12: una soglia minima fissa, in modo che la tolleranza non raggiunga mai del tutto lo zero.
La tolleranza relativa è quella che conta quasi ovunque, perché cresce e si riduce proporzionalmente al numero. Un errore di 0.0000003 è trascurabile accanto a 0.3; accanto a 1_000_000 risulterebbe assurdamente restrittivo.
Vicino allo zero, lo scenario cambia. Una parte su un milione di zero è zero, quindi rimane solo la minuscola soglia minima assoluta:
import pytest
print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))False
TrueUn milionesimo sembra zero a una persona, ma non ad approx(0.0). Se ci si aspetta che un risultato sia zero, o vicino a esso, fornisci tu stesso ad approx una tolleranza assoluta.
Scegliere la propria tolleranza: rel= e abs=
I valori predefiniti sono adatti al caso di "stesso calcolo, eseguito in modo leggermente diverso". Quando il tuo codice arrotonda, misura o fa stime, decidi cosa significa "abbastanza vicino" ed esplicitalo:
import pytest
print(100.4 == pytest.approx(100, abs=0.5))
print(100.6 == pytest.approx(100, abs=0.5))
print(100.6 == pytest.approx(100, rel=0.01))
print(pytest.approx(100, abs=0.5))
print(pytest.approx(100, rel=0.01))
print(pytest.approx(100, rel=0.01, abs=5))True
False
True
100 ± 0.5
100 ± 1
100 ± 5abs=0.5significa "entro mezza unità, indipendentemente dalla grandezza del numero".rel=0.01significa "entro l'uno percento del valore atteso". Per100questo equivale a± 1.- Specificando entrambi, esattamente come per i valori predefiniti, vince la tolleranza maggiore: qui
abs=5supera l'uno percento di 100.
Scegli pensando a una frase chiara. "La lettura della temperatura può scostarsi di mezzo grado" corrisponde a abs=0.5. "La stima può differire dell'uno percento" corrisponde a rel=0.01. E quando il valore atteso è zero, solo abs può essere d'aiuto.
L'approccio sbagliato è partire da un test che fallisce e allargare la tolleranza finché non diventa verde. Una tolleranza scelta solo per far passare il test lascerà passare con altrettanta facilità anche un bug:
import pytest
print(0.95 == pytest.approx(1.0, rel=0.1))TrueUn risultato errato del cinque percento ora viene considerato "uguale". Se il codice deve essere accurato a meno dell'errore di arrotondamento, mantieni il valore predefinito. Una tolleranza permissiva ha bisogno di una motivazione valida ed esplicita, e tale motivazione va inserita in un commento accanto al test.
Liste, tuple e dizionari
approx accetta anche contenitori di numeri ed effettua il confronto elemento per elemento, ciascuno con la propria tolleranza:
import pytest
shares = [0.1 + 0.2, 0.7]
point = (1 / 3, 2 / 3)
rates = {"tax": 0.1 + 0.05, "tip": 0.1}
print(shares == pytest.approx([0.3, 0.7]))
print(point == pytest.approx((0.333333, 0.666667), rel=1e-5))
print(rates == pytest.approx({"tax": 0.15, "tip": 0.1}))
print(pytest.approx([0.3, 0.7]))True
True
True
approx([0.3 ± 3.0e-07, 0.7 ± 7.0e-07])Una lista viene confrontata per posizione. Un dizionario viene confrontato per chiave, quindi l'ordine delle chiavi non ha importanza, ma le chiavi stesse devono corrispondere esattamente. Anche le lunghezze devono coincidere.
Il vero vantaggio si manifesta quando un confronto fallisce. 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.01sLeggilo dal basso delle righe E verso l'alto. Un elemento su tre non corrisponde. La tabella indica quale (indice 1), cosa è stato ottenuto (0.25) e cosa era atteso, con la relativa tolleranza. L'elemento 0, ovvero 0.30000000000000004, non compare nell'elenco: era abbastanza vicino, quindi non costituisce il problema. Con un dizionario, la colonna Index contiene invece la chiave.
A cosa non serve approx
approx esiste per i float. Se gli passi qualcos'altro, ricade silenziosamente sul normale ==:
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
TruePer una stringa, approx non aggiunge nulla e non c'è alcuna tolleranza da mostrare. Per un numero intero, aggiunge qualcosa che probabilmente non vorresti. Se count_items() deve restituire 10, allora 10.000001 è un bug, e approx(10) lo lascia passare. Interi, stringhe, booleani e None si confrontano con ==.
math.isclose — la versione della libreria standard
Python dispone di una propria verifica di tolleranza, math.isclose. I suoi valori predefiniti sono diversi: rel_tol=1e-9 (più restrittivo di approx) e abs_tol=0.0 (nessuna soglia minima assoluta):
import math
print(math.isclose(0.1 + 0.2, 0.3))
print(math.isclose(0.000001, 0.0))
print(math.isclose(0.000001, 0.0, abs_tol=1e-5))
print(math.isclose(100.6, 100, rel_tol=0.01))True
False
True
TrueCon abs_tol=0.0, niente tranne esattamente 0.0 risulterà mai vicino a zero. La stessa lezione di prima, ma ancora più evidente.
Nel codice applicativo, math.isclose è lo strumento ideale, poiché pytest non è installato in produzione. In un test è invece preferibile approx, e la ragione sta nel report prodotto. 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 si limita a dirti che i numeri sono diversi. approx indica anche di quanto differiscono e quale tolleranza era consentita. Inoltre, math.isclose non supporta il confronto di liste o dizionari.
Il denaro non è un problema di tolleranza
Quando un test sui prezzi fallisce con 0.30000000000000004, la tentazione di ricorrere ad approx è forte. Guarda però cosa significa tale tolleranza per una fattura di importo elevato:
import pytest
expected = 1_000_000.00
charged = 1_000_000.99
print(charged == pytest.approx(expected))TrueNovantanove centesimi di differenza, e il test passa. Una parte su un milione di un milione è un'unità intera. Con il denaro, "abbastanza vicino" non è una risposta corretta. Un cliente a cui viene addebitato un centesimo in più ha ricevuto un addebito errato.
La correzione non riguarda il test. Tanto per cominciare, il codice non dovrebbe mai gestire il denaro con un float. Il tipo decimal.Decimal di Python memorizza le cifre decimali esattamente come vengono scritte:
from decimal import Decimal
print(Decimal("0.10") + Decimal("0.20"))
print(Decimal("0.10") + Decimal("0.20") == Decimal("0.30"))
print(Decimal("1000000.99") == Decimal("1000000.00"))
print(Decimal(0.1))0.30
True
False
0.1000000000000000055511151231257827021181583404541015625Con Decimal, == torna a essere esatto, che è proprio ciò di cui ha bisogno un test sul denaro. L'ultima riga rappresenta l'insidia. Decimal(0.1) viene creato da un float, quindi ne copia fedelmente l'errore. Crea sempre un Decimal a partire da una stringa.
L'arrotondamento ai centesimi è esplicito e puoi scegliere tu la regola:
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 distinzione è quindi semplice. Le misurazioni (lunghezze, medie, rapporti, letture di sensori) sono float, testati con approx. Gli importi di denaro sono Decimal, testati con ==.
NaN non è uguale a se stesso
float("nan") significa "not a number" (non un numero), il risultato di operazioni come 0 * inf o di una misurazione mancante. Per definizione non è uguale a nulla, se stesso incluso:
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 segue questa regola a meno che non si passi nan_ok=True, che indica "in questo punto è previsto un NaN, e corrisponde a un NaN". Per un singolo valore, assert math.isnan(result) è più chiaro e leggibile. nan_ok=True trova la sua ragion d'essere sui contenitori, dove un NaN si trova in mezzo a numeri ordinari: [1.5, nan, 2.0] == pytest.approx([1.5, nan, 2.0], nan_ok=True) restituisce True.
Ordine che non hai promesso e tempo che non si ferma
I float non sono gli unici valori che risultano "quasi" corretti. Altri due casi richiedono lo stesso tipo di ragionamento: stabilisci cosa è garantito dal contratto e testa soltanto quello.
Ordine. Questa funzione raccoglie i tag di alcuni post tramite 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'ordine delle stringhe in un set cambia da un'esecuzione all'altra, poiché l'hashing delle stringhe è randomizzato per ciascun processo. Fissare il seed rende evidente questo comportamento. 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.01sEseguilo di nuovo con PYTHONHASHSEED=2 e tutti e tre i test passano. Stesso codice, stesso test, risultato diverso. Questo è un test instabile (flaky), ed è peggiore di un test che fallisce sempre. Se la funzione non promette un ordine, non verificarne alcuno. Confronta con sorted(...) o confronta come set. Un'avvertenza: un set nasconde anche i duplicati. Se i duplicati contano, usa sorted oppure collections.Counter, che si occupa di conteggiarli.
Tempo. Un timestamp generato all'interno del codice non potrà mai essere uguale a uno generato nel test, perché il tempo scorre tra le due chiamate. Verifica invece una finestra temporale:
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.01sIl test è esatto e non richiede alcuna tolleranza: il timestamp deve cadere tra i due istanti che hai registrato. Se preferisci una tolleranza, approx accetta un datetime quando abs= è un timedelta: order["created_at"] == pytest.approx(now, abs=timedelta(seconds=1)). Quando un test deve fissare il tempo a un valore esatto, la soluzione consiste nel controllare l'orologio, argomento trattato più avanti nel corso con monkeypatch.
Un esempio completo
stats.py:
import math
from decimal import ROUND_HALF_UP, Decimal
CENT = Decimal("0.01")
def mean(values):
if not values:
return math.nan
return sum(values) / len(values)
def shares(counts):
total = sum(counts.values())
return {name: count / total for name, count in counts.items()}
def with_vat(price, rate=Decimal("0.15")):
return (price * (1 + rate)).quantize(CENT, rounding=ROUND_HALF_UP)
def countries(orders):
return list({order["country"] for order in orders})test_stats.py:
import math
from decimal import Decimal
import pytest
from stats import countries, mean, shares, with_vat
def test_mean_of_measurements():
# 0.6000000000000001 / 3 -- close, never exact
assert mean([0.1, 0.2, 0.3]) == pytest.approx(0.2)
def test_mean_near_zero():
# (0.1 + 0.2 - 0.3) / 3 is about 1.9e-17, not 0.0
assert mean([0.1, 0.2, -0.3]) == pytest.approx(0.0, abs=1e-9)
def test_mean_of_nothing_is_nan():
assert math.isnan(mean([]))
def test_shares_are_fractions():
result = shares({"tea": 1, "coffee": 2})
assert result == pytest.approx({"tea": 1 / 3, "coffee": 2 / 3})
assert sum(result.values()) == pytest.approx(1.0)
def test_shares_to_two_places():
result = shares({"tea": 1, "coffee": 2})
assert result == pytest.approx({"tea": 0.33, "coffee": 0.67}, abs=0.005)
def test_vat_is_exact_to_the_cent():
assert with_vat(Decimal("19.99")) == Decimal("22.99")
assert with_vat(Decimal("0.10")) == Decimal("0.12")
def test_vat_refuses_a_float():
with pytest.raises(TypeError):
with_vat(19.99)
def test_countries_ignores_order_and_duplicates():
orders = [{"country": "BD"}, {"country": "IN"}, {"country": "BD"}]
assert sorted(countries(orders)) == ["BD", "IN"]Poi 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 ===============================Otto test, uno per ciascuna riga del piano illustrato in "Prima di scrivere il test". Ognuno sceglie il confronto in modo mirato:
meanesegue calcoli aritmetici su float, quindi usaapproxcon le impostazioni predefinite.- Una media che dovrebbe fare zero risulta pari a
1.9e-17. La tolleranza relativa è inutile con lo zero, perciò il test definisce un valore perabs=pienamente giustificabile: qualunque scarto inferiore a un miliardesimo qui è solo rumore di arrotondamento. - Una media vuota restituisce NaN per progettazione, quindi si usa
math.isnan, dato che==non potrebbe mai passare. sharesrestituisce un dizionario di float, quindi si usaapproxsull'intero dizionario. Il secondo test esplicita la propria tolleranza (abs=0.005, mezzo centesimo) poiché confronta valori arrotondati a due cifre decimali.with_vatriguarda somme di denaro, quindi richiedeDecimale un==esatto.0.10 × 1.15 = 0.115è il caso che verifica la regola di arrotondamento:ROUND_HALF_UPproduce0.12.- Un prezzo passato come float viene rifiutato invece di essere convertito silenziosamente. Tale rifiuto fa parte del contratto e ha quindi un proprio test dedicato con
pytest.raises, come nel capitolo precedente. countriesnon promette alcun ordine, perciò il test ordina gli elementi prima del confronto.
Quando qualcosa va storto
assert 0.30000000000000004 == 0.3 Un float calcolato confrontato con ==. Racchiudi il valore atteso: == pytest.approx(0.3). Se il valore rappresenta denaro, correggi il codice per usare Decimal.
AssertionError: approx() is not supported in a boolean context. Hai scritto assert pytest.approx(x) senza alcun confronto. approx ha senso solo da un lato di ==, come suggerisce il messaggio: assert a == approx(b).
TypeError: '>' not supported between instances of 'ApproxScalar' and 'float' approx supporta solo == e !=. Per "almeno" o "al massimo", confronta i numeri normali: assert result > 0.2.
Impossible to compare lists with different sizes. La lista e la lista passata ad approx hanno lunghezze diverse. La tolleranza si applica ai valori, mai al numero di elementi. Il report riporta entrambe le lunghezze nella riga successiva.
assert nan == nan ± ??? Un NaN su entrambi i lati, e NaN non è mai uguale a nulla. Usa math.isnan(result), oppure passa nan_ok=True se NaN è il valore atteso.
*`TypeError: unsupported operand type(s) for : 'decimal.Decimal' and 'float'** Decimal rifiuta di combinarsi con float, intenzionalmente, per evitare che si inserisca un numero inesatto. Scrivi anche l'altro operando come Decimal: Decimal("1.15")`.
Decimal('0.1000000000000000055511151231257827021181583404541015625') in un report Un Decimal è stato creato a partire da un float, Decimal(0.1), ereditandone l'errore. Crealo a partire da una stringa: Decimal("0.1").
Un test che passa in un'esecuzione e fallisce nella successiva Cerca un ordine proveniente da un set, oppure un timestamp confrontato con ==. Effettua il confronto con sorted(...) o con un set, e verifica il tempo tramite un intervallo.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Un milionesimo è "abbastanza vicino" a zero? Cosa stampano le due righe?
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
Questo test passa. Cosa c'è che non va?
import pytest
def test_invoice_total():
expected = 1_000_000.00
charged = 1_000_000.99
assert charged == pytest.approx(expected)- ANon c'è niente che non va — approx è il modo corretto di confrontare il denaro
- Bapprox è così severo che il test fallirà ogni tanto
- Capprox non funziona con i numeri grandi, quindi il risultato è privo di significato
- DPassa anche se l'addebito è più alto di 99 centesimi — il denaro richiede Decimal e ==
La funzione non promette alcun ordine e restituisce ogni tag una sola volta. Quale assert è corretto?
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"])
Per rispondere serve un account
Accedi per verificare le tue risposte
Le domande sono qui sopra, e ragionarci a mente è la parte che conta. Accedi per vedere le risposte, le spiegazioni e i suggerimenti a tre livelli.
Tocca a te
Scrivi geometry.py con tre funzioni:
circle_area(r), che restituiscemath.pi * r * rsplit_bill(total, people), dovetotalè unDecimal, che restituisce una lista di quoteDecimalal centesimo che sommate danno esattamentetotal(le prime persone pagano un centesimo in più se la cifra non si divide equamente)normalise(values), che divide ciascun valore per la loro somma e restituisce la lista
Poi scrivi test_geometry.py:
- Testa
circle_area(1)ecircle_area(0.1)conapprox. Poi testacircle_area(0)rispetto adapprox(0.0), spiegandoti perché questo caso non ha bisogno diabs=. - Testa
split_bill(Decimal("10.00"), 3)con==esatto, e verifica chesum(...)del risultato sia pari aDecimal("10.00"). - Testa che
normalise([1, 1, 1])sia approssimativamente[1/3, 1/3, 1/3], e che la sua somma siaapprox(1.0).
Infine, esegui un esperimento. Scrivi una versione con float del test di suddivisione per un conto di 1_000_000.00 diviso tra tre persone, confronta le quote con approx e introduci un errore di un centesimo in una quota attesa. Il test se ne accorge? La risposta è il motivo per cui il denaro viene gestito con Decimal.
Soluzione
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)Poi 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 ===============================Il motivo di ciascuna decisione:
- Le aree attese sono scritte come
math.piemath.pi / 100, non come lunghi numeri decimali copiati dall'output di un'esecuzione. Un valore atteso copiato direttamente dall'output del codice non testa nulla: concorderebbe anche con un bug. circle_area(0)non ha bisogno diabs=perchémath.pi * 0 * 0è esattamente0.0. Non c'è alcun errore di arrotondamento da assorbire, e la soglia minima predefinita di1e-12è più che sufficiente.abs=serve per i risultati che dovrebbero essere zero ma arrivano come un piccolissimo numero diverso da zero.split_billlavora con centesimi interi. Converte il totale in un numero intero di centesimi, divide condivmode assegna il resto un centesimo alla volta. Gli interi sono esatti, quindi le quote si sommano correttamente per costruzione, e il test può usare un semplice==sui valoriDecimal.- Due test separati per il conto, uno per le quote e uno per la somma totale. Se la regola di suddivisione cambia (ad esempio, l'ultima persona paga il centesimo in più), il primo test fallisce mentre il secondo continua a passare, indicando con precisione quale promessa è cambiata.
normaliserestituisce float, quindi entrambi i suoi test usanoapprox, sia sulla lista nel suo complesso sia sulla somma.- L'ultimo test è l'esperimento, mantenuto come test in modo che sia auto-documentante. Una quota superiore di un centesimo passa comunque con
approxa questi ordini di grandezza, poiché una parte su un milione di 333.333,33 è pari a circa 0,33. Il test passa, e proprio questo passaggio è il bug: ecco perchésplit_billusaDecimale==.
Passo 6 di 6
Sfida — il quiz del capitolo
Dieci domande dalla più facile alla più difficile. Le ultime sono difficili di proposito.
Accedi per fare il quiz