pytest / Chapter 1 of 4

Fixtures, assertions, and parametrization

Write pytest assertions, fixtures, and parametrized tests. Examples cover fixture scope, teardown, temporary files, monkeypatch, and test selection.

12 topicsUpdated
01 / 26

First tests and installation#

A test is a plain Python function that checks one behavior with assert. pytest finds and runs it.

Install pytest in your project environment. Name files test_*.py or *_test.py, functions test_*, and classes Test* with no custom __init__. A passing assertion is silent; a failing one shows compared values and the line that failed.

ShellInstall and run
python -m pip install -U pytest
python -m pytest
python -m pytest tests/test_cart.py
Pythontests/test_cart.py
from decimal import Decimal


def total(prices: list[Decimal], tax: Decimal) -> Decimal:
    subtotal = sum(prices, Decimal("0"))
    return (subtotal * (1 + tax)).quantize(Decimal("0.01"))


def test_total_adds_tax_and_rounds():
    result = total([Decimal("19.99"), Decimal("2.50")], Decimal("0.10"))
    assert result == Decimal("24.74")

python -m pytest runs pytest with the current interpreter, which helps when multiple Python environments exist. Exit code 0 means tests passed; 1 means tests failed; 5 means none were collected. An import or fixture setup problem appears as an error, distinct from a failed assertion.

Start small
Put production code in a package and tests under tests/. Run one test file while working, then the entire suite before committing.
02 / 26

Assertions and comparisons#

Write assertions with normal Python expressions. pytest adds compared values to failure reports.

Write the behavior you expect directly. Avoid a bare assert response when the interesting requirement is a status, body, or field. Plain assert statements are stripped by Python's optimized -O mode; run tests normally.

PythonExpress the actual requirement
from collections import Counter


def test_invoice_has_expected_lines(invoice):
    assert invoice.currency == "USD"
    assert Counter(line.sku for line in invoice.lines) == Counter(["A-1", "B-2"])
    assert invoice.total_cents == 2450
    assert all(line.quantity > 0 for line in invoice.lines)

Use pytest.approx for floating point calculations. It compares within tolerance (rel and abs); it also handles lists and dictionaries of numeric values. For money, prefer Decimal and exact equality.

PythonFloating point and messages
import pytest


def test_exchange_rate():
    actual = 1.1 * 3
    assert actual == pytest.approx(3.3, rel=1e-10, abs=0)


def test_result_shape(result):
    assert "id" in result, f"missing id in {result!r}"

For large data structures, pytest displays a diff. Use -vv for fuller diffs, --tb=short for a shorter traceback, or --tb=long for all frames. pytest.fail("reason") fails explicitly; pytest.skip("reason") skips at runtime.

ShellControl failure detail
python -m pytest -vv --tb=short
python -m pytest -x --maxfail=1
03 / 26

Run and select tests#

pytest discovers a suite by default; file paths, node IDs and selection expressions narrow it.

A node ID identifies a collected test such as tests/test_cart.py::TestCart::test_checkout. A parametrized case adds a bracketed ID. Quote bracketed IDs so your shell does not interpret them.

ShellCommon run commands
python -m pytest
python -m pytest tests/unit/
python -m pytest tests/test_cart.py::TestCart::test_checkout
python -m pytest 'tests/test_cart.py::test_total[bulk]'
python -m pytest -k 'checkout and not slow'
python -m pytest -m 'smoke and not network'
python -m pytest --collect-only -q

-k matches test names, class names, files and other keywords. -m selects registered markers. -q is quiet; -v is verbose; -s disables output capture; -x stops after the first failure. --lf reruns last failures and --ff runs last failures first. --stepwise resumes until a failure, then pauses the run.

ShellUseful feedback loops
python -m pytest -q --lf
python -m pytest --ff -x
python -m pytest -ra --durations=10
python -m pytest --disable-warnings
python -m pytest --help

-ra expands outcome summaries for skipped and expected failures. --durations=10 reports slow tests. -k accepts a substring expression.

04 / 26

Fixtures: reusable setup#

A fixture is a function pytest calls before a test and injects by matching a parameter name.

Use fixtures for dependencies or setup that several tests share. A fixture's return value is reused within one test even if multiple fixtures request it. The default scope is function: a fresh value for every test.

Pythontests/conftest.py — shared by tests below it
import pytest


@pytest.fixture
def cart():
    return {"items": [], "discount": 0}


@pytest.fixture
def filled_cart(cart):
    cart["items"].extend([{"sku": "A-1", "price": 12}, {"sku": "B-2", "price": 8}])
    return cart
Pythontests/test_cart.py

def test_new_cart_is_empty(cart):
    assert cart["items"] == []


def test_filled_cart_has_two_items(filled_cart):
    assert len(filled_cart["items"]) == 2
    assert sum(item["price"] for item in filled_cart["items"]) == 20

Define a fixture in the test module when it is local to that file. Put it in conftest.py to share with that directory and its descendants; pytest discovers it automatically, so tests do not import it. A fixture can request another fixture, building a dependency graph. Avoid directly calling a decorated fixture as a normal function.

ShellDiscover fixture names and origins
python -m pytest --fixtures tests/
python -m pytest --setup-show tests/test_cart.py
05 / 26

Fixture teardown and scope#

Use yield to clean up resources; scope controls how often setup runs.

Code before yield sets up the fixture; code after yield runs when the fixture is finished, even if the test fails. If setup raises before yield, that fixture has no teardown; previously completed fixtures still tear down. Keep one resource and its cleanup together.

PythonA resource with guaranteed cleanup
import pytest


@pytest.fixture
def opened_report(tmp_path):
    path = tmp_path / "report.csv"
    stream = path.open("w+", encoding="utf-8")
    try:
        yield stream
    finally:
        stream.close()


def test_report_is_written(opened_report):
    opened_report.write("sku,total\nA-1,20\n")
    opened_report.flush()
    assert opened_report.name.endswith("report.csv")

scope="function" is the default. class, module, package, and session reuse one fixture instance across progressively more tests. Wider scopes speed up expensive setup, but shared mutable state can make tests depend on order. A wider scoped fixture cannot request a narrower scoped fixture.

PythonScoped setup and explicit finalizer
@pytest.fixture(scope="session")
def service_endpoint():
    return "http://127.0.0.1:8080"


@pytest.fixture
def registered_user(request, api_client):
    user = api_client.create_user(email="[email protected]")
    request.addfinalizer(lambda: api_client.delete_user(user["id"]))
    return user

Use request.addfinalizer when cleanup must be registered as soon as a particular side effect succeeds. A yield fixture is usually clearer. Use @pytest.fixture(autouse=True) sparingly for invariants every test truly needs, such as clearing a process-wide registry.

06 / 26

Parametrize cases#

One test function can run for many inputs; each data row becomes a separately reported case.

@pytest.mark.parametrize is useful for boundary conditions, tricky Unicode, empty inputs and invalid values. Give cases stable id values so failing nodes are easy to rerun. Parameter objects are passed as is, so mutating a shared list or dictionary can affect later cases.

PythonA table of behaviors
import pytest


@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        pytest.param("  Alice  ", "alice", id="trim-and-lower"),
        pytest.param("ÉMILIE", "émilie", id="unicode"),
        pytest.param("", "", id="empty"),
    ],
)
def test_normalize_name(raw, expected):
    assert raw.strip().lower() == expected
ShellSelect one generated case
python -m pytest -q tests/test_names.py
python -m pytest 'tests/test_names.py::test_normalize_name[unicode]'

Stacking parametrization decorators creates the Cartesian product: the example below runs four cases. The closest decorator varies first. ids can also be a list or callable. For input cases that require expensive fixture setup, see advanced parametrization below.

PythonEvery combination
@pytest.mark.parametrize("currency", ["USD", "EUR"])
@pytest.mark.parametrize("quantity", [1, 10])
def test_line_total(currency, quantity):
    line = {"currency": currency, "total": quantity * 9}
    assert line["total"] > 0
07 / 26

Exceptions, warnings and errors#

Check the error type and its message or attributes, and distinguish an expected exception from a test error.

pytest.raises passes only if the code in its with block raises the expected type. Keep that block as narrow as possible. match= applies a regular expression to the exception text, so escape punctuation when needed.

PythonCheck an invalid purchase
import pytest


def purchase(stock, quantity):
    if quantity > stock:
        raise ValueError(f"requested {quantity}, only {stock} left")
    return stock - quantity


def test_rejects_oversell():
    with pytest.raises(ValueError, match=r"only 2 left") as exc:
        purchase(stock=2, quantity=3)
    assert exc.value.args[0].startswith("requested 3")

pytest.warns verifies emitted warnings; pytest.deprecated_call checks deprecation warnings. pytest.raises and pytest.warns fail when no expected event occurs. For warnings emitted at import time, wrap the import or use warning filters in configuration.

PythonWarning assertions
import warnings


def legacy_parse(value):
    warnings.warn("legacy parser", DeprecationWarning, stacklevel=2)
    return value.strip()


def test_legacy_warning():
    with pytest.warns(DeprecationWarning, match="legacy parser") as record:
        assert legacy_parse(" x ") == "x"
    assert len(record) == 1
Do not hide failures
A broad with pytest.raises(Exception) can accidentally pass when setup or an unrelated bug raises. Assert the narrow exception type and move setup outside the block.
08 / 26

Files and temporary directories#

tmp_path gives each test its own pathlib directory that pytest manages.

Write inputs under tmp_path and pass the path to the code under test. The directory is isolated between tests. tmp_path_factory can create a directory shared by a wider scoped fixture.

PythonA small file-processing example
import csv


def read_positive_totals(path):
    with path.open(newline="", encoding="utf-8") as stream:
        return [int(row["total"]) for row in csv.DictReader(stream) if int(row["total"]) > 0]


def test_csv_reader_skips_nonpositive_rows(tmp_path):
    path = tmp_path / "orders.csv"
    path.write_text("total,sku\n12,A\n0,B\n-3,C\n", encoding="utf-8")
    assert read_positive_totals(path) == [12]
PythonBuild data once per session
import pytest


@pytest.fixture(scope="session")
def sample_csv(tmp_path_factory):
    path = tmp_path_factory.mktemp("samples") / "large.csv"
    path.write_text("total,sku\n9,X\n", encoding="utf-8")
    return path

Use --basetemp only when you intentionally want a fixed temporary base directory; pytest clears it at the start of a run. Older tmpdir returns py.path.local; prefer tmp_path in new code.

ShellInspect a known temporary base
python -m pytest --basetemp=.pytest-tmp tests/test_csv.py
09 / 26

Monkeypatch and mocks#

Replace a dependency at the place the code under test looks it up, then assert observable behavior.

monkeypatch is built in: it restores attributes and environment variables after the test. unittest.mock provides call assertions and AsyncMock. Patch app.billing.gateway.charge when billing looks up gateway.charge, even if the real function was originally defined elsewhere.

PythonReplace a module dependency
# app/billing.py
from app import gateway


def checkout(order):
    receipt = gateway.charge(order["total_cents"])
    return {"order_id": order["id"], "receipt": receipt}

# tests/test_billing.py
from app import billing


def test_checkout_uses_charge(monkeypatch):
    calls = []

    def fake_charge(amount):
        calls.append(amount)
        return "rcpt-42"

    monkeypatch.setattr(billing.gateway, "charge", fake_charge)
    assert billing.checkout({"id": 7, "total_cents": 1200}) == {
        "order_id": 7, "receipt": "rcpt-42"
    }
    assert calls == [1200]
PythonEnvironment and unittest.mock
from unittest.mock import Mock, patch
import os


def test_environment(monkeypatch):
    monkeypatch.setenv("APP_MODE", "test")
    assert os.environ["APP_MODE"] == "test"
    monkeypatch.delenv("APP_MODE")


def test_call_contract():
    with patch("app.billing.gateway.charge", autospec=True) as charge:
        charge.return_value = "rcpt-17"
        assert billing.checkout({"id": 3, "total_cents": 50})["receipt"] == "rcpt-17"
        charge.assert_called_once_with(50)

monkeypatch.setitem(mapping, key, value), delitem, syspath_prepend, and chdir handle other global inputs. Use monkeypatch.context() for a patch that must end before test teardown. A third-party mocker fixture is supplied by pytest-mock, not by pytest itself.

10 / 26

Print output, logs and warnings#

pytest captures stdout, stderr and logs to make failure reports useful.

capsys captures Python writes to sys.stdout and sys.stderr. capfd captures output written to OS file descriptors, useful for subprocesses or C extensions. Calling readouterr() returns captured text so far and resets the fixture's capture buffer.

PythonCapture command output

def show_invoice(invoice):
    print(f"Invoice {invoice['id']}: {invoice['total']}")


def test_invoice_output(capsys):
    show_invoice({"id": 7, "total": "24.50"})
    out, err = capsys.readouterr()
    assert out == "Invoice 7: 24.50\n"
    assert err == ""

caplog records Python logging records and text; select a level with caplog.at_level. A handler that replaces the root logger can interfere with capture, so use normal loggers in the tested code. For interactive debugging, use -s or --capture=tee-sys (live output plus captured output).

PythonAssert a structured log
import logging


def test_retries_are_logged(caplog):
    with caplog.at_level(logging.WARNING, logger="app.worker"):
        logging.getLogger("app.worker").warning("retry order=%s", 42)
    assert ("app.worker", logging.WARNING, "retry order=42") in caplog.record_tuples
ShellCapture modes
python -m pytest -s
python -m pytest --capture=tee-sys
python -m pytest --log-cli-level=INFO
11 / 26

Mark, skip and expect failure#

Markers attach metadata to tests; built-in marks affect outcomes and custom marks select groups.

Register custom marks in config, then run them with -m. skipif skips under a condition. xfail is for known broken behavior; strict=True turns an unexpected pass into a failure, prompting you to remove the mark. pytest.importorskip skips when an optional dependency is unavailable.

PythonOutcome and selection markers
import sys
import pytest


@pytest.mark.smoke
def test_health(client):
    assert client.get("/health").status_code == 200


@pytest.mark.skipif(sys.platform == "win32", reason="uses Unix sockets")
def test_unix_transport():
    assert True


@pytest.mark.xfail(reason="upstream bug #123", strict=True)
def test_discount_rounding():
    assert round(2.675, 2) == 2.68


def test_optional_parser():
    parser = pytest.importorskip("orjson")
    assert parser.loads(b'{"ok":true}')["ok"]
ShellList, filter and enforce markers
python -m pytest --markers
python -m pytest -m 'smoke and not network'
python -m pytest --strict-markers

pytest.mark.usefixtures("name") requests a fixture without a function argument, useful when only its setup side effect matters. pytestmark = pytest.mark.slow marks all tests in a module. Avoid marking ordinary failures as xfail indefinitely.

12 / 26

Project configuration#

Keep default paths, markers and test policy in one config file; CLI options can override many settings.

Use pyproject.toml for modern projects. The [tool.pytest.ini_options] table supports the established ini-style options. pytest 9 also supports native [tool.pytest] with TOML-native types; choose one format and check your installed version before switching. pytest.ini has its own [pytest] section and takes precedence over pyproject.toml when both apply.

TOMLpyproject.toml
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
pythonpath = ["."]
addopts = "-ra --strict-markers --strict-config"
markers = [
  "smoke: fast checks of core behavior",
  "network: requires network access",
  "integration: uses external services",
]
filterwarnings = [
  "error",
  "ignore:legacy parser:DeprecationWarning:third_party_module",
]
INIEquivalent pytest.ini
[pytest]
minversion = 8.0
testpaths = tests
addopts = -ra --strict-markers --strict-config
markers =
    smoke: fast checks of core behavior
    integration: uses external services

--strict-markers rejects misspelled marker names. --strict-config turns config warnings into errors. filterwarnings can make new warnings fail the suite; scope intentional exceptions narrowly. Discovery options include python_files, python_classes, python_functions, norecursedirs, and --ignore. -c path chooses a config file; --rootdir path changes the project root. PYTEST_ADDOPTS appends CLI options.

ShellInspect and override configuration
python -m pytest -c pyproject.toml --collect-only -q
python -m pytest -o addopts='' tests/unit/
python -m pytest --trace-config