Filter concepts by levelShowing all levels.

Python · Testing

pytest

Concepts
5

Discovery and plain-assert assertions, fixtures and their scopes, parametrization, marks and plugins, and async tests via conftest.py-shared configuration.

This section

Writing and structuring tests

How pytest finds tests, the plain-assert style it enables, and fixtures for reusable setup and teardown.

Test discovery and assertions

corebeginner

pytest finds tests automatically: files named test_*.py or *_test.py, functions named test_*, classes named Test*. Inside a test, a plain assert is enough — pytest rewrites it to show exactly what values did not match.

Think of it as

unittest needs you to say assertEqual(a, b) so it knows how to explain a failure. pytest instead reads your plain assert a == b at import time and rewrites it internally to capture both sides — you write ordinary Python, and the detailed failure message comes for free.

python
def test_something():
    assert actual_value == expected_value

pytest.main()   # or, from a shell: pytest

What we're doing: Show a passing assertion, then a failing one, to see pytest's assertion-rewriting output for real.

test_add.pypython
def add(a, b):
    return a + b


def test_add_wrong_expectation():
    assert add(2, 2) == 5
6
A plain assert — no assertEqual needed. pytest rewrites this at collection time to capture both sides for the failure report.
Output
    def test_add_wrong_expectation():
>       assert add(2, 2) == 5
E       assert 4 == 5
E        +  where 4 = add(2, 2)

Why this works: pytest's assertion rewriting parses the test file and replaces plain assert with introspection code, so a failure shows the ACTUAL computed value (4) next to the expected one (5) and even shows where 4 came from — all without writing assertEqual(add(2, 2), 5).

Naming a test helper function test_* by accident

Wrong

python
def test_data_factory():   # looks like a test, but it's a HELPER
    return {"id": 1, "name": "widget"}

def test_uses_factory():
    data = test_data_factory()   # pytest ALSO tries to run this as a test
    assert data["id"] == 1

Better

python
def make_test_data():      # doesn't match test_* discovery pattern
    return {"id": 1, "name": "widget"}

def test_uses_factory():
    data = make_test_data()
    assert data["id"] == 1

What you see: pytest reports an extra, unexpected "test" that fails or errors — usually with a confusing "fixture not found" or a silent pass that means nothing, since a helper wasn't written to be run standalone.

Why: Discovery is purely name-based — pytest cannot tell a genuine test from a same-named helper function. Anything matching test_* gets collected and run, so a helper needs a name outside that pattern.

From a plain assert to a detailed failure report

File matches test_*.py

discovered automatically, no registration needed

Function matches test_*

collected and run as a test

assert add(2, 2) == 5

ordinary Python — no assertEqual needed

Rewritten at collection time

failure shows: assert 4 == 5, + where 4 = add(2, 2)

  1. File matches test_*.py — discovered automatically, no registration needed
  2. Function matches test_* — collected and run as a test
  3. assert add(2, 2) == 5 — ordinary Python — no assertEqual needed
  4. Rewritten at collection time — failure shows: assert 4 == 5, + where 4 = add(2, 2)

Discovery naming rules

Discovery naming rules
ThingNaming rule pytest looks for
Filetest_*.py or *_test.py
Functiontest_*
ClassTest* (no __init__ method)
Method on a Test* classtest_*

Together

python
# test_orders.py -- discovered automatically, no registration needed
def test_order_total_includes_tax():
    assert calculate_total(100, tax_rate=0.08) == 108.0

class TestOrderValidation:
    def test_rejects_negative_quantity(self):
        assert validate_quantity(-1) is False

Remember: pytest finds tests by name (test_*.py, test_* functions) — write plain assert statements, and pytest's rewriting shows exactly what did not match on failure.

See also: fixtures and scopes · marks and plugins

Fixtures and fixture scopes

coreintermediate

@pytest.fixture marks a function as reusable setup. Any test that takes a parameter with the same name gets that fixture's return value automatically — pytest injects it, nothing is imported or called directly.

Think of it as

A fixture is a vending machine, not an ingredient list. A test does not build its own dependencies by hand — it just names what it needs as a parameter, and pytest hands over a ready-made one, built fresh (or reused, depending on scope) each time.

python
@pytest.fixture
def resource():
    setup_thing = build()
    yield setup_thing   # tests run here
    teardown(setup_thing)   # runs after the test, guaranteed

def test_uses_it(resource):   # parameter NAME matches the fixture
    assert resource.ready

What we're doing: Show a function-scoped fixture rebuilding per test, and a module-scoped fixture building once and tearing down at the end.

test_fixtures.pypython
import pytest

@pytest.fixture
def sample_order():
    print("\n[setup] building sample_order")
    return {"id": 1, "total": 49.99}

def test_order_has_id(sample_order):
    assert sample_order["id"] == 1

def test_order_total(sample_order):
    assert sample_order["total"] == 49.99

@pytest.fixture(scope="module")
def db_connection():
    print("\n[setup] opening db_connection (module scope)")
    yield {"connected": True}
    print("\n[teardown] closing db_connection")

def test_uses_db_connection_a(db_connection):
    assert db_connection["connected"]

def test_uses_db_connection_b(db_connection):
    assert db_connection["connected"]
5
sample_order (default function scope) prints "[setup]" for EACH test that uses it — two tests, two setup lines.
17
db_connection (module scope) is built once for the whole file, then reused — its setup print only appears once.
19
The teardown after yield only runs once, at the very end of the module, after every test that used it has finished.
Output
[setup] building sample_order
PASSED
[setup] building sample_order
PASSED
[setup] opening db_connection (module scope)
PASSED
PASSED
[teardown] closing db_connection

Why this works: Function scope rebuilds sample_order for every test that needs it, keeping tests fully isolated. Module scope builds db_connection once and shares it — cheaper for something expensive to set up, at the cost of tests no longer having a fully independent copy.

Using a wide-scoped fixture that returns mutable state

Wrong

python
@pytest.fixture(scope="module")
def shared_cart():
    return []   # ONE list, shared by every test in the file

def test_add_item(shared_cart):
    shared_cart.append("widget")
    assert len(shared_cart) == 1

def test_cart_starts_empty(shared_cart):
    assert len(shared_cart) == 0   # FAILS if the test above ran first

Better

python
@pytest.fixture   # default function scope -- fresh list every test
def shared_cart():
    return []

def test_add_item(shared_cart):
    shared_cart.append("widget")
    assert len(shared_cart) == 1

def test_cart_starts_empty(shared_cart):
    assert len(shared_cart) == 0   # passes -- always a fresh list

What you see: A test passes or fails depending on which other tests ran before it, in which order — the classic symptom of shared mutable fixture state.

Why: A module/session-scoped fixture is built once and the SAME object is handed to every test — if that object is mutable and one test changes it, every later test sees the change. Function scope (the default) avoids this by rebuilding fresh state every time.

Fixture scopes — how often each rebuilds

function (default)

rebuilt once per test — fully isolated

class

rebuilt once per test class

module

rebuilt once per test file — shared across its tests

session

rebuilt once for the entire test run — cheapest, least isolated

  1. function (default) — rebuilt once per test — fully isolated
  2. class — rebuilt once per test class
  3. module — rebuilt once per test file — shared across its tests
  4. session — rebuilt once for the entire test run — cheapest, least isolated

Fixture scopes

Fixture scopes
ScopeRebuilt
function (default)Once per test function
classOnce per test class
moduleOnce per test file
sessionOnce for the entire test run

Together

python
@pytest.fixture
def sample_order():           # function scope -- fresh every test
    return {"id": 1, "total": 49.99}

@pytest.fixture(scope="module")
def db_connection():          # built once, shared by every test in this file
    conn = connect_to_test_db()
    yield conn
    conn.close()

Remember: @pytest.fixture supplies setup via parameter-name match; yield splits setup/teardown; scope controls rebuilds.

See also: test discovery and assertions · test isolation and determinism

Advertisement

Scaling and configuring tests

Running one test body against many inputs, tagging tests for selection, and sharing configuration and async support across a whole test suite.

Parametrization

standardintermediate

@pytest.mark.parametrize("names", [values]) runs the same test function once per row of values, reporting each as its own separate test — instead of copy-pasting the same test body for every case.

Think of it as

Without parametrize, testing five inputs means five near-identical functions. parametrize is a spreadsheet: one test body, one column of expected results, one row per case pytest runs automatically.

python
@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (0, 0, 0),
    (-1, 1, 0),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

What we're doing: Run one test body against three input rows and see each reported as its own test ID.

test_parametrize.pypython
import pytest

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (0, 0, 0),
    (-1, 1, 0),
])
def test_add_parametrized(a, b, expected):
    assert a + b == expected
3
The string "a,b,expected" names the parameters; the list below supplies one tuple of values per test run.
8
The test body itself only mentions the three names — parametrize is what supplies three different sets of values to it.
Output
test_add_parametrized[2-3-5] PASSED
test_add_parametrized[0-0-0] PASSED
test_add_parametrized[-1-1-0] PASSED

Why this works: pytest builds one test ID per tuple, named from the tuple's own values by default — three tuples become three independently reported tests from a single function body, each pass/fail tracked on its own.

Remember: @pytest.mark.parametrize("names", [rows]) runs one test body once per row, each reported as its own separately pass/fail test ID.

See also: fixtures and scopes · marks and plugins

Marks and plugins

standardintermediate

A mark like @pytest.mark.slow tags a test so it can be selected, deselected, or skipped by name. A plugin is a package that adds new marks, fixtures, or command-line options — pytest-asyncio and pytest-cov are both plugins.

Think of it as

A mark is a sticky note on a test — "slow", "requires a database", "expected to fail." pytest -m "not slow" reads the sticky notes and runs only what matches, without touching test code.

python
@pytest.mark.slow
def test_full_batch_import():
    ...

# pytest -m "slow"       -- run only marked tests
# pytest -m "not slow"   -- run everything except marked tests

What we're doing: Register a custom mark and show pytest warns about an unregistered one, to make the registration requirement concrete.

test_marks.pypython
import pytest

@pytest.mark.slow
def test_marked_slow():
    assert True
3
slow is not a built-in pytest mark — using it without registering it in pytest.ini/pyproject.toml produces a warning, not an error.
Output
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo?  You can register custom marks to avoid this warning
1 passed, 1 warning

Why this works: The test still runs and passes — an unregistered custom mark is a warning, not a failure. Registering it (a markers = section in pyproject.toml) silences the warning and lets pytest --markers document what it means.

Built-in marks worth knowing

Built-in marks worth knowing
MarkEffect
@pytest.mark.skip(reason="...")always skips the test, records the reason
@pytest.mark.skipif(cond, reason="...")skips only if cond is True at collection time
@pytest.mark.xfailexpected to fail — a failure does not fail the run; an unexpected PASS is flagged
@pytest.mark.parametrize(...)runs the test once per row of supplied values

Together

python
import sys

@pytest.mark.skip(reason="not implemented yet")
def test_future_feature():
    ...

@pytest.mark.skipif(sys.platform == "win32", reason="posix-only")
def test_file_permissions():
    ...

@pytest.mark.xfail(reason="known bug, see #456")
def test_known_bug():
    assert buggy_function() == "correct"

Remember: Marks tag tests for selection (pytest -m) or special handling (skip, xfail); plugins are installed packages that add new marks, fixtures, or CLI options.

See also: parametrization · async tests and conftest

Async tests, conftest.py, and test configuration

standardintermediate

With the pytest-asyncio plugin, an async def test function just runs, awaiting normally inside it. conftest.py holds fixtures shared across every test file in its directory — pytest finds it automatically, no import needed.

Think of it as

conftest.py is a directory-wide toolbox. Any test file in that directory (or below it) can use a fixture defined there without importing anything — pytest already knows to look there before running a test.

python
# conftest.py
@pytest.fixture
def api_client():
    return build_test_client()

# test_users.py -- api_client used with NO import
def test_get_user(api_client):
    assert api_client.get("/users/1").status_code == 200

# an async test, with pytest-asyncio installed and configured
async def test_fetch_user():
    result = await fetch_user(1)
    assert result["id"] == 1

What we're doing: Define a fixture in conftest.py and use it in a test file with no import, then run an async test directly.

conftest.py + test_api.pypython
# conftest.py
@pytest.fixture
def api_client():
    return {"base_url": "https://api.example.com", "authenticated": True}

# test_api.py -- no import of api_client needed
def test_api_client_from_conftest(api_client):
    assert api_client["authenticated"] is True

# an async test (pytest-asyncio installed, asyncio_mode = "auto")
async def test_fetch_returns_data():
    result = await fetch_data()
    assert result == {"status": "ok"}
3
api_client is defined in conftest.py, not in the test file itself.
7
test_api.py uses api_client as a parameter with zero import statements — pytest found it via conftest.py automatically.
Output
test_api_client_from_conftest PASSED

Why this works: conftest.py is loaded automatically for every test file it shares a directory with (or a subdirectory of) — pytest treats every fixture defined there as available everywhere in scope, without any explicit wiring.

Remember: conftest.py fixtures are auto-shared across a directory, no import needed; pytest-asyncio (a plugin) lets async def tests await directly.

See also: fixtures and scopes · event loop and coroutines

Advertisement