Skip to main content
Pytest beginner Lesson 1 of 10

Your First Pytest Test

Write a test, run it, and read pytest's output — including the assertion introspection that makes a failure tell you what actually went wrong.

pytest runs a function whose name starts with test_ and reports whether its assertions held. That is the whole model — everything else is convenience built on top.

Installing

pip install pytest
Successfully installed iniconfig-2.0.0 packaging-24.2 pluggy-1.5.0 pytest-8.3.4

Something to test

# shipping.py
def shipping_cost(weight_kg: float, express: bool = False) -> float:
    """Cost in GBP. Free over 10kg on standard delivery."""
    if weight_kg <= 0:
        raise ValueError(f"weight must be positive, got {weight_kg}")

    base = 3.99 if weight_kg < 2 else 6.99
    if weight_kg >= 10 and not express:
        base = 0.0
    return round(base * (2.5 if express else 1.0), 2)

The first test

# test_shipping.py
from shipping import shipping_cost


def test_light_parcel_costs_base_rate():
    assert shipping_cost(1.5) == 3.99
pytest
========================= test session starts =========================
platform linux -- Python 3.11.9, pytest-8.3.4, pluggy-1.5.0
rootdir: /home/you/shipping
collected 1 item

test_shipping.py .                                              [100%]

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

The . after the filename is the test passing. One character per test — pytest’s progress output is dense on purpose, because a real suite has thousands.

A failure that tells you something

This is the reason to use pytest. Add a test with the wrong expectation:

def test_heavy_parcel_is_free():
    assert shipping_cost(12) == 6.99
========================= test session starts =========================
collected 2 items

test_shipping.py .F                                             [100%]

============================== FAILURES ===============================
_______________________ test_heavy_parcel_is_free ______________________

    def test_heavy_parcel_is_free():
>       assert shipping_cost(12) == 6.99
E       assert 0.0 == 6.99
E        +  where 0.0 = shipping_cost(12)

test_shipping.py:9: AssertionError
======================= short test summary info =======================
FAILED test_shipping.py::test_heavy_parcel_is_free - assert 0.0 == 6.99
==================== 1 failed, 1 passed in 0.02s ======================

Read what it gave you from a bare assert: the failing line, both sides of the comparison (0.0 == 6.99), and the call that produced the left side. Python’s own assert would have printed AssertionError and nothing else.

That is assertion introspection — pytest rewrites your test module’s bytecode at import time so it can reconstruct the expression. It is why you never need assertEqual.

Comparing collections

The introspection gets more valuable as values get bigger:

def test_price_table():
    expected = {"small": 3.99, "medium": 6.99, "large": 6.99, "free": 0.0}
    actual = {
        "small": shipping_cost(1),
        "medium": shipping_cost(5),
        "large": shipping_cost(9),
        "free": shipping_cost(15),
    }
    assert actual == expected

Change one expected value and run it:

_____________________________ test_price_table _____________________________

    def test_price_table():
        expected = {"small": 3.99, "medium": 6.99, "large": 7.99, "free": 0.0}
        ...
>       assert actual == expected
E       AssertionError: assert {'free': 0.0, ...} == {'free': 0.0, ...}
E
E         Omitting 3 identical items, use -vv to show
E         Differing items:
E         {'large': 6.99} != {'large': 7.99}
E
E         Full diff:
E           {
E               'free': 0.0,
E         -     'large': 7.99,
E         ?               ^
E         +     'large': 6.99,
E         ?               ^
E               'medium': 6.99,
E               'small': 3.99,
E           }

It hid the three matching keys, named the differing one, and pointed at the character that differs. On a dict with fifty keys this is the difference between a two-second fix and ten minutes of squinting.

Testing that something raises

import pytest


def test_negative_weight_rejected():
    with pytest.raises(ValueError) as exc_info:
        shipping_cost(-1)

    assert "must be positive" in str(exc_info.value)
test_shipping.py::test_negative_weight_rejected PASSED            [100%]

The test passes only if the block raises ValueError. If it raises nothing:

E       Failed: DID NOT RAISE <class 'ValueError'>

And if it raises the wrong type, the original exception propagates so you see the real traceback rather than a vague assertion failure.

Match the message directly with match=, which takes a regex:

def test_error_names_the_bad_value():
    with pytest.raises(ValueError, match=r"got -1"):
        shipping_cost(-1)

Floats need approx

def test_express_surcharge():
    assert shipping_cost(1, express=True) == 3.99 * 2.5
>       assert shipping_cost(1, express=True) == 3.99 * 2.5
E       assert 9.98 == 9.975000000000001
E        +  where 9.98 = shipping_cost(1, express=True)

The function rounds; the test does not. Comparing floats for exact equality fails on almost any arithmetic:

def test_express_surcharge():
    assert shipping_cost(1, express=True) == pytest.approx(9.975, abs=0.01)
test_shipping.py::test_express_surcharge PASSED                   [100%]

pytest.approx also works on lists, dicts and numpy arrays, which saves writing a tolerance loop.

Useful command-line flags

pytest -v
test_shipping.py::test_light_parcel_costs_base_rate PASSED        [ 25%]
test_shipping.py::test_heavy_parcel_is_free PASSED                [ 50%]
test_shipping.py::test_negative_weight_rejected PASSED            [ 75%]
test_shipping.py::test_express_surcharge PASSED                   [100%]
pytest -k express            # only tests whose name matches
pytest test_shipping.py::test_price_table    # one specific test
pytest -x                    # stop at the first failure
pytest --lf                  # re-run only last-failed
pytest -q                    # quiet
pytest -s                    # do not capture stdout, so print() shows

--lf is the one that changes your day. After a run with twelve failures, fix one thing and pytest --lf re-runs only those twelve instead of the whole suite.

Captured output

pytest hides print from passing tests and shows it for failing ones:

def test_with_logging():
    print("computing cost for 5kg")
    cost = shipping_cost(5)
    print(f"got {cost}")
    assert cost == 99.99          # deliberately wrong
_____________________________ test_with_logging _____________________________
>       assert cost == 99.99
E       assert 6.99 == 99.99

---------------------------- Captured stdout call ---------------------------
computing cost for 5kg
got 6.99

The Captured stdout call section appears only on failure. That is why a print you added for debugging seems to vanish — the test passed. Use -s to see it regardless.

Practice

1. Write a test asserting shipping_cost(10) is free, and one asserting the express rate at 10kg is not.
def test_ten_kg_standard_is_free():
    assert shipping_cost(10) == 0.0


def test_ten_kg_express_still_charged():
    assert shipping_cost(10, express=True) == pytest.approx(17.48, abs=0.01)
test_shipping.py::test_ten_kg_standard_is_free PASSED             [ 50%]
test_shipping.py::test_ten_kg_express_still_charged PASSED        [100%]

10kg is the boundary — >= means it qualifies. Boundaries are where bugs live, so test the value itself, not just either side.

2. Rename a test function to check_something and run pytest.
collected 4 items

One fewer than before — it silently disappeared. pytest only collects functions starting with test_. When a test “isn’t running”, check the name first; there is no warning.

3. Assert two long lists differ and read the output.
E       AssertionError: assert [1, 2, 3, 4, 5...] == [1, 2, 3, 4, 6...]
E         At index 4 diff: 5 != 6
E         Use -v to get more diff

It names the first differing index rather than dumping both lists. Add -vv for the complete diff when the lists are large.

4. Use pytest.raises for an exception that is never raised.
E       Failed: DID NOT RAISE <class 'ValueError'>

A clear, specific failure. This matters because the opposite mistake — wrapping code in try/except and asserting nothing — passes silently whether or not the error occurs.

Next: fixtures, and how to stop repeating setup in every test.

Frequently Asked Questions

Why use pytest instead of unittest?
pytest uses plain assert statements and rewrites them so failures show the actual values, where unittest needs assertEqual and its variants. It also has fixtures, parametrize, and a large plugin ecosystem. unittest is in the standard library, which is its main advantage.
How does pytest find my tests?
It collects files matching test_*.py or *_test.py, then functions starting with test_ and classes starting with Test that have no __init__. Anything outside those naming rules is invisible to it, which is the usual reason a test appears not to run.
What is assertion introspection?
pytest rewrites the bytecode of your assert statements so that when one fails it can show both sides of the comparison. That is why a plain assert gives you a full diff rather than just AssertionError.
Should test files live next to the code or in a tests directory?
Either works. A separate tests/ directory keeps the package clean and is the common choice for libraries; tests alongside code are easier to keep in sync. Pick one and be consistent, because mixing them causes import confusion.