- Write test functions for pytest and read its output
- Check exceptions and floats with
pytest.raisesandpytest.approx - Reduce repetition with fixtures and
parametrize
You fix a small bug in the discount function, and a week later someone finds that the prices in the shopping cart are wrong. Checking everything by hand after every change is impossible. Automated tests are small functions that call your code and check the results; hundreds of them run in a second every time the code changes. In Python the de facto standard for this is pytest: tests are plain functions with plain assert statements.
The first tests
Install pytest into the project's virtual environment: python -m pip install pytest. It collects tests automatically: files named test_*.py or *_test.py, and inside them, functions whose names start with test_. Here is the code under test and its tests:
def apply_discount(price: float, percent: float) -> float:
if not 0 <= percent <= 100:
raise ValueError('percent must be between 0 and 100')
return round(price * (1 - percent / 100), 2)pricing.pyfrom pricing import apply_discount
def test_no_discount():
assert apply_discount(80, 0) == 80
def test_quarter_off():
assert apply_discount(80, 25) == 60
def test_rounding():
assert apply_discount(19.99, 10) == 17.99test_pricing.py$ pytest
============================= test session starts ==============================
platform linux -- Python 3.13.6, pytest-8.3.4, pluggy-1.5.0
rootdir: /home/aysel/shop
collected 3 items
test_pricing.py ... [100%]
============================== 3 passed in 0.02s ===============================Each dot is a passed test. Now imagine that someone “simplifies” apply_discount and removes round(). The next run shows immediately what broke, with the actual values:
$ pytest -q
..F [100%]
=================================== FAILURES ===================================
________________________________ test_rounding _________________________________
def test_rounding():
> assert apply_discount(19.99, 10) == 17.99
E assert 17.991 == 17.99
E + where 17.991 = apply_discount(19.99, 10)
test_pricing.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_rounding - assert 17.991 == 17.99
1 failed, 2 passed in 0.03sBefore running a test module, pytest rewrites its assert statements so that a failing check reports the values of every part of the expression: here, that apply_discount(19.99, 10) returned 17.991. That is why pytest needs no special methods like assertEqual — a plain assert is enough.
How a test runner works
There is no magic behind pytest. A test is a function that raises AssertionError when something is wrong; the runner collects the functions, calls each one and counts the results. The mini runner below does the same in a few lines — and shows what we lose without assertion rewriting: an ordinary assert reports only the message we wrote ourselves:
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError('percent must be between 0 and 100')
return round(price * (1 - percent / 100), 2)
def test_quarter_off():
assert apply_discount(80, 25) == 60
def test_rounding():
assert apply_discount(19.99, 10) == 17.99
def test_wrong_expectation():
assert apply_discount(50, 10) == 40, 'expected 40'
tests = [f for name, f in list(globals().items()) if name.startswith('test_')]
passed = 0
for test in tests:
try:
test()
except AssertionError as e:
print(f'FAILED {test.__name__}: {e}')
else:
passed += 1
print(f'PASSED {test.__name__}')
print(f'{passed} passed, {len(tests) - passed} failed')▸ Expected output
PASSED test_quarter_off PASSED test_rounding FAILED test_wrong_expectation: expected 40 2 passed, 1 failed
What should you test? Experienced developers look for four kinds of cases for every function:
- Typical values — the function's everyday job:
apply_discount(80, 25). - Boundaries — 0, 100, an empty list, a list with one item; bugs hide here most often.
- Invalid input — a negative percentage, a percentage above 100: the function must raise a clear exception.
- Regressions — every bug that is found gets a test that reproduces it, so that it never comes back.
Exceptions and floating-point numbers
Checking the error cases matters as much as checking the normal ones. **pytest.raises** is a context manager: the test passes only if the block raises the given exception; match additionally searches the error message with a regular expression. Floating-point numbers are compared with **pytest.approx**, because in binary arithmetic 0.1 + 0.2 == 0.3 is False:
import pytest
from pricing import apply_discount
def test_invalid_percent():
with pytest.raises(ValueError, match='between 0 and 100'):
apply_discount(50, 120)
def test_float_sum():
assert 0.1 + 0.2 == pytest.approx(0.3)Fixtures: prepared data for tests
When several tests need the same starting data, move its preparation into a fixture: a function marked with @pytest.fixture. A test receives the fixture's value simply by naming it as a parameter — pytest calls the fixture for every test, so each test gets a fresh object. Pytest also has built-in fixtures, for example tmp_path, a new temporary folder for each test:
import pytest
from cart import Cart
@pytest.fixture
def cart():
c = Cart()
c.add('tea', 3.5)
c.add('bread', 0.8)
return c
def test_total(cart):
assert cart.total() == pytest.approx(4.3)
def test_remove(cart):
cart.remove('tea')
assert cart.items == {'bread': 0.8}
def test_save(cart, tmp_path):
path = tmp_path / 'cart.json'
cart.save(path)
assert path.exists()test_remove changed its cart, but that does not affect test_total — it received its own copy. A fixture can also yield its value instead of returning it: the code after yield runs after the test, like the exit part of a context manager — the natural place to close a database connection. Fixtures used in many files go into a conftest.py file, which pytest loads automatically.
parametrize: one test, many cases
Instead of copying a test for each input, list the cases with **@pytest.mark.parametrize**. Pytest turns every tuple into a separate test with its own name, so a failure shows exactly which case broke:
import pytest
from pricing import apply_discount
@pytest.mark.parametrize('price, percent, expected', [
(100, 0, 100),
(100, 15, 85),
(80, 25, 60),
(19.99, 10, 17.99),
])
def test_apply_discount(price, percent, expected):
assert apply_discount(price, percent) == expected$ pytest -v test_pricing.py
============================= test session starts ==============================
platform linux -- Python 3.13.6, pytest-8.3.4, pluggy-1.5.0 -- /home/aysel/shop/.venv/bin/python
cachedir: .pytest_cache
rootdir: /home/aysel/shop
collected 4 items
test_pricing.py::test_apply_discount[100-0-100] PASSED [ 25%]
test_pricing.py::test_apply_discount[100-15-85] PASSED [ 50%]
test_pricing.py::test_apply_discount[80-25-60] PASSED [ 75%]
test_pricing.py::test_apply_discount[19.99-10-17.99] PASSED [100%]
============================== 4 passed in 0.02s ===============================| Command | What it does |
|---|---|
pytest -x | stops at the first failure |
pytest -k discount | runs only tests whose names contain discount |
pytest --lf | reruns only the tests that failed last time |
pytest -q / pytest -v | shorter / more detailed output |
pytest --cov=shop | code coverage (the pytest-cov plugin) |
Write the function is_palindrome(text) so that all the tests below pass: letter case, spaces and punctuation must be ignored.
def is_palindrome(text):
# keep only letters and digits, ignore case, compare with the reversed version
return False
def test_simple():
assert is_palindrome('level')
def test_mixed_case():
assert is_palindrome('Anna')
def test_phrase():
assert is_palindrome('Was it a car or a cat I saw?')
def test_not_palindrome():
assert not is_palindrome('python')
for name, test in list(globals().items()):
if name.startswith('test_'):
test()
print('PASSED', name)▸ Expected output
PASSED test_simple PASSED test_mixed_case PASSED test_phrase PASSED test_not_palindrome
Write a simple version of pytest.raises: the context manager raises(exc_type) prints ok: <Name> was raised and swallows the exception when the block raises it, and raises AssertionError when there was no exception.
from contextlib import contextmanager
@contextmanager
def raises(exc_type):
# yield inside try; handle exc_type in except; raise AssertionError in else
yield
def grade(score):
if not 0 <= score <= 100:
raise ValueError('score must be between 0 and 100')
return 'pass' if score >= 50 else 'fail'
with raises(ValueError):
grade(120)
try:
with raises(ValueError):
grade(70)
except AssertionError as e:
print('test failed:', e)▸ Expected output
ok: ValueError was raised test failed: ValueError was not raised
Key points
- pytest collects
test_*.pyfiles andtest_*functions; a test fails when it raises an exception, usuallyAssertionError. - Thanks to assertion rewriting, a plain
assertshows the values that caused the failure. pytest.raises(Error, match=...)checks exceptions, andpytest.approxcompares floats.- Fixtures give every test fresh prepared data;
tmp_pathis a built-in fixture, andyieldfixtures clean up afterwards. @pytest.mark.parametrizeturns one test function into many named cases.
Check yourself
10 questions. Every correct answer earns XP.