Index Ai Academy
Phase 7 · Software engineering · Python in depth

Testing with pytest

Fixtures, parametrize, mocks, temporary files and coverage.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

The testing lesson showed why automated tests matter. pytest is the tool nearly every Python project uses to write them: plain functions and plain assert statements, with powerful helpers for setup, repetition, fake files and fake services. Knowing it well means you can test code that touches the clock, the network and the disk, and that's most real code. It's also how you'll check that the AI features you build keep working.

What you'll be able to do

  • Write pytest tests with plain assert, pytest.raises and pytest.approx
  • Share setup with fixtures and conftest.py, and cover many cases with parametrize
  • Replace slow or unpredictable parts with monkeypatch and mocks, and use tmp_path and capsys
  • Judge a test suite by whether it catches bugs, and read coverage sensibly

What it covers

  1. Tests are functions that assert
  2. Fixtures: shared setup
  3. Many cases, one test: parametrize
  4. Faking the slow and unpredictable parts
  5. How good are your tests?
  6. Worked example: tmppath, capsys and a fixture together

Tests are functions that assert

A pytest test is a function whose name starts with test_, in a file whose name starts with test_. Inside, a plain assert states what should be true. pytest finds the tests, runs each one, and when an assert fails it shows the values on both sides:

from pathlib import Path
import pytest

Path("shipping.py").write_text('''
def shipping_cost(weight_kg, express=False):
    if weight_kg <= 0:
        raise ValueError("weight must be positive")
    base = 15 + 2 * weight_kg if express else 6 + weight_kg
    return round(base, 2)
''')

Path("test_shipping.py").write_text('''
import pytest
from shipping import shipping_cost

def test_standard():
    assert shipping_cost(3) == 9

def test_express_costs_more():
    assert shipping_cost(3, express=True) > shipping_cost(3)

def test_fractional_weights():
    assert shipping_cost(0.1 + 0.2) == pytest.approx(6.3)

def test_rejects_zero_weight():
    with pytest.raises(ValueError, match="positive"):
        shipping_cost(0)

def test_this_one_is_wrong():
    assert shipping_cost(10) == 15
''')

code = pytest.main(["-q", "-p", "no:cacheprovider", "test_shipping.py"])
print("exit code:", code)
It runs right here in your browser, with nothing to install.

In a real project these are two files in a folder, and you'd type pytest -q in a terminal; here one cell writes both files and runs pytest. Read the failure: pytest shows the assertion with the actual value (16), so you can see at once whether the code or the test is wrong. Here the test is wrong: 6 + 10 is 16.

pytest.approx compares floats with a small tolerance, since 0.1 + 0.2 isn't exactly 0.3. pytest.raises checks that a block raises the expected exception, and match= checks its message too.

A few command-line options make everyday testing faster. pytest -x stops at the first failure, so you can fix one thing at a time. pytest -k discount runs only tests whose names contain "discount". pytest --lf reruns just the tests that failed last time. And pytest -v lists every test by name, which helps when you want to see exactly what ran.

Fixtures: shared setup

Many tests need the same starting point: a cart with two items, a temporary database, a logged-in user. A fixture is a function decorated with @pytest.fixture that builds it. Any test that names the fixture as a parameter gets a fresh one:

import pytest
from cart import Cart

@pytest.fixture
def cart():
    c = Cart()
    c.add("TE-101", price=200)
    c.add("CA-132", price=5, qty=2)
    return c

def test_total(cart):
    assert cart.total() == 189.0        # 210 with 10% off for orders of 100 or more

def test_remove(cart):
    cart.remove("CA-132")
    assert cart.total() == 180.0

Each test gets its own new cart, so test_remove can't affect test_total: tests stay independent and can run in any order. A fixture can yield instead of return, and the code after the yield runs as cleanup, like a context manager. Fixtures used by many test files go in a file called conftest.py, which pytest loads automatically, with no import needed.

pytest has built-in fixtures too. Name them as parameters and they appear: tmp_path (a fresh temporary folder for each test), capsys (what the code printed), monkeypatch (temporary changes, see below) and caplog (log records).

Many cases, one test: parametrize

When the same check applies to many inputs, list them instead of copying the test:

from pathlib import Path
import pytest

Path("discount.py").write_text('''
def discount(subtotal):
    return 0.1 if subtotal >= 100 else 0.0
''')
Path("test_discount.py").write_text('''
import pytest
from discount import discount

@pytest.mark.parametrize("subtotal, expected", [
    (0, 0.0),
    (99.99, 0.0),
    (100, 0.1),
    (100.01, 0.1),
    (2500, 0.1),
])
def test_discount(subtotal, expected):
    assert discount(subtotal) == expected
''')
pytest.main(["-q", "-p", "no:cacheprovider", "test_discount.py"])

Each row runs as its own test and is reported separately, so a failure names the exact input. Notice the rows crowd around 100. Bugs live at boundaries: >= written as >, an off-by-one in a range, an empty list. Always test the edge, just below it and just above it.

Quick check
What does this print?
import pytest
print(0.1 + 0.2 == 0.3, 0.1 + 0.2 == pytest.approx(0.3))
  1. True False
  2. True True
  3. False True
  4. False False
Show the answer

False True

Floats can't store 0.1 and 0.2 exactly, so the sum is 0.30000000000000004. approx compares with a small tolerance.

Faking the slow and unpredictable parts

Some code depends on things a test can't control: the clock, random numbers, environment variables, a real API. monkeypatch swaps something out for the length of one test and puts it back afterwards:

def test_uses_key_from_environment(monkeypatch):
    monkeypatch.setenv("SMS_KEY", "test-key")
    assert load_settings()["sms_key"] == "test-key"

def test_report_date(monkeypatch):
    monkeypatch.setattr(report, "today", lambda: date(2025, 6, 30))
    assert report.title() == "Report for June 2025"

For checking how something was called, unittest.mock provides mock objects that record every call made to them:

from unittest.mock import Mock

def notify_if_cheaper(price, target, send):
    if price <= target:
        send(f"Now ${price:.2f}")
        return True
    return False

send = Mock()
assert notify_if_cheaper(356.25, 400, send) is True
send.assert_called_once_with("Now $356.25")
assert notify_if_cheaper(500, 400, send) is False
print("calls recorded:", send.call_count, send.call_args_list)

Passing send in as a parameter, as in the alert-bot project, is what makes it easy to replace. Code that reaches out for its dependencies (calling requests.get deep inside) needs patching; code that receives them can simply be handed a fake. Don't overdo mocks, though: a test that mocks everything checks only that the code calls what you told it to call.

Try it now

Use the lightest tool that works. Most tests need no fakes at all; fakes are for the parts you can't control.

How good are your tests?

A test suite's job is to catch bugs. Passing isn't the goal; a suite with no assertions passes too. Two ways to judge it:

  • Coverage measures which lines the tests ran: pytest --cov=cart with the pytest-cov plugin, or coverage run -m pytest then coverage report. A line no test runs is certainly untested. But running a line doesn't mean checking it: 100% coverage with weak asserts can still miss every bug. Use coverage to find gaps, not as a score to hit.
  • Plant a bug and see if the tests notice. Change >= to >, delete a validation, return the wrong value. If everything still passes, a test is missing. Tools called mutation testers do this automatically; the exercise below does it by hand, and checks your tests the same way.

Good tests are also fast, independent of each other and of the order they run in, and named for the behavior they check (test_discount_starts_at_exactly_100), so a failure explains itself. When a bug is reported, write a test that reproduces it first, watch it fail, then fix the code: that bug can never quietly return.

Worked example

Worked example: tmp_path, capsys and a fixture together

Test a function that saves a report to a file and prints a summary, using built-in fixtures so the test touches no real files and captures the output.

from pathlib import Path
import pytest

Path("report.py").write_text('''
import json

def save_report(rows, folder):
    path = folder / "report.json"
    path.write_text(json.dumps(rows))
    print(f"saved {len(rows)} rows to {path.name}")
    return path
''')
Path("test_report.py").write_text('''
import json
import pytest
from report import save_report

@pytest.fixture
def rows():
    return [{"sku": "TE-101", "qty": 2}, {"sku": "PA-108", "qty": 1}]

def test_saves_json(rows, tmp_path):
    path = save_report(rows, tmp_path)
    assert json.loads(path.read_text()) == rows

def test_prints_summary(rows, tmp_path, capsys):
    save_report(rows, tmp_path)
    assert capsys.readouterr().out == "saved 2 rows to report.json\\n"
''')
code = pytest.main(["-q", "-p", "no:cacheprovider", "test_report.py"])
print("exit code:", code)
In the lesson you can change this and run it.

tmp_path gives each test its own empty temporary folder, so the tests never write into the project or into each other's files. capsys.readouterr().out returns everything printed so far, so the summary line can be checked exactly. The rows fixture keeps the sample data in one place for both tests. Exit code 0 means every test passed; 1 means at least one failed, which is what CI checks.

Practice what you just read

  • 6 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Why does each test that names a fixture get a fresh object?

  1. So tests are independent: one test's changes can't affect another
  2. Because fixtures can't be shared
  3. To make pytest faster
  4. So the fixture runs only once

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson