Skip to content
Educora
Advanced20 min23 / 42

Testing with pytest

Write automated tests: how pytest finds tests, assert rewriting, checking exceptions and floats, fixtures, `tmp_path` and `parametrize`.

Check yourself
In this lesson you will learn
  • Write test functions for pytest and read its output
  • Check exceptions and floats with pytest.raises and pytest.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:

Python
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.py
Python
from 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.99
test_pricing.py
Terminal
$ 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:

Terminal
$ 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.03s
Definition
Assertion rewriting

Before 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:

Python
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:

Python
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:

Python
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:

Python
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
Terminal
$ 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 ===============================
CommandWhat it does
pytest -xstops at the first failure
pytest -k discountruns only tests whose names contain discount
pytest --lfreruns only the tests that failed last time
pytest -q / pytest -vshorter / more detailed output
pytest --cov=shopcode coverage (the pytest-cov plugin)
Exercise

Write the function is_palindrome(text) so that all the tests below pass: letter case, spaces and punctuation must be ignored.

Exercise · Python
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
Exercise

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.

Exercise · Python
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_*.py files and test_* functions; a test fails when it raises an exception, usually AssertionError.
  • Thanks to assertion rewriting, a plain assert shows the values that caused the failure.
  • pytest.raises(Error, match=...) checks exceptions, and pytest.approx compares floats.
  • Fixtures give every test fresh prepared data; tmp_path is a built-in fixture, and yield fixtures clean up afterwards.
  • @pytest.mark.parametrize turns one test function into many named cases.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
Which functions does pytest collect as tests by default?