Testing with pytest

Writing test functions, asserting raised exceptions, fixtures for setup/teardown, and parametrized tests.

Why pytest

Python ships a built-in testing library, unittest, but the wider Python ecosystem has largely converged on pytest as the de facto standard for new code — it needs no test classes or boilerplate inheritance, uses a plain assert statement instead of a family of self.assertEqual/self.assertTrue/... methods to check, and rewrites failed assertions to show exactly what was compared, without you writing a custom message. Install it into your project's virtual environment (see the virtual-environments-and-tooling page in this track):

Bash
pip install pytest

The code under test

Python
# calculator.py
class InsufficientFundsError(Exception):
    pass

def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero")
    return a / b

class Account:
    def __init__(self, balance=0):
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(f"Cannot withdraw {amount}, balance is {self.balance}")
        self.balance -= amount

A first test file

pytest discovers tests automatically by convention: files named test_*.py or *_test.py, containing functions named test_*. No test class, no registration, no imports beyond what you actually need:

Python
# test_calculator.py
import pytest
from calculator import add, divide, Account, InsufficientFundsError

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0

def test_divide():
    assert divide(10, 2) == 5

def test_divide_by_zero_raises():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

Run it from the project root:

Bash
pytest
Plaintext
============================= test session starts ==============================
collected 3 items

test_calculator.py ...                                                    [100%]

============================== 3 passed in 0.02s ===============================

A plain assert is all a test needs — no special assertion methods to remember. When one fails, pytest's assertion rewriting shows the actual values involved without you writing a custom failure message:

Plaintext
    def test_add():
>       assert add(2, 3) == 6
E       assert 5 == 6
E        +  where 5 = add(2, 3)

Testing that an exception is raised

pytest.raises is a context manager that asserts a specific exception type is raised inside its block — the test fails if the block completes without raising, or raises a different exception type:

Python
def test_withdraw_more_than_balance_raises():
    account = Account(balance=100)
    with pytest.raises(InsufficientFundsError):
        account.withdraw(250)

def test_deposit_negative_amount_raises():
    account = Account()
    with pytest.raises(ValueError, match="must be positive"):
        account.deposit(-10)

The match argument checks the exception's message against a regular expression, useful when a function can raise the same exception type for more than one reason and the test needs to confirm it's specifically this one.

Fixtures

A fixture is a reusable piece of setup (and, optionally, teardown) that a test function requests simply by naming it as a parameter — pytest matches parameter names to fixture functions automatically:

Python
# conftest.py — fixtures defined here are automatically available to every test file
import pytest
from calculator import Account

@pytest.fixture
def funded_account():
    account = Account(balance=500)
    return account

def test_withdraw_reduces_balance(funded_account):
    funded_account.withdraw(100)
    assert funded_account.balance == 400

def test_deposit_increases_balance(funded_account):
    funded_account.deposit(50)
    assert funded_account.balance == 550

Each test gets its own fresh funded_account — the fixture function runs again for every test that requests it, so tests never leak state into one another, even though both tests above name the same fixture.

A fixture can also handle teardown by using yield instead of return — code before the yield is setup, code after it runs once the test finishes, whether it passed or failed:

Python
@pytest.fixture
def temp_log_file(tmp_path):
    log_file = tmp_path / "test.log"
    log_file.write_text("")
    yield log_file
    # anything after yield runs as cleanup, even if the test raised an exception
    if log_file.exists():
        log_file.unlink()

tmp_path above is a built-in pytest fixture — pytest ships several ready-made fixtures for exactly these common situations (a temporary directory that's automatically cleaned up, capturing stdout, monkeypatching an environment variable) so you don't have to write that plumbing yourself.

Parametrize — one test, many inputs

@pytest.mark.parametrize runs the same test function once per set of inputs, instead of writing a nearly-identical test for each case:

Python
@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
    (100, 200, 300),
])
def test_add_parametrized(a, b, expected):
    assert add(a, b) == expected
Plaintext
test_calculator.py::test_add_parametrized[2-3-5] PASSED
test_calculator.py::test_add_parametrized[-1-1-0] PASSED
test_calculator.py::test_add_parametrized[0-0-0] PASSED
test_calculator.py::test_add_parametrized[100-200-300] PASSED

pytest reports each parameter set as its own individually-named test, so a single failing case among many is immediately identifiable in the output rather than being buried inside one big test function with a manual loop. Parametrize works just as well for exception cases:

Python
@pytest.mark.parametrize("amount", [-1, -100, -0.01])
def test_deposit_rejects_non_positive_amounts(amount):
    account = Account()
    with pytest.raises(ValueError):
        account.deposit(amount)

Putting it together: a complete test file

Python
# test_account.py
import pytest
from calculator import Account, InsufficientFundsError

@pytest.fixture
def funded_account():
    return Account(balance=500)

class TestDeposit:
    def test_increases_balance(self, funded_account):
        funded_account.deposit(100)
        assert funded_account.balance == 600

    @pytest.mark.parametrize("amount", [-50, 0])
    def test_rejects_non_positive_amount(self, funded_account, amount):
        with pytest.raises(ValueError):
            funded_account.deposit(amount)

class TestWithdraw:
    def test_decreases_balance(self, funded_account):
        funded_account.withdraw(100)
        assert funded_account.balance == 400

    def test_raises_when_insufficient_funds(self, funded_account):
        with pytest.raises(InsufficientFundsError):
            funded_account.withdraw(1000)

Grouping related tests in a plain class (no special base class required — pytest recognizes any class named Test* containing test_* methods) is a common organizational convention once a module has enough related tests to benefit from grouping, and fixtures work identically whether a test is a standalone function or a method on one of these classes.

Common mistakes

  • Naming a test file or function in a way pytest's default discovery doesn't recognize (check_calculator.py instead of test_calculator.py, or verify_add instead of test_add) — the test silently never runs, with no error at all.
  • Writing one large test function that asserts many unrelated things — when it fails, it's unclear which assertion actually broke; prefer small, focused tests (or parametrize) so a failure immediately narrows down the cause.
  • Sharing mutable state between tests instead of relying on a fixture that creates it fresh each time — a test's outcome should never depend on which other tests happened to run first.
  • Forgetting match= when a function raises the same exception type for several different reasons, so a test using pytest.raises(ValueError) passes even though it triggered the wrong validation branch entirely.

Interview questions

Q: What does a pytest fixture actually do, and how does a test "receive" one? A fixture is a function decorated with @pytest.fixture that provides setup (and, via yield, teardown) for tests — a test requests it simply by naming an identically-named parameter, and pytest supplies the fixture's return (or yielded) value automatically. By default, a fixture runs fresh for every test that requests it, so tests stay isolated from one another's state, though a fixture's scope can be widened (scope="module" or scope="session") when genuinely expensive setup should be shared across multiple tests instead.

Q: Why is @pytest.mark.parametrize preferable to a manual for loop inside one test function that checks several input/output pairs? With parametrize, pytest reports each parameter combination as its own separately named, separately pass/failing test — a single bad case is immediately visible by name in the test output, and the rest of the cases still run even if one fails. A manual loop inside one test function stops at the first failing assertion (since an unhandled AssertionError aborts the function), hiding whether any of the later cases would have passed or failed, and reports only one generic failure location for the whole loop.