Skip to content
End To End Tester

pytest

Python's test framework — plain assert, the fixture model that makes it different, parameterisation, and the plugins worth having.

1 min read · updated 19 September 2026

pytest has two features that set it apart: plain assert with rewritten introspection, and fixtures as a dependency-injection system for tests.

#Plain assert

python
def test_discount_applies_at_the_threshold():
    result = apply_discount(Order(subtotal_cents=10_000), POLICY)

    assert result.discount_cents == 2_000

When it fails, pytest rewrites the assertion to show both sides, including diffs of dicts and lists:

E       assert 1999 == 2000
E        +  where 1999 = Discount(discount_cents=1999, code='SUMMER').discount_cents

No assertion vocabulary to learn, and better failure output than most frameworks with one.

#Fixtures

A test declares what it needs by naming it as a parameter.

python
# conftest.py — fixtures here are available to every test below it
import pytest

@pytest.fixture(scope="session")
def postgres():
    """One container for the whole run: expensive and read-only."""
    with PostgresContainer("postgres:16-alpine") as container:
        run_migrations(container.get_connection_url())
        yield container            # everything after yield is teardown

@pytest.fixture
def connection(postgres):
    """A fresh transaction per test, rolled back afterwards."""
    conn = psycopg.connect(postgres.get_connection_url())
    tx = conn.transaction()
    tx.__enter__()
    yield conn
    tx.__exit__(Exception, None, None)   # always roll back
    conn.close()

@pytest.fixture
def repository(connection):
    return OrderRepository(connection)
python
# The test names only what it needs; the graph is resolved for it.
def test_round_trips_money_without_losing_precision(repository):
    repository.save(Order(reference="REF-1", amount_cents=199_999))

    assert repository.find("REF-1").amount_cents == 199_999

That composition is the thing. repository needs connection needs postgres, each with its own lifetime, and no test knows about any of it.

Scope discipline matters. function (default) → classmodulepackagesession. Use the narrowest that is fast enough; a session-scoped fixture that tests mutate is exactly the shared-state problem described in test data management.

#Parameterisation

python
@pytest.mark.parametrize("subtotal,expected", [
    (12_000, 2_400),
    (10_000, 2_000),    # the boundary
    (9_999, 0),
])
def test_discount_applies_at_or_above_the_threshold(subtotal, expected):
    assert apply_discount(Order(subtotal), POLICY).discount_cents == expected


# Stacking produces the cartesian product: 3 x 2 = 6 tests
@pytest.mark.parametrize("tier", ["standard", "gold", "platinum"])
@pytest.mark.parametrize("region", ["UK", "EU"])
def test_shipping_is_priced_everywhere(tier, region):
    assert shipping_for(tier, region).cents >= 0


# ids make the report readable when the values do not
@pytest.mark.parametrize(
    "token,valid",
    [(expired_token(), False), (future_token(), False), (current_token(), True)],
    ids=["expired", "not-yet-valid", "current"],
)
def test_token_validation(token, valid):
    assert is_valid(token) is valid

Parameterising a fixture runs every test that uses it once per value — which is how you run a whole suite against two database backends.

#Doubles

python
# monkeypatch: scoped, automatically undone at the end of the test
def test_reads_the_configured_region(monkeypatch):
    monkeypatch.setenv("AWS_REGION", "eu-west-2")

    assert current_region() == "eu-west-2"


# unittest.mock for objects
from unittest.mock import Mock, patch

def test_notifies_the_customer_once_shipped():
    mailer = Mock()

    ship(order, mailer=mailer)

    mailer.send.assert_called_once()
    assert "on its way" in mailer.send.call_args.kwargs["subject"]


# patch where it is USED, not where it is defined — the usual gotcha
@patch("shipping.service.fetch_rates")
def test_falls_back_when_rates_are_unavailable(fetch_rates):
    fetch_rates.side_effect = TimeoutError

    assert quote(order).cents == FALLBACK_SHIPPING_CENTS

patch("shipping.service.fetch_rates") rather than patch("rates.client.fetch_rates") is the single most common pytest mocking mistake: you must patch the name in the module that imported it.

#Plugins worth having

Plugin What it does
pytest-xdist -n auto — parallel across cores
pytest-cov coverage integration
pytest-randomly shuffles order, exposing test interdependence
pytest-asyncio async def tests
freezegun freeze the clock
responses / respx HTTP stubbing for requests / httpx
pytest-timeout kill a hung test rather than hanging the build

pytest-randomly is the underrated one. A suite that fails when shuffled has an order dependency, and finding it early is far cheaper than finding it when you turn on -n auto.

#Useful invocations

bash
pytest -x                       # stop at the first failure
pytest -k "discount and not slow"
pytest -m integration           # by marker
pytest --lf                     # last failed
pytest -n auto --dist loadfile  # parallel, a file's tests stay on one worker
pytest --durations=10           # the ten slowest tests
ini
# pyproject.toml
[tool.pytest.ini_options]
addopts = "-q --strict-markers --strict-config"
markers = ["integration: needs a database", "slow: takes over a second"]
testpaths = ["tests"]

--strict-markers turns a typo'd @pytest.mark.integraton into an error rather than a silently ignored decorator, which is worth switching on immediately.

Common questions

What makes pytest fixtures different from setUp methods?
They are composable and request-driven. A test declares the fixtures it needs by naming them as parameters, fixtures can depend on other fixtures, and each has its own scope and teardown. The result is that setup is shared by need rather than by inheritance.
What scope should a fixture have?
The narrowest that is fast enough. Function scope is the default and the safest; session scope is for genuinely expensive, genuinely read-only things like a database container. A session-scoped fixture that tests mutate is a shared-state bug waiting to happen.
How do I run pytest tests in parallel?
pytest-xdist, with `-n auto`. Give each worker its own database schema or data prefix first — xdist will reveal every shared-resource assumption the suite has.

Runnable samples for this page

last test results ↗
  • Pythonpython/tests/tools/pytest

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?