Definition
Writing code and hoping it works only holds up for so long. pytest checks it for you and names what fails.
A test, here, is a plain function whose name starts with test_, and which holds at least one assert. No class to create, no special method to call: the function is the test.
An example, on a basket that adds up prices.
# file test_basket.py
from basket import total
def test_adds_the_prices():
assert total([2.5, 3.5]) == 6.0
def test_empty_basket_is_zero():
assert total([]) == 0The command runs from the root of the project, with no argument: the tool looks for test_*.py files, imports them, calls every test* function, and details each failure.
pytestpytest is not part of the standard library: it is installed with pip, preferably inside a virtual environment belonging to the project.
The problem it solves
With no test tool, checking that a program still works after a change means running it by hand and reading whatever a print puts on screen here and there. That method holds up to two hundred lines, while a project stays small.
Beyond that, nobody replays the fifteen edge cases of last month from memory, and one fix breaks another with nothing to report it.
An automated test freezes the check. The question asked after every change is no longer "does it still work", answered from memory, but "what stopped working", answered by the tool naming the file, the line and the value it got.
Against unittest
Python already ships unittest, a testing module modelled on JUnit. Here is what is gained, and what is lost, by installing a second one.
| On this point | unittest | pytest |
|---|---|---|
| Install | Included in Python | To be installed |
| Writing a test | A method inside a class | A free function |
| Checking | Some thirty assertX methods | The assert keyword alone |
| Preparing data | setUp, before every test | Fixtures asked for by name |
| Failure message | Both values if the right method was picked | Both values, always |
The last row is what tips most projects over: with pytest, a failing assert result == expected is enough, the tool rewrites the expression and shows both sides. With unittest, someone would have had to guess the right assertX method among some thirty of them.
The other way round, unittest keeps a real advantage: no dependency to install, which matters for a library shipped to other people. pytest runs tests written for unittest anyway, so the move can be made one file at a time.
The first-day trap
The test_ prefix on the file and on the function is not a matter of style: it is the very mechanism by which pytest finds what to run.
A file called basket_test.py or a function called check_total never run. The tool announces collected 0 items, exits in a fraction of a second and reports no failure at all. Nothing turned green: nothing was run, sometimes for weeks.
The second setback is a ModuleNotFoundError on your own code, while the file sits right next to it. pytest adds the root it detected to the import path, not the current folder: running the command from the root of the project settles most cases, and installing the project in editable mode settles the rest.
Fixtures, parametrising and expected exceptions
Three needs come up often. The first: data that several tests share, a basket already filled for instance, declared once as a fixture with a decorator and asked for by every test that names it among its arguments.
The second: a check to replay over several values, without duplicating the function, thanks to parametrising, each value counted as a separate test.
The third: checking that an exception is indeed raised, through a dedicated context manager that fails if nothing was raised inside it.
All three, gathered in a single file.
import pytest
from basket import total
@pytest.fixture
def sample_basket():
return [2.5, 3.5, 4.0]
def test_total(sample_basket):
assert total(sample_basket) == 10.0
@pytest.mark.parametrize("price", [-1, -0.5])
def test_rejects_a_negative_price(price):
with pytest.raises(ValueError):
total([price])What it does not prove
A green suite does not say the code is correct. It only says the cases that were written behave as expected, which is very different.
The classic mistake is testing the language rather than one's own logic: checking that an addition gives the right total, or chasing a coverage percentage. Those tests always pass, because they cover behaviour Python already guarantees, and so they never catch anything.
A useful test covers a rule that could change by accident: a capped discount, a rounding, an expected refusal. And pytest replaces neither ruff, which reviews the shape of the code, nor mypy, which checks type consistency without running anything. The three catch faults of a different nature, complementary rather than redundant.
Frequently asked questions
Is pytest worth installing when Python already ships unittest?
In most projects, yes. The shorter syntax makes people write more tests, and it is the number of tests actually written that protects a codebase, never their elegance. The real exception is a library shipped to others, where unittest keeps the advantage of imposing no extra dependency.
How many tests should a project write at the start?
Fewer than you would imagine. One test on the calculation that carries the value of the product, then one test per fixed bug, the one reproducing the bug before the fix is even written. That rule builds a useful suite without ever spending a dedicated session on it.
How can a single test be run instead of the whole suite?
By following the file path with :: and the name of the function, for instance pytest tests/test_basket.py::test_total. The -k option filters by name fragment and -x stops everything at the first failure, which saves reading fifty errors coming from one single cause.