Unit Testing with pytest
Writing and running unit tests in Python with pytest: test functions and assertions, fixtures, parametrized tests, testing exceptions, and reading a failure report.
This page is the site's main pytest reference. The idea of a unit
test, and Python's built-in unittest module, are
introduced on
Debugging and Testing; here we go into the tool most Python teams use instead. The
running example is the Portal team's cost page at Kestrel Energy,
which prices each 15-minute reading at the day or night rate.
pytest
pytest is the most widely used test framework for Python: it finds test files and test functions, runs them, and reports what failed and why. It is a third-party package, not part of the standard library. As of October 2026 the current version is 9.1.1 (June 2026), which needs Python 3.10 or newer. Every example on this page was run with it.
Compared with unittest, pytest needs less code:
-
A test is a plain function, not a method in a class that inherits
from
TestCase. -
You check results with Python's own
assertstatement instead ofassertEqual,assertTrueand a dozen other methods, and still get a detailed message when it fails. -
Setup and clean-up are reusable fixtures that a test asks for by
name, instead of
setUpandtearDownmethods. -
pytest also runs existing
unittesttest classes, so a project can switch gradually.
Install it in the project's virtual environment, not system-wide, and pin the version the team uses:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install "pytest==9.1.1"
pytest --version
A first test
A test function is a function
whose name starts with test, which pytest calls without
arguments (apart from fixtures, below) and counts as passed when it
returns without an exception. A failing assert raises
AssertionError, so that is a failure; any other
exception is a failure too.
The function under test prices one reading. Prices are
Decimal, because money must not suffer from
floating-point rounding. On this page the code under test and the tests share one file,
test_example.py, so that each example is complete; in a
real project they live in separate files, as shown at the end.
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
DAY_PRICE = Decimal("0.3120") # euro per kWh, day_night contract
NIGHT_PRICE = Decimal("0.2240")
def is_night(at):
"""The night rate applies before 07:00 and from 23:00."""
return at.hour < 7 or at.hour >= 23
def energy_cost(kwh, at):
"""Cost of one reading in euro, rounded to cents."""
price = NIGHT_PRICE if is_night(at) else DAY_PRICE
return (Decimal(str(kwh)) * price).quantize(Decimal("0.01"), ROUND_HALF_UP)
def test_afternoon_reading_uses_day_price():
# Arrange
reading_time = datetime(2026, 10, 5, 14, 15)
# Act
cost = energy_cost(1.5, reading_time)
# Assert
assert cost == Decimal("0.47")
def test_reading_at_23_15_uses_night_price():
assert energy_cost(1.5, datetime(2026, 10, 5, 23, 15)) == Decimal("0.34")
def test_no_consumption_costs_nothing():
assert energy_cost(0, datetime(2026, 10, 5, 3, 0)) == Decimal("0.00")
... [100%] 3 passed in 0.31s
With -q (quiet) pytest prints one character per test: a
dot for a pass, F for a failure, s for
skipped and x for an expected failure, then a summary
line with counts and the run time. The names say what behavior is
checked, so a failing name in a pipeline log already tells you what
broke: test_reading_at_23_15_uses_night_price is better
than test_cost_2.
Arrange, act, assert
The first test is laid out in the arrange-act-assert pattern: first prepare the input and the state (arrange), then call the code under test once (act), then check the outcome (assert). It is a common convention, not a pytest rule, and the comments are only there to show it; in short tests a blank line between the parts is enough. Its value is that every test reads the same way, and that a test with two "act" steps stands out as two tests in one. Behavior-driven teams say the same thing as given, when, then (see Test-Driven Development).
Running tests
Run pytest in the project folder. The options you will
use most:
| Command | What it does |
|---|---|
pytest -q |
Short output: dots and a summary line |
pytest -v --no-header |
One line per test with its result; --no-header
leaves out the platform, version and folder lines
|
pytest -x |
Stop at the first failure |
pytest -k night |
Run only tests whose name matches the expression |
pytest tests/test_tariff.py::test_seven_is_day
|
Run one file, or one test in it |
pytest -m "not slow" |
Select tests by marker (see below) |
pytest --lf |
Rerun only the tests that failed last time (needs the cache
folder .pytest_cache)
|
pytest -ra |
Add a short summary of every test that did not simply pass |
pytest --tb=short |
Shorter tracebacks in failure reports |
pytest ends with an exit code that pipelines use: 0 when all tests passed, 1 when some failed, 2 when the run was interrupted, 5 when no tests were collected at all. A quality gate in the pipeline is often nothing more than "pytest must exit with 0".
Test discovery
Test discovery is how pytest
finds the tests to run without being told: it searches the given
folders (by default the current one, recursively) for files named
test_*.py or *_test.py, and in those files
collects functions whose names start with test, and
methods starting with test in classes whose names start
with Test (and that have no __init__).
Anything else is ignored, silently. That is convenient until a name
is slightly off:
cat > test.py <<'PY'
def test_night():
assert True
PY
cat > test_cost.py <<'PY'
def check_night():
assert True
PY
pytest -q
echo "exit code: $?"
no tests ran in 0.15s exit code: 5
test.py does not match test_*.py, and
check_night does not start with test, so
nothing ran. Exit code 5 makes a pipeline fail, which is good; on a
laptop, a green-looking "no tests ran" is easy to miss. If a test
you just wrote does not show up in pytest -v, check the
names first.
Reading a failure report
Here is the defect from
BUG-1041: someone
wrote > 23 where the night rule needs
>= 23. The early-morning test still passes, which is
exactly why this defect reached customers. A test at 23:15 does not
pass:
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
DAY_PRICE = Decimal("0.3120")
NIGHT_PRICE = Decimal("0.2240")
def is_night(at):
return at.hour < 7 or at.hour > 23 # the defect: should be >= 23
def energy_cost(kwh, at):
price = NIGHT_PRICE if is_night(at) else DAY_PRICE
return (Decimal(str(kwh)) * price).quantize(Decimal("0.01"), ROUND_HALF_UP)
def test_early_morning_reading_uses_night_price():
assert energy_cost(1.5, datetime(2026, 10, 5, 6, 45)) == Decimal("0.34")
def test_reading_at_23_15_uses_night_price():
expected = Decimal("0.34")
cost = energy_cost(1.5, datetime(2026, 10, 5, 23, 15))
assert cost == expected
def test_23_15_counts_as_night():
late = datetime(2026, 10, 5, 23, 15)
assert is_night(late)
.FF [100%]
=================================== FAILURES ===================================
____________________ test_reading_at_23_15_uses_night_price ____________________
def test_reading_at_23_15_uses_night_price():
expected = Decimal("0.34")
cost = energy_cost(1.5, datetime(2026, 10, 5, 23, 15))
> assert cost == expected
E AssertionError: assert Decimal('0.47') == Decimal('0.34')
test_example.py:24: AssertionError
__________________________ test_23_15_counts_as_night __________________________
def test_23_15_counts_as_night():
late = datetime(2026, 10, 5, 23, 15)
> assert is_night(late)
E assert False
E + where False = is_night(datetime.datetime(2026, 10, 5, 23, 15))
test_example.py:29: AssertionError
=========================== short test summary info ============================
FAILED test_example.py::test_reading_at_23_15_uses_night_price - AssertionErr...
FAILED test_example.py::test_23_15_counts_as_night - assert False
2 failed, 1 passed in 0.21s
Read a failure report in this order:
-
The summary at the bottom: which tests failed
(
FAILED file::test_name) and how many passed. -
The section per failed test, headed by its name
between underscores. It shows the test's source up to the failing
line, marked with
>. -
The
Elines: the actual values. The cost wasDecimal('0.47'), the expected valueDecimal('0.34'). In the second test thewhereline explains whereFalsecame from: the callis_night(datetime(..., 23, 15)). -
The location,
test_example.py:24: AssertionError, to jump to in your editor.
The two failures point at the same cause from two sides: the cost is
the day price, and is_night says 23:15 is not night. A
failure in your own code (not in an assert) shows a longer
traceback
with one section per function call; --tb=short makes it
more compact.
pytest can show those values because of
assertion rewriting: when
it imports a test module, it rewrites every
assert statement so that the intermediate values of the
expression are recorded and printed when the assertion fails. A
plain Python assert outside pytest would only say
AssertionError. Rewriting applies to test modules and
conftest.py files; an assert in a helper
module you import from your tests gives the short message unless you
register that module with
pytest.register_assert_rewrite.
Parametrized tests
The night rule has four boundaries: 06:59 and 07:00, 22:59 and 23:00
(and midnight, where night continues). Writing a test function per
time is repetitive, and a loop inside one test stops at the first
failure and hides the rest. A
parameterized test
runs the same test body once per set of inputs, each as a separate
test. In pytest that is the
@pytest.mark.parametrize decorator (a decorator is the
line starting with @ above a function; it changes or
registers the function). The values for the boundaries come from
boundary value analysis on
Test Design Techniques:
from datetime import time
import pytest
def is_night(at):
"""The night rate applies before 07:00 and from 23:00."""
return at.hour < 7 or at.hour >= 23
@pytest.mark.parametrize(
"at, expected",
[
(time(0, 0), True),
(time(6, 59), True),
(time(7, 0), False),
(time(22, 59), False),
(time(23, 0), True),
(time(23, 59), True),
],
ids=["00:00", "06:59", "07:00", "22:59", "23:00", "23:59"],
)
def test_is_night_at_the_boundaries(at, expected):
assert is_night(at) == expected
============================= test session starts ============================== collecting ... collected 6 items test_example.py::test_is_night_at_the_boundaries[00:00] PASSED [ 16%] test_example.py::test_is_night_at_the_boundaries[06:59] PASSED [ 33%] test_example.py::test_is_night_at_the_boundaries[07:00] PASSED [ 50%] test_example.py::test_is_night_at_the_boundaries[22:59] PASSED [ 66%] test_example.py::test_is_night_at_the_boundaries[23:00] PASSED [ 83%] test_example.py::test_is_night_at_the_boundaries[23:59] PASSED [100%] ============================== 6 passed in 0.16s ===============================
The first argument names the parameters, as one comma-separated
string; the list holds one tuple per case. The test function
receives the values as arguments with those names. The
ids give each case a readable name in the report;
without them pytest generates names such as at0-True,
which say nothing when a case fails in the pipeline. For a single
case you can also write
pytest.param(time(23, 0), True, id="23:00"). Had this
parametrized test existed, the 23:00 case would have
caught BUG-1041 before release.
Testing exceptions and floats
Code that should refuse bad input must be tested for that too. A
meter reading cannot be negative, and text where a number should be
is an error. pytest.raises is a
context manager
that passes only if the code inside the with block
raises the given exception; match also checks the
message (it is a regular expression, searched in the message):
import pytest
def parse_reading(line):
"""Turn 'M-30001;0.25' into ('M-30001', 0.25)."""
meter_id, kwh_text = line.split(";")
kwh = float(kwh_text)
if kwh < 0:
raise ValueError(f"{meter_id}: negative reading {kwh} kWh")
return meter_id, kwh
def test_valid_line_is_parsed():
assert parse_reading("M-30001;0.25") == ("M-30001", 0.25)
def test_negative_reading_is_rejected():
with pytest.raises(ValueError, match="negative reading"):
parse_reading("M-30002;-4.21")
def test_text_instead_of_a_number_is_rejected():
with pytest.raises(ValueError):
parse_reading("M-30003;n/a")
def test_two_quarter_hours_add_up():
assert 0.1 + 0.2 == pytest.approx(0.3)
.... [100%] 4 passed in 0.17s
Keep the code inside pytest.raises to the one call that
should fail. If the block holds three lines and the first one raises
ValueError for some other reason, the test passes for
the wrong reason.
The last test is about floats. In Python 0.1 + 0.2 is
0.30000000000000004, so
0.1 + 0.2 == 0.3 is False.
pytest.approx compares within a small relative
tolerance (one millionth of the expected value by default), which is
right for measurements such as kWh. For money, use
Decimal in the code and compare exactly, as in the
first example: an invoice that is "approximately right" is wrong.
Fixtures
A fixture in pytest is a
function, marked with @pytest.fixture, that prepares
something a test needs and hands it over; a test asks for it by
using the fixture's name as a parameter, and pytest calls the
fixture first and passes the result in. This is a form of
dependency injection: the test does not build its own data, it receives it.
pytest has built-in fixtures. tmp_path gives each test
its own empty temporary folder, so a test can write files without
touching real data or other tests. Here a fixture of our own,
export_file, uses it to create a small KBS export
(semicolon-separated, as the billing system writes it):
import pytest
def total_per_meter(path):
"""Add up the kWh per meter in a KBS export (semicolon-separated)."""
totals = {}
with open(path, encoding="utf-8") as export:
next(export) # skip the header line
for line in export:
meter_id, kwh = line.strip().split(";")
totals[meter_id] = totals.get(meter_id, 0) + float(kwh)
return totals
@pytest.fixture
def export_file(tmp_path):
path = tmp_path / "readings.csv"
path.write_text(
"meter;kwh\n"
"M-30001;0.25\n"
"M-30001;0.50\n"
"M-30002;1.75\n",
encoding="utf-8",
)
return path
def test_readings_are_added_per_meter(export_file):
assert total_per_meter(export_file) == {"M-30001": 0.75, "M-30002": 1.75}
def test_export_with_only_a_header_gives_no_totals(tmp_path):
empty = tmp_path / "empty.csv"
empty.write_text("meter;kwh\n", encoding="utf-8")
assert total_per_meter(empty) == {}
.. [100%] 2 passed in 0.23s
Fixtures can use other fixtures (export_file uses
tmp_path), and every test gets a fresh result: the
second test cannot see the first one's file. (For real CSV files
with quoting, use the
csv module; a plain split keeps this example short.)
Setup and teardown with yield
When something must be cleaned up after the test, the fixture uses
yield instead of return. Everything before
yield is setup, the yielded value goes to the test, and
everything after yield runs when the test is done,
whether it passed or failed:
import sqlite3
import pytest
def save_reading(connection, meter_id, kwh):
connection.execute("INSERT INTO readings VALUES (?, ?)", (meter_id, kwh))
def total_kwh(connection, meter_id):
row = connection.execute(
"SELECT COALESCE(SUM(kwh), 0) FROM readings WHERE meter_id = ?", (meter_id,)
).fetchone()
return row[0]
@pytest.fixture
def db():
connection = sqlite3.connect(":memory:") # setup
connection.execute("CREATE TABLE readings (meter_id TEXT, kwh REAL)")
yield connection # the test runs here
connection.close() # teardown, even if the test failed
def test_total_of_two_readings(db):
save_reading(db, "M-30001", 0.25)
save_reading(db, "M-30001", 0.50)
assert total_kwh(db, "M-30001") == 0.75
def test_each_test_gets_a_fresh_database(db):
assert total_kwh(db, "M-30001") == 0
.. [100%] 2 passed in 0.19s
By default a fixture runs once per test (scope
function). With
@pytest.fixture(scope="module") or
scope="session" it runs once per file or once per whole
run, which saves time for something expensive, such as starting a
database server, but then tests share the object and can disturb
each other. Start with the default and widen the scope only when the
run is too slow. An in-memory SQLite database is fine for a unit
test; whether it is good enough for testing queries that run on
PostgreSQL in production is a question for
Integration and Contract Testing.
Other built-in fixtures you will meet: monkeypatch
(temporarily replace a function or environment variable, see
Test Doubles),
capsys (capture what the code prints) and
caplog (capture log records).
Markers: skip, xfail and your own
A marker is a label attached
to a test with @pytest.mark.<name>. Some markers
change how pytest treats the test; others are only labels to select
tests by. parametrize is a marker too.
-
skipandskipif(condition, reason=...): do not run the test (here, or under this condition). -
xfail(reason=...): the test is expected to fail, for example because of a known open bug. It is reported asxand does not break the run; if it unexpectedly passes, pytest reportsXPASS, a sign the bug may be fixed. -
Your own names, such as
slow, to run a subset with-m.
import sys
from datetime import date
import pytest
def quarter_hours_in_day(day):
return 96 # known defect BUG-1042: ignores daylight saving time
def test_normal_day_has_96_quarter_hours():
assert quarter_hours_in_day(date(2026, 10, 7)) == 96
@pytest.mark.xfail(reason="BUG-1042: 25 Oct 2026 has 100 quarter-hours")
def test_day_the_clocks_go_back():
assert quarter_hours_in_day(date(2026, 10, 25)) == 100
@pytest.mark.skipif(sys.platform != "win32", reason="Windows file shares only")
def test_export_to_windows_share():
assert True
@pytest.mark.slow
def test_whole_year():
assert sum(quarter_hours_in_day(date(2026, 1, 1)) for _ in range(365)) == 35040
.xs. [100%]
=============================== warnings summary ===============================
test_example.py:25
test_example.py:25: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
SKIPPED [1] test_example.py:20: Windows file shares only
XFAIL test_example.py::test_day_the_clocks_go_back - BUG-1042: 25 Oct 2026 has 100 quarter-hours
2 passed, 1 skipped, 1 xfailed, 1 warning in 0.20s
-rsx adds the reasons for skipped (s) and
expected-to-fail (x) tests to the summary. The warning
is useful: pytest does not know slow, so a typo such as
@pytest.mark.slwo would otherwise go unnoticed and the
test would run in every quick build. Register your markers in the
configuration, as in the next section.
A project layout and configuration
In a real project the code lives in src/ and the tests
in tests/, and the settings in
pyproject.toml. Since pytest 9.0 the settings can be a
native TOML table, [tool.pytest], with real lists and
booleans; older projects use [tool.pytest.ini_options],
which still works (but not both in one file). This example builds
such a project in an empty folder, lists the tests pytest discovers,
and then runs everything except the slow test:
mkdir -p src tests
cat > pyproject.toml <<'TOML'
[tool.pytest]
testpaths = ["tests"]
pythonpath = ["src"]
markers = ["slow: takes more than a few seconds"]
TOML
cat > src/tariff.py <<'PY'
def is_night(hour):
return hour < 7 or hour >= 23
PY
cat > tests/conftest.py <<'PY'
import pytest
@pytest.fixture
def night_hours():
return [0, 1, 2, 3, 4, 5, 6, 23]
PY
cat > tests/test_tariff.py <<'PY'
import pytest
from tariff import is_night
def test_every_night_hour_is_night(night_hours):
assert all(is_night(hour) for hour in night_hours)
def test_seven_is_day():
assert not is_night(7)
@pytest.mark.slow
def test_a_whole_year_has_8_night_hours_a_day(night_hours):
hours = [hour % 24 for hour in range(365 * 24)]
assert sum(is_night(hour) for hour in hours) == 365 * len(night_hours)
PY
cat > tests/test_export.py <<'PY'
def test_export_uses_night_hours(night_hours):
assert len(night_hours) == 8
PY
cat > tests/check_prices.py <<'PY'
def test_never_collected():
assert False
PY
pytest --collect-only -q
echo "---"
pytest -q -m "not slow"
tests/test_export.py::test_export_uses_night_hours tests/test_tariff.py::test_every_night_hour_is_night tests/test_tariff.py::test_seven_is_day tests/test_tariff.py::test_a_whole_year_has_8_night_hours_a_day 4 tests collected in 0.19s --- ... [100%] 3 passed, 1 deselected in 0.18s
testpaths tells pytest where to look,
pythonpath makes
from tariff import is_night work without installing the
package, and markers registers slow.
check_prices.py was not collected: its name does not
match test_*.py, so its failing test never ran.
--collect-only is the quickest way to check discovery.
Sharing fixtures with conftest.py
Both test files used night_hours without importing it.
That fixture lives in
conftest.py, a file pytest loads
automatically before the tests in its folder and all subfolders;
fixtures and hooks defined there are available to every test below
it, without an import. Put shared fixtures (a test database, a test
customer, a fixed clock) in the conftest.py of the
tests/ folder, and fixtures only one file needs in that
file. Never import from conftest.py yourself.