RizTech Academy logo
RizTech Academy
Testing and Code QualityLesson 2 of 530 min

pytest: writing your first tests

Scattered assert statements work. They also stop at the first failure, tell you little about why, and have nowhere to live. pytest fixes all three and asks almost nothing in return.

Setting up

With a virtual environment active, from module 8:

python -m pip install pytest

Then the layout from the project structure lesson:

expense-tracker/
    src/expense_tracker/
        calculations.py
    tests/
        test_calculations.py

Your first test

# src/expense_tracker/calculations.py
def apply_discount(amount: float, percent: float) -> float:
    """Reduce an amount by a percentage."""
    if not 0 <= percent <= 100:
        raise ValueError(f"Percent must be 0-100, got {percent}")
    return round(amount * (1 - percent / 100), 2)
# tests/test_calculations.py
from expense_tracker.calculations import apply_discount


def test_ten_percent_off():
    assert apply_discount(1000, 10) == 900.0

Run it:

pytest
========================= test session starts =========================
collected 1 item

tests/test_calculations.py .                                    [100%]

========================== 1 passed in 0.01s ==========================

That dot is your test passing.

pytest finds tests by convention, which is the only rule to remember:

  • Files named test_*.py or *_test.py
  • Functions named test_*
  • Classes named Test*, if you use them

No registration, no base class to inherit. A function starting with test_ is a test.

Reading a failure

This is where pytest earns its place. Break the function and run again:

========================== FAILURES ===========================
______________________ test_ten_percent_off ___________________

    def test_ten_percent_off():
>       assert apply_discount(1000, 10) == 900.0
E       assert 1100.0 == 900.0
E        +  where 1100.0 = apply_discount(1000, 10)

tests/test_calculations.py:5: AssertionError
======================= 1 failed in 0.02s =====================

It shows the expression, the actual value, the expected value, and what produced it. A plain assert would have said AssertionError and nothing else.

This is pytest rewriting your asserts behind the scenes to report the values involved. It is the single best reason to use it over unittest, where you would write self.assertEqual(...) for the same information.

And it runs every test, reporting all failures rather than stopping at the first — so you see the shape of what broke.

Testing that something raises

import pytest


def test_rejects_percent_over_100():
    with pytest.raises(ValueError):
        apply_discount(1000, 150)

The test passes when the code inside raises ValueError, and fails if it raises nothing — which is the point. A function that quietly accepts bad input is the bug you are guarding against.

Check the message too, when it matters:

def test_error_message_names_the_value():
    with pytest.raises(ValueError, match="got 150"):
        apply_discount(1000, 150)

match is a regular expression searched against the message. Useful for confirming the error is the one you meant, not a coincidentally similar one.

Floating point

Module 2's warning, now in test form:

def test_third_of_a_hundred():
    assert apply_discount(100, 33.333) == 66.67

Comparing floats with == is unreliable. pytest has the tool:

from pytest import approx


def test_third_of_a_hundred():
    assert apply_discount(100, 33.333) == approx(66.67)

approx compares within a small tolerance. Use it for any float comparison in a test.

Running a subset

pytest                                  everything
pytest tests/test_calculations.py       one file
pytest -k discount                      tests whose name contains "discount"
pytest -x                               stop at the first failure
pytest -v                               list every test name
pytest -q                               quieter
pytest --lf                             only what failed last time

pytest --lf is the one you will use most while fixing something — it re-runs just the failures until they pass.

Fixtures

When several tests need the same setup:

import pytest
from expense_tracker.models import Expense


@pytest.fixture
def sample_expenses():
    return [
        Expense(250, "food"),
        Expense(1200, "transport"),
        Expense(15000, "rent"),
    ]


def test_total(sample_expenses):
    assert total(sample_expenses) == 16450


def test_count(sample_expenses):
    assert len(sample_expenses) == 3

A test asks for a fixture by naming it as a parameter, and pytest supplies it. The magic is worth being explicit about: the parameter name must match the fixture function name.

Each test gets a fresh result. The fixture runs again for every test, so one test modifying the list cannot affect another. Shared mutable state between tests is how suites become unreliable, and fixtures prevent it by default.

Temporary files

tmp_path is built in and removes the main excuse for not testing file code:

def test_saves_and_loads(tmp_path):
    path = tmp_path / "expenses.json"
    save(expenses, path)
    assert load(path) == expenses

A real directory, unique to that test, cleaned up afterwards. It is a Path from module 7, so / works.

Parametrising

Four near-identical tests:

def test_zero_percent(): assert apply_discount(1000, 0) == 1000.0
def test_ten_percent(): assert apply_discount(1000, 10) == 900.0
def test_fifty_percent(): assert apply_discount(1000, 50) == 500.0
def test_full_discount(): assert apply_discount(1000, 100) == 0.0

become one:

@pytest.mark.parametrize(
    "amount, percent, expected",
    [
        (1000, 0, 1000.0),
        (1000, 10, 900.0),
        (1000, 50, 500.0),
        (1000, 100, 0.0),
        (99.99, 50, 50.0),
    ],
)
def test_apply_discount(amount, percent, expected):
    assert apply_discount(amount, percent) == approx(expected)

Five separate tests, reported separately, so a failure names the exact row. And adding a case is one line, which means you will actually add it.

This is the feature that makes covering edge cases cheap. Use it whenever tests differ only in their data.

Making imports work

If pytest cannot find your package, the usual fix is the one from module 8:

python -m pip install -e .

That installs your project in editable mode, so from expense_tracker... resolves the same way it will in production. The src layout makes this the only way it can work, which was the argument for it.

Check your work

The failure output shows the expression, the actual value, the expected value, and what produced it:

E       assert 1100.0 == 900.0
E        +  where 1100.0 = apply_discount(1000, 10)

A plain assert gives you AssertionError and nothing else. This is the best reason to use pytest, and it comes from pytest rewriting your asserts to report the values involved.

Both failures are reported. pytest runs every test rather than stopping at the first, so you see the shape of what broke.

pytest.raises.

def test_rejects_percent_over_100():
    with pytest.raises(ValueError):
        apply_discount(1000, 150)

Remove the validation and this fails — which is the point. A function that quietly accepts bad input is the bug being guarded against.

Floats.

assert apply_discount(100, 33.333) == approx(66.67)

== on floats is unreliable, from module 2. Use approx for any float comparison in a test.

Fixtures give each test a fresh result. The fixture function runs again per test, so one test modifying the list cannot affect another. Shared mutable state between tests is how suites become unreliable.

tmp_path is a real directory, unique per test, cleaned up afterwards — and it is a Path, so / works. It removes the main excuse for not testing file code.

Parametrising reports each row as a separate test, so a failure names the exact case. And adding a case is one line, which means you will actually add it.

The flags. -v lists names, -k filters, -x stops at the first failure, --lf re-runs only what failed last time. --lf is the one you will use most.

Practice

  1. Install pytest. Write one passing test and run it.
  2. Break the function and read the failure output. Note what it tells you that a plain assert would not.
  3. Write a failing and a passing test in the same file. Confirm both are reported.
  4. Test that invalid input raises ValueError with pytest.raises. Then remove the validation and confirm the test fails.
  5. Add match and check the error message names the offending value.
  6. Write a float comparison that fails with ==, then fix it with approx.
  7. Write a fixture returning three objects and use it in two tests.
  8. Use tmp_path to test a function that writes and reads a JSON file.
  9. Convert four similar tests into one parametrize. Add a fifth case.
  10. Run pytest -v, -k, -x and --lf and see what each does.

Next: how to structure tests so they stay useful.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship