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
def test_discount_applies_at_the_threshold():
result = apply_discount(Order(subtotal_cents=10_000), POLICY)
assert result.discount_cents == 2_000When 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_centsNo 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.
# 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)# 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_999That 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) → class → module →
package → session. 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
@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 validParameterising a fixture runs every test that uses it once per value — which is how you run a whole suite against two database backends.
#Doubles
# 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_CENTSpatch("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
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# 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 ↗- Python
python/tests/tools/pytest
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Writing Tests in Pythonpytest fixtures, the patching rules that catch everyone, async testing, and the toolchain for a Python project that has to hold up in CI.
- Mocking FrameworksMoq, NSubstitute, Mockito, unittest.mock, Sinon and the rest — what each ecosystem's mocking library does well, and the failure modes they share.
- Test Data ManagementWhere a test's data comes from decides whether the suite can run in parallel, twice in a row, or at all — builders, factories, fixtures and per-test isolation.
- Running Tests in ParallelHow parallelism turns hidden coupling into failures, the shared resources that clash, and how to make a suite genuinely safe to run concurrently.
- Code CoverageWhat the percentage measures, why it is a finding tool rather than a target, how to collect it in each ecosystem, and how to gate on it without causing harm.
- iOS App TestingXCTest, Swift Testing, XCUITest and the accessibility identifiers that make UI automation possible — plus what to run on a simulator and what needs a device.
- Arrange-Act-AssertThe three-part shape every readable test has, why the act step should be one line, and the smells that show up when a test will not fit the pattern.