Floats and pytest.approx — testing "close enough"
Why 0.1 + 0.2 == 0.3 is false, what pytest.approx tolerates by default, when you need rel and abs, and why money wants Decimal rather than approx. With NaN, unordered results and timestamps.
- 1Encounter
- 2Understand
- 3Worked
- 4Predict
- 5Apply
- 6Stretch
The problem we are solving
Ask Python something a child could answer:
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)0.30000000000000004
FalseThat is not a bug in your machine, and it is not a bug in Python. It is how almost every programming language stores decimal fractions. And it walks straight into your tests.
test_total.py:
def total(prices):
return sum(prices)
def test_total():
assert total([0.1, 0.2]) == 0.3Then 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.01sThe function is correct. The test is wrong. It asks for an exactness that floating-point numbers cannot give. This chapter is about asking the right question instead: is it close enough? It is also about the cases where "close enough" is the wrong question, and money is the main one.
By the end of this chapter you can
- Explain in two sentences why
0.1 + 0.2 == 0.3isFalse - Compare floats in a test with
pytest.approx, and read its default tolerance - Set your own tolerance with
rel=andabs=, and know which one you need near zero - Use
approxon lists, tuples and dicts, and read the report it gives when they differ - Choose between
pytest.approx,math.iscloseandDecimal - Test NaN, results whose order is not promised, and timestamps
Prerequisites: testing exceptions.
Before you write the test
With numbers, the most important decision comes before any code: for each result, what kind of equality has the code promised? Pick wrong and the test is either flaky (too strict) or blind (too loose). Settle three things first.
1. The contract. Take the small stats.py module that this chapter ends with. In plain words, it promises:
mean(values)returns the average of a list of floats, andnanfor an empty list.shares(counts)turns counts into fractions that add up to one.with_vat(price)adds 15% VAT to aDecimalprice, rounded half-up to the cent. Afloatprice is refused with aTypeError.countries(orders)returns each country once. It does not promise any order.
Each line already tells you how to compare. "Average of floats" means a tolerance. "Rounded to the cent" means exact. "Refused" means pytest.raises. "Not in any order" means sort or use a set before comparing.
2. The setup. Nothing new to install. You need the venv from chapter one with pytest in it, the module importable from the test file (both in one folder, run pytest from there), and two standard library modules, math and decimal. No fixtures, no files, no network.
3. The plan. Write the cases down before writing the tests. For every row, decide the comparison too, not just the expected value:
| Case | Input | Expected | Compare with | | --- | --- | --- | --- | | Happy path, float arithmetic | mean([0.1, 0.2, 0.3]) | 0.2 | approx (default tolerance) | | Boundary: result near zero | mean([0.1, 0.2, -0.3]) | 0.0 | approx(0.0, abs=1e-9) | | Edge: empty input | mean([]) | nan | math.isnan | | Container of floats | shares({"tea": 1, "coffee": 2}) | {"tea": 1/3, "coffee": 2/3} | approx on the dict | | Money, happy path | with_vat(Decimal("19.99")) | Decimal("22.99") | exact == | | Money, rounding boundary | with_vat(Decimal("0.10")) | Decimal("0.12") (0.115 rounds up) | exact == | | Invalid input | with_vat(19.99) | TypeError | pytest.raises | | Order not promised | countries(...) with a duplicate | ["BD", "IN"] | sorted(...) == |
What not to test. Do not test that 0.1 + 0.2 is 0.30000000000000004. That is Python's behaviour, not yours, and pinning it makes your test a test of the float format. Do not test the order of a result that comes from a set, nor the exact microsecond of a timestamp. Neither is promised. And do not pick a tolerance by widening it until the test turns green. A tolerance is a statement about the problem ("half a degree", "one percent"), never about the test.
The sections below teach each tool in that table. The complete example at the end implements the plan row by row.
Why 0.1 + 0.2 is not 0.3
A float is stored in binary, in a fixed amount of space (64 bits). Some fractions have no exact binary form, just as 1/3 has no exact decimal form: 0.3333… goes on forever, and at some point you have to stop writing. 0.1 is one of those fractions in binary. Python keeps the nearest number it can hold.
You can see the stored values by asking for more digits than Python normally shows:
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 is stored a hair too large, and so is 0.2. Adding them adds the two errors. Meanwhile 0.3 is stored a hair too small. The two results land on neighbouring floats, and == compares floats bit by bit, so it says False.
That leads to the one rule of this chapter: never use == on a float that came out of arithmetic. A float you typed in yourself and passed through unchanged is fine. A float that was added, divided or multiplied should be compared with a tolerance.
pytest.approx — equal, within a tolerance
Change one line of the 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) builds an object that is "equal" to any number close enough to 0.3. How close? Print one and it tells you:
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-12The defaults are two tolerances, and the larger of the two wins:
- relative
rel=1e-6: one part in a million of the expected value. For0.3that is0.0000003; for a million it is1. - absolute
abs=1e-12: a fixed floor, so the tolerance never quite reaches zero.
The relative tolerance is the one that matters almost everywhere, because it grows and shrinks with the number. An error of 0.0000003 is noise next to 0.3; next to 1_000_000 it would be absurdly strict.
Near zero, the picture changes. One part in a million of zero is zero, so only the tiny absolute floor is left:
import pytest
print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))False
TrueA millionth looks like zero to a person, but not to approx(0.0). If a result is expected to be zero, or near it, give approx an absolute tolerance yourself.
Choosing your own tolerance: rel= and abs=
The defaults suit "the same calculation, done slightly differently". When your code rounds, measures or estimates, decide what "close enough" means and say it:
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.5means "within half a unit, whatever the size of the number".rel=0.01means "within one percent of the expected value". For100that is± 1.- Give both and, as with the defaults, the larger tolerance wins: here
abs=5beats one percent of 100.
Choose with a sentence in mind. "The temperature reading may be off by half a degree" is abs=0.5. "The estimate may be off by one percent" is rel=0.01. And when the expected value is zero, only abs can help.
The wrong way is to start from a failing test and widen the tolerance until it goes green. A tolerance that was chosen to make the test pass will pass a bug just as happily:
import pytest
print(0.95 == pytest.approx(1.0, rel=0.1))TrueA result that is five percent wrong now counts as "equal". If the code should be accurate to rounding error, keep the default. A loose tolerance needs a reason you can say out loud, and that reason belongs in a comment next to it.
Lists, tuples and dicts
approx also accepts a container of numbers and compares it element by element, each with its own tolerance:
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])A list compares by position. A dict compares by key, so the order of the keys does not matter, but the keys themselves must match exactly. The lengths must match too.
The real gain shows up when a comparison fails. 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.01sRead it from the bottom of the E lines up. One element of three did not match. The table says which one (index 1), what came out (0.25) and what was expected, with its tolerance. Element 0, the 0.30000000000000004, is not listed. It was close enough, so it is not the problem. With a dict, the Index column holds the key instead.
What approx is not for
approx exists for floats. Hand it something else and it quietly falls back to plain ==:
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
TrueFor a string, approx adds nothing, and there is no tolerance to show. For a whole number, it adds something you probably did not want. If count_items() is supposed to return 10, then 10.000001 is a bug, and approx(10) lets it through. Integers, strings, booleans and None are compared with ==.
math.isclose — the standard library version
Python has its own tolerance check, math.isclose. Its defaults are different: rel_tol=1e-9 (stricter than approx) and abs_tol=0.0 (no floor at all):
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
TrueWith abs_tol=0.0, nothing except exactly 0.0 is ever close to zero. The same lesson as before, only sharper.
In application code, math.isclose is the right tool, because pytest is not installed in production. In a test, prefer approx, and the reason is the report. 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 tells you that the numbers differ. approx also tells you by how much, and what tolerance was allowed. math.isclose also has no answer for lists or dicts.
Money is not a tolerance problem
When a test on prices fails with 0.30000000000000004, reaching for approx is tempting. Look at what that tolerance means for a large invoice:
import pytest
expected = 1_000_000.00
charged = 1_000_000.99
print(charged == pytest.approx(expected))TrueNinety-nine cents of difference, and the test passes. One part in a million of a million is one whole unit. For money, "close enough" is not a correct answer. A customer charged one cent too much has been charged the wrong amount.
The fix is not in the test. The code should not hold money in a float to begin with. Python's decimal.Decimal stores decimal digits exactly as written:
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.1000000000000000055511151231257827021181583404541015625With Decimal, == is exact again, which is what a money test needs. The last line is the trap. Decimal(0.1) is built from a float, so it copies the float's error faithfully. Always build a Decimal from a string.
Rounding to cents is explicit, and you choose the rule:
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.00So the split is simple. Measurements (lengths, averages, ratios, sensor readings) are floats, tested with approx. Amounts of money are Decimal, tested with ==.
NaN is not equal to itself
float("nan") means "not a number", the result of something like 0 * inf or a missing reading. By definition it is equal to nothing, itself included:
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 follows that rule unless you pass nan_ok=True, which says "a NaN here is expected, and matches a NaN". For a single value, assert math.isnan(result) reads more plainly. nan_ok=True earns its place on containers, where one NaN sits among ordinary numbers: [1.5, nan, 2.0] == pytest.approx([1.5, nan, 2.0], nan_ok=True) is True.
Order you did not promise, and time that will not hold still
Floats are not the only values that come out "nearly" right. Two more cases need the same thinking: decide what is promised, and test only that.
Order. This function collects the tags of some posts through a 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"}The order of strings in a set changes from run to run, because string hashing is randomised per process. Fixing the seed makes that visible. PYTHONHASHSEED=1 pytest -q:
F.. [100%]
=================================== FAILURES ===================================
____________________________ test_order_is_a_guess _____________________________
def test_order_is_a_guess():
> assert unique_tags(POSTS) == ["floats", "python", "testing"]
E AssertionError: assert ['python', 't...ng', 'floats'] == ['floats', 'p...n', 'testing']
E
E At index 0 diff: 'python' != 'floats'
E Use -v to get more diff
test_tags.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_tags.py::test_order_is_a_guess - AssertionError: assert ['python'...
1 failed, 2 passed in 0.01sRun it again with PYTHONHASHSEED=2 and all three pass. Same code, same test, different result. That is a flaky test, and it is worse than a failing one. If the function does not promise an order, do not test one. Compare sorted(...), or compare as a set. One caution: a set also hides duplicates. If duplicates matter, use sorted, or collections.Counter, which counts them.
Time. A timestamp taken inside your code can never equal one taken in the test, because time passes between the two calls. Test a window instead:
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.01sThe test is exact and needs no tolerance at all: the stamp must fall between two moments you recorded. If you prefer a tolerance, approx accepts a datetime when abs= is a timedelta: order["created_at"] == pytest.approx(now, abs=timedelta(seconds=1)). When a test has to pin time to one exact value, the answer is to control the clock, and that comes later in the course with monkeypatch.
A complete example
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"]Then 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 ===============================Eight tests, one for each row of the plan in "Before you write the test". Each picks its comparison on purpose:
meanis arithmetic on floats, soapproxwith its defaults.- A mean that should be zero comes out as
1.9e-17. Relative tolerance is useless at zero, so the test gives anabs=it can justify: anything below a billionth is rounding noise here. - An empty mean is NaN by design, so
math.isnan, because==could never pass. sharesreturns a dict of floats, soapproxon the whole dict. The second test states its tolerance (abs=0.005, half a hundredth) because it compares against numbers rounded to two places.with_vatis money, soDecimaland exact==.0.10 × 1.15 = 0.115is the case that proves the rounding rule:ROUND_HALF_UPmakes it0.12.- A float price is refused rather than silently converted. That refusal is part of the contract, so it gets its own test with
pytest.raises, as in the previous chapter. countriespromises no order, so the test sorts before comparing.
When it breaks
assert 0.30000000000000004 == 0.3 A computed float compared with ==. Wrap the expected value: == pytest.approx(0.3). If the value is money, fix the code to use Decimal instead.
AssertionError: approx() is not supported in a boolean context. You wrote assert pytest.approx(x) with no comparison. approx only means something on one side of ==, as the message suggests: assert a == approx(b).
TypeError: '>' not supported between instances of 'ApproxScalar' and 'float' approx supports only == and !=. For "at least" or "at most", compare the plain numbers: assert result > 0.2.
Impossible to compare lists with different sizes. The list and the approx list have different lengths. A tolerance applies to values, never to how many there are. The report gives both lengths on the next line.
assert nan == nan ± ??? A NaN on both sides, and NaN never equals anything. Use math.isnan(result), or pass nan_ok=True if a NaN is the expected value.
*`TypeError: unsupported operand type(s) for : 'decimal.Decimal' and 'float'** Decimal refuses to mix with float, on purpose, so an inexact number cannot sneak in. Write the other operand as a Decimal too: Decimal("1.15")`.
Decimal('0.1000000000000000055511151231257827021181583404541015625') in a report A Decimal was built from a float, Decimal(0.1), and inherited its error. Build it from a string: Decimal("0.1").
A test that passes on one run and fails on the next Look for order coming from a set, or for a timestamp compared with ==. Compare sorted(...) or a set, and test time as a window.
Step 4 of 6 — Predict
Check your understanding
Is one millionth "close enough" to zero? What do the two lines print?
import pytest
print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))- ATrue True
- BTrue False
- CFalse True
- DFalse False
This test passes. What is wrong with it?
import pytest
def test_invoice_total():
expected = 1_000_000.00
charged = 1_000_000.99
assert charged == pytest.approx(expected)- Aapprox is so strict that the test will fail now and then
- BIt passes although the charge is 99 cents too high — money needs Decimal and ==
- Capprox does not work on large numbers, so the result is meaningless
- DNothing is wrong — approx is the right way to compare money
The function promises no order and returns each tag once. Which assert is right?
def unique_tags(posts):
tags = set()
for post in posts:
tags.update(post["tags"])
return list(tags)- Aassert unique_tags(POSTS) == ["floats", "python"]
- Bassert unique_tags(POSTS) == pytest.approx(["floats", "python"])
- Cassert set(unique_tags(POSTS)) is {"floats", "python"}
- Dassert sorted(unique_tags(POSTS)) == ["floats", "python"]
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
Your turn
Write geometry.py with three functions:
circle_area(r), which returnsmath.pi * r * rsplit_bill(total, people), wheretotalis aDecimal, returning a list ofDecimalshares to the cent that add up exactly tototal(the first few people pay one cent more when it does not divide evenly)normalise(values), which divides every value by their sum and returns the list
Then write test_geometry.py:
- Test
circle_area(1)andcircle_area(0.1)withapprox. Then testcircle_area(0)againstapprox(0.0), and explain to yourself why that one needs noabs=. - Test
split_bill(Decimal("10.00"), 3)with exact==, and test thatsum(...)of the result equalsDecimal("10.00"). - Test that
normalise([1, 1, 1])is approximately[1/3, 1/3, 1/3], and that its sum isapprox(1.0).
Then run one experiment. Write a float version of the split test for a bill of 1_000_000.00 between three people, compare the shares with approx, and make one expected share wrong by a cent. Does the test notice? The answer is the reason money lives in Decimal.
Solution
geometry.py:
import math
from decimal import Decimal
def circle_area(r):
return math.pi * r * r
def split_bill(total, people):
cents = int(total * 100)
base, extra = divmod(cents, people)
return [Decimal(base + (1 if i < extra else 0)) / 100 for i in range(people)]
def normalise(values):
total = sum(values)
return [value / total for value in values]test_geometry.py:
import math
from decimal import Decimal
import pytest
from geometry import circle_area, normalise, split_bill
def test_area_of_unit_circle():
assert circle_area(1) == pytest.approx(math.pi)
def test_area_of_small_circle():
# 0.1 * 0.1 is not exactly 0.01, so the result needs a tolerance
assert circle_area(0.1) == pytest.approx(math.pi / 100)
def test_area_of_point_is_zero():
# 0 * anything is exactly 0.0, so the tiny default abs floor is enough
assert circle_area(0) == pytest.approx(0.0)
def test_split_is_exact_to_the_cent():
assert split_bill(Decimal("10.00"), 3) == [
Decimal("3.34"),
Decimal("3.33"),
Decimal("3.33"),
]
def test_split_adds_up_to_the_bill():
assert sum(split_bill(Decimal("10.00"), 3)) == Decimal("10.00")
def test_normalise_gives_fractions():
assert normalise([1, 1, 1]) == pytest.approx([1 / 3, 1 / 3, 1 / 3])
def test_normalised_values_sum_to_one():
assert sum(normalise([1, 1, 1])) == pytest.approx(1.0)
def test_float_split_misses_a_cent():
# The experiment: a share that is one cent wrong still "passes" with floats
wrong_share = 333_333.33 + 0.01
assert wrong_share == pytest.approx(333_333.33)Then 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 ===============================Why each decision:
- The expected areas are written as
math.piandmath.pi / 100, not as long decimals copied from a run. An expected value copied from the code's own output tests nothing: it would agree with a bug too. circle_area(0)needs noabs=becausemath.pi * 0 * 0is exactly0.0. There is no rounding error to absorb, and the default floor of1e-12is more than enough.abs=is for results that should be zero but arrive as a tiny nonzero number.split_billworks in whole cents. It turns the total into an integer number of cents, divides withdivmod, and hands the remainder out one cent at a time. Integers are exact, so the shares add up by construction, and the test can use plain==onDecimalvalues.- Two separate tests for the bill, one for the shares and one for the sum. If the split rule changes (say, the last person pays the extra cent), the first test fails and the second still passes, which tells you exactly which promise changed.
normalisereturns floats, so both of its tests useapprox, on the list as a whole and on the sum.- The last test is the experiment, kept as a test so that it documents itself. A share one cent too large still passes
approxat this size, because one part in a million of 333,333.33 is about 0.33. It passes, and that passing is the bug: this is whysplit_billusesDecimaland==.
Step 6 of 6
Stretch — the chapter quiz
Ten questions from easy to hard. The last ones are difficult on purpose.
Sign in to take the quiz