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 assert statement instead of assertEqual, assertTrue and 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 setUp and tearDown methods.
  • pytest also runs existing unittest test 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:

  1. The summary at the bottom: which tests failed (FAILED file::test_name) and how many passed.
  2. The section per failed test, headed by its name between underscores. It shows the test's source up to the failing line, marked with >.
  3. The E lines: the actual values. The cost was Decimal('0.47'), the expected value Decimal('0.34'). In the second test the where line explains where False came from: the call is_night(datetime(..., 23, 15)).
  4. 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.

  • skip and skipif(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 as x and does not break the run; if it unexpectedly passes, pytest reports XPASS, 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.