DevLogTechnical writing
Tutorials

Unit test Python with pytest

September 20, 2026

Tests are the safety net that lets you refactor a function without holding your breath. pytest is the standard, friendly way to write them in Python: there's no test class to subclass and no ceremony — a test is just a plain function whose name starts with test_, and you check expectations with the built-in assert statement.

Install

pytest is a regular package, so install it from PyPI — preferably inside a virtual environment:

pip install -U pytest

Confirm it's there:

pytest --version

Write your first test

A real project keeps code and tests close together. Create test_sample.py with a function to test and a test for it:

def func(x):
    return x + 1

def test_answer():
    assert func(3) == 5

The second function is the test. Naming matters because pytest discovers tests by name: it collects files named test_*.py or *_test.py, and inside them, functions named test_*. Now run it from that directory:

pytest

You'll see the result — in this case one test passes. Now change the assertion to == 4, which is true, and run again.

Assert with plain Python

Nothing special is involved in assert func(3) == 5 — it's the ordinary Python assertion. What pytest adds is advanced assertion introspection: when an assertion fails, it rewrites the message to show the intermediate values involved, so you learn why it failed instead of just that it failed. There's no JUnit-style assertEqual to remember.

Running a single file narrows the noise:

pytest test_sample.py

Test many inputs with parametrize

Copy-pasting a test to check one more input gets stale fast. @pytest.mark.parametrize runs the same test body once per input set:

import pytest

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

@pytest.mark.parametrize("a,b,expected", [
    (1, 2, 3),
    (2, 3, 5),
    (5, 3, 18),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

pytest runs test_add three times — the decorator's second argument is a list of (a, b, expected) triples used in turn — and reports each as its own test. The third case is deliberately wrong, so you'll see exactly one failure: the introspection shows that add(5, 3) returned 8, not 18. That's the workflow: one test body, many inputs, each failure attributable to its exact case.

Expect an exception with pytest.raises

Testing that something should throw is a test too. pytest.raises asserts that an exception of a given type is raised:

import pytest

def divide(a, b):
    return a / b

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)

The with block must raise a ZeroDivisionError (or a subclass) — if it doesn't, the test fails. You can also match on the message with pytest.raises(ValueError, match="must be 0"), which is handy for asserting the reason a function refuses bad input, not just that it refused.

What's next

Run pytest in watch mode while you work, look at fixtures (@pytest.fixture) for setting up shared state, and explore --tb=short or -x to stop at the first failure during debugging. Once a codebase has tests, the bigger win is using a coverage tool to see which lines are actually exercised — that's where untested paths hide.

← More Tutorials