Advanced · Lesson 64 of 94

Testing with pytest

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

A test is code that calls your code and asserts what should be true afterwards. pytest finds every function whose name starts with test_ in files named test_*.py, runs them, and reports which passed. There is no boilerplate: a test is a function, and a check is the assert keyword. Tests are the only way to change code with confidence, because they tell you within seconds whether the behavior you relied on still holds.

Testing with pytest: shop.py: total(prices) → test_shop.py: def test_total(): assert total([1, 2 → $ pytest → passed → failed: expected vs actual, with the line
Testing with pytest · Figure 1 · Open for full size

When an assertion fails, pytest rewrites it to show the values on both sides, so assert total([1, 2]) == 4 reports assert 3 == 4 with the call spelled out. That is why plain assert is enough, and why you never need assertEqual-style methods. Read a failure report from the bottom: the assertion line, then the values, then the test's location. One clear assertion per idea makes the report readable; a test that asserts ten unrelated things fails at the first and hides the rest. Around the assertion itself sit a handful of conventions that let pytest find your tests and let a stranger read them: what the files and functions are called, where they live, and the shape each test takes.

Convention Why
files test_*.py or *_test.py pytest collects them automatically
functions def test_...(): collected as tests
tests/ folder next to the package separate from shipped code, easy to run all at once
one behavior per test the name and the failure both say what broke
arrange, act, assert set up, call once, check; reads top to bottom
pytest.raises(ErrorType) asserts that a block raises that exception

Testing failures matters as much as testing success. with pytest.raises(ValueError): wraps a block that must raise; if it does not, the test fails. Add match= to check the message. This is how you test the validation and error paths from the exceptions lessons, and it is often where the important bugs live.

The layout: a package folder with your modules, a tests/ folder next to it with test_<module>.py files, and an empty conftest.py or a pyproject.toml [tool.pytest.ini_options] section so pytest finds the package. Run pytest from the project root and it discovers everything. -v lists each test by name, -x stops at the first failure, -k name runs tests matching a word, and path::test_name runs one test. Tests run in the order found, and each must work alone, so never let one test depend on another having run.

Testing with pytest: project/ → shop/; __init__.py, pricing.py → tests/; test_pricing.py → pyproject.toml; [tool.pytest.ini_options → '.'
Testing with pytest · Figure 2 · Open for full size

Code

The code under test and its first tests. Each test checks one behavior, and the names say what.

# shop/__init__.py
# shop/pricing.py
def total(prices, tax_rate=0.0):
    if tax_rate < 0:
        raise ValueError("tax_rate must be non-negative")
    return round(sum(prices) * (1 + tax_rate), 2)


def discount(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError(f"percent out of range: {percent}")
    return round(price * (1 - percent / 100), 2)
# tests/test_pricing.py
import pytest

from shop.pricing import discount, total


def test_total_of_empty_list_is_zero():
    assert total([]) == 0


def test_total_adds_tax():
    assert total([10, 20], tax_rate=0.5) == 45.0


def test_discount_rounds_to_cents():
    assert discount(19.99, 15) == 16.99


def test_negative_tax_rate_is_rejected():
    with pytest.raises(ValueError, match="non-negative"):
        total([1], tax_rate=-0.1)


def test_discount_out_of_range():
    with pytest.raises(ValueError):
        discount(10, 150)
# pyproject.toml
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

Running the suite. Five dots, five passes; -v names each test.

# run_tests.sh
pytest -q 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
echo "---"
pytest -v --no-header -k discount 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
Output
.....                                                                    [100%]
5 passed in Ns
---
============================= test session starts ==============================
collecting ... collected 5 items / 3 deselected / 2 selected

tests/test_pricing.py::test_discount_rounds_to_cents PASSED              [ 50%]
tests/test_pricing.py::test_discount_out_of_range PASSED                 [100%]

======================= 2 passed, 3 deselected in Ns ========================

A failing test and its report. The assertion is rewritten to show both values, and the location points at the exact line. (Run times are normalized to Ns in these outputs so they stay stable.)

# tests/test_failing.py
from shop.pricing import total


def test_total_with_wrong_expectation():
    assert total([10, 20], tax_rate=0.1) == 30
# run_failing.sh
pytest -q tests/test_failing.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
Output
F                                                                        [100%]
=================================== FAILURES ===================================
______________________ test_total_with_wrong_expectation _______________________

    def test_total_with_wrong_expectation():
>       assert total([10, 20], tax_rate=0.1) == 30
E       assert 33.0 == 30
E        +  where 33.0 = total([10, 20], tax_rate=0.1)

tests/test_failing.py:5: AssertionError
=========================== short test summary info ============================
FAILED tests/test_failing.py::test_total_with_wrong_expectation - assert 33.0...
1 failed in Ns

Assertions on collections and floats. pytest.approx handles floating point; rich assertion output shows exactly which items differ.

# tests/test_collections.py
import pytest

from shop.pricing import discount


def test_bulk_discounts():
    prices = [10.0, 20.0, 30.0]
    assert [discount(p, 10) for p in prices] == [9.0, 18.0, 27.0]


def test_float_comparison():
    assert discount(10, 33) == pytest.approx(6.7)
    assert 0.1 + 0.2 == pytest.approx(0.3)


def test_shows_the_difference():
    assert [discount(p, 10) for p in [10.0, 20.0]] == [9.0, 18.5]
# run_collections.sh
pytest -q tests/test_collections.py 2>&1 | grep -E "passed|failed|^E " | sed -E 's/ in [0-9.]+s/ in Ns/'
Output
E       assert [9.0, 18.0] == [9.0, 18.5]
E
E         At index 1 diff: 18.0 != 18.5
E         Use -v to get more diff
1 failed, 2 passed in Ns

Try it yourself

Create a textkit/ package with clean(text) and initials(name) from the modules lesson, a tests/test_textkit.py with at least six tests including two that use pytest.raises for bad input (add validation to the functions if needed), and a pyproject.toml with the pytest section. Run pytest -v, then break one function on purpose, run again, and read the report before fixing it.

Common mistakes

What's next

You can prove your code works. Next you will remove the repetition from test files with fixtures and run one test over many inputs with parametrize.

Course overview