Skip to content
elephantoo

Testing with pytest

Lesson 31 of 38 16 min read

Write and run tests, readable assertion failures, parametrize, fixtures, mocking and organising a test suite.


Professional code comes with automated tests: small programs that check your code does what it should. Tests let you refactor without fear, catch bugs before users do, and document how code is meant to be used. In Python, the de facto standard is pytest — tests are plain functions with plain assert statements, and pytest takes care of discovery, reporting and much more. Every Python job description that mentions testing means pytest.

Terminal
pip install pytest

Your first test#

Say you have a small module:

pricing.py
def apply_discount(price: float, percent: float) -> float:
    """Return price reduced by percent, rounded to 2 decimals."""
    if not 0 <= percent <= 100:
        raise ValueError(f"percent must be between 0 and 100, got {percent}")
    return round(price * (1 - percent / 100), 2)


def total(items: list[tuple[float, int]]) -> float:
    return round(sum(price * qty for price, qty in items), 2)

Create a test file next to it. Names matter: files must be called test_*.py (or *_test.py), and test functions must start with test:

test_pricing.py
import pytest

from pricing import apply_discount, total


def test_apply_discount_basic():
    assert apply_discount(1000, 10) == 900


def test_apply_discount_zero_and_full():
    assert apply_discount(499.99, 0) == 499.99
    assert apply_discount(499.99, 100) == 0


def test_total_of_items():
    assert total([(100, 2), (25.5, 4)]) == 302


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


def test_invalid_percent_raises():
    with pytest.raises(ValueError, match="between 0 and 100"):
        apply_discount(100, 150)

Run pytest from the project folder:

Terminal
pytest
Output
============================= test session starts ==============================
platform linux -- Python 3.13.5, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 5 items

test_pricing.py .....                                                    [100%]

============================== 5 passed in 0.01s ===============================

Each . is a passing test. Use pytest -v for one line per test, pytest -q for quiet output, and pytest test_pricing.py::test_total_of_items to run a single test.

When a test fails#

pytest's killer feature is its assertion introspection: you write a plain assert, and on failure it shows exactly what the values were. Suppose a bug sneaks into apply_discount (it forgets to round):

test_failing.py
def apply_discount(price, percent):
    return price * (1 - percent / 100)        # bug: no rounding


def test_discount_rounds_to_paise():
    assert apply_discount(99.99, 15) == 84.99
Terminal
pytest test_failing.py
Output
...
    def test_discount_rounds_to_paise():
>       assert apply_discount(99.99, 15) == 84.99
E       assert 84.99149999999999 == 84.99
E        +  where 84.99149999999999 = apply_discount(99.99, 15)

test_failing.py:6: AssertionError
=========================== short test summary info ============================
FAILED test_failing.py::test_discount_rounds_to_paise - assert 84.99149999999...
============================== 1 failed in 0.02s ===============================

No special assertEqual methods to remember — just assert. (Notice the floating-point noise in 84.99149999999999, too — exactly the kind of thing tests catch.)

Comparing floats

Floating-point results rarely match exactly. Use pytest.approx:

test_floats.py
import pytest


def test_float_sum():
    assert 0.1 + 0.2 == pytest.approx(0.3)
    assert [0.1 + 0.2, 1 / 3] == pytest.approx([0.3, 0.3333], abs=1e-4)

Parametrize: many cases, one test#

Instead of copy-pasting tests for different inputs, use @pytest.mark.parametrize:

test_param.py
import pytest

from pricing import apply_discount


@pytest.mark.parametrize(
    ("price", "percent", "expected"),
    [
        (1000, 10, 900),
        (999, 50, 499.5),
        (100, 0, 100),
        (100, 100, 0),
        (19.99, 33, 13.39),
    ],
)
def test_apply_discount(price, percent, expected):
    assert apply_discount(price, percent) == expected


@pytest.mark.parametrize("bad", [-1, 100.01, 1000])
def test_rejects_bad_percent(bad):
    with pytest.raises(ValueError):
        apply_discount(100, bad)
Terminal
pytest test_param.py -v
Output
...
test_param.py::test_apply_discount[1000-10-900] PASSED                   [ 12%]
test_param.py::test_apply_discount[999-50-499.5] PASSED                  [ 25%]
test_param.py::test_apply_discount[100-0-100] PASSED                     [ 37%]
test_param.py::test_apply_discount[100-100-0] PASSED                     [ 50%]
test_param.py::test_apply_discount[19.99-33-13.39] PASSED                [ 62%]
test_param.py::test_rejects_bad_percent[-1] PASSED                       [ 75%]
test_param.py::test_rejects_bad_percent[100.01] PASSED                   [ 87%]
test_param.py::test_rejects_bad_percent[1000] PASSED                     [100%]

============================== 8 passed in 0.01s ===============================

Each case runs and reports separately, so one failing input doesn't hide the others.

Fixtures: reusable setup#

Tests often need the same setup: a sample object, a temporary file, a database connection. A fixture is a function decorated with @pytest.fixture; any test that names it as a parameter receives its return value:

cart.py
class Cart:
    def __init__(self):
        self.items = {}

    def add(self, name, price, qty=1):
        if qty < 1:
            raise ValueError("qty must be at least 1")
        current = self.items.get(name, (price, 0))[1]
        self.items[name] = (price, current + qty)

    def total(self):
        return sum(p * q for p, q in self.items.values())
test_cart.py
import json

import pytest

from cart import Cart


@pytest.fixture
def cart():
    c = Cart()
    c.add("pen", 25, 4)
    c.add("notebook", 120)
    return c


def test_total(cart):
    assert cart.total() == 220


def test_adding_same_item_increases_qty(cart):
    cart.add("pen", 25)
    assert cart.items["pen"] == (25, 5)


def test_rejects_zero_qty(cart):
    with pytest.raises(ValueError):
        cart.add("ink", 50, qty=0)


@pytest.fixture
def saved_cart(cart, tmp_path):            # fixtures can use other fixtures
    path = tmp_path / "cart.json"          # tmp_path: built-in temp folder fixture
    path.write_text(json.dumps(cart.items))
    yield path                             # code after yield = teardown
    print("cleaning up", path.name)


def test_saved_cart_round_trip(saved_cart):
    data = json.loads(saved_cart.read_text())
    assert data["pen"] == [25, 4]
Terminal
pytest test_cart.py -q
Output
....                                                                     [100%]
4 passed in 0.01s

Every test gets a fresh cart, so tests can't interfere with each other. A fixture that uses yield runs its teardown code after the test, even if the test fails. Put fixtures shared by many test files in a conftest.py file — pytest loads it automatically.

Useful built-in fixtures: tmp_path (a temporary directory), monkeypatch (temporarily replace attributes, environment variables or functions), capsys (capture printed output), and caplog (capture logs).

Mocking external dependencies#

Unit tests should be fast and deterministic — no real network calls, no real emails. Replace those dependencies with fakes using monkeypatch (or unittest.mock):

weather.py
import requests


def get_temperature(city: str) -> float:
    r = requests.get("https://api.example.com/weather", params={"city": city}, timeout=5)
    r.raise_for_status()
    return r.json()["temp_c"]


def advice(city: str) -> str:
    t = get_temperature(city)
    return "Carry water!" if t >= 35 else "Nice day."
test_weather.py
from unittest.mock import Mock

import weather


def test_advice_hot(monkeypatch):
    monkeypatch.setattr(weather, "get_temperature", lambda city: 41.0)
    assert weather.advice("Nagpur") == "Carry water!"


def test_get_temperature_parses_json(monkeypatch):
    fake_response = Mock()
    fake_response.json.return_value = {"temp_c": 22.5}
    fake_get = Mock(return_value=fake_response)
    monkeypatch.setattr(weather.requests, "get", fake_get)

    assert weather.get_temperature("Shimla") == 22.5
    fake_get.assert_called_once()
    assert fake_get.call_args.kwargs["params"] == {"city": "Shimla"}
Terminal
pytest test_weather.py -q
Output
..                                                                       [100%]
2 passed in 0.05s

Patch the name where it's looked up (weather.requests.get, or weather.get_temperature), not where it was originally defined.

Organising a test suite#

A typical project layout:

Output
shop/
├── pyproject.toml
├── src/
│   └── shop/
│       ├── __init__.py
│       └── pricing.py
└── tests/
    ├── conftest.py
    ├── test_pricing.py
    └── test_cart.py

Configure pytest in pyproject.toml:

pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra"

More handy tools:

  • Markers: @pytest.mark.skip(reason=...), @pytest.mark.skipif(sys.platform == "win32", reason=...), @pytest.mark.xfail for known bugs, and custom markers like @pytest.mark.slow (run with -m "not slow").
  • Coverage: pip install pytest-cov, then pytest --cov=shop shows which lines your tests never execute.
  • Useful flags: -x (stop at first failure), --lf (rerun last failures), -k discount (run tests whose names match), --pdb (drop into the debugger on failure).
  • CI: run pytest on every push with GitHub Actions or GitLab CI.

What makes a good test#

  • One behaviour per test, with a descriptive name: test_rejects_zero_qty tells you what broke.
  • Arrange, Act, Assert: set up, do the thing, check the result.
  • Test behaviour, not implementation details, so refactoring doesn't break tests needlessly.
  • Cover edge cases: empty input, zero, negative numbers, very large values, invalid types, unicode.
  • Keep tests fast — mock the network, use tmp_path instead of real folders, use in-memory SQLite.
  • Consider writing the test first (test-driven development): it forces you to design the interface before the implementation.

Common mistakes#

  • Misnamed files or functions (pricing_tests.py, def check_total()) — pytest silently skips them.
  • Tests that depend on each other or on execution order — use fixtures for fresh state.
  • Comparing floats with == — use pytest.approx.
  • Hitting real APIs or production databases in unit tests.
  • Catching the exception yourself in a test instead of using pytest.raises.

What's next#

Tests tell you that something is broken; next you'll learn tools for finding out why: logging and debugging.

Check your understanding

Quick quiz

0/3 answered
  1. 1.How does pytest find your tests by default?

  2. 2.How do you assert that a function raises ValueError in pytest?

  3. 3.What is a pytest fixture?

Finished reading?

Mark this lesson complete to track your progress.