DEV Community

DatanestDigital
DatanestDigital

Posted on

pytest Patterns That Make Tests Actually Maintainable (2026)

Most Python test suites don't fail because the team can't write tests. They fail because the tests become a maintenance burden: copy-pasted setup, brittle mocks, and a suite so slow nobody runs it locally. pytest gives you the tools to avoid all of that — if you use them deliberately.

Here are the patterns that keep a suite fast, readable, and cheap to change as the code evolves.

1. Fixtures over setup/teardown

Fixtures are pytest's superpower: reusable, composable setup that's requested by name. A test declares what it needs as an argument and pytest builds it.

import pytest

@pytest.fixture
def db_session():
    session = create_session()
    yield session            # test runs here
    session.rollback()       # teardown after yield
    session.close()

def test_create_user(db_session):
    user = create_user(db_session, name="Ada")
    assert user.id is not None
Enter fullscreen mode Exit fullscreen mode

Everything after yield is teardown — guaranteed to run even if the test fails.

2. Scope fixtures to control cost

A fixture that spins up a database shouldn't rebuild it for every test. Set scope so expensive setup is shared.

@pytest.fixture(scope="session")
def engine():
    eng = create_engine("postgresql://...")
    yield eng
    eng.dispose()
Enter fullscreen mode Exit fullscreen mode

function (default), class, module, session — widen the scope for expensive resources, keep it narrow for anything that holds mutable state.

3. Parametrize instead of copy-pasting tests

When the only thing changing is the inputs and expected output, parametrize turns ten near-identical tests into one.

@pytest.mark.parametrize("amount, expected", [
    (100, "1.00"),
    (0, "0.00"),
    (99999, "999.99"),
    (-50, "-0.50"),
])
def test_format_cents(amount, expected):
    assert format_cents(amount) == expected
Enter fullscreen mode Exit fullscreen mode

Each row reports as its own test, so a failure points straight at the offending case.

4. Factory fixtures for flexible test data

When tests need objects with slight variations, return a function from the fixture instead of a fixed object.

@pytest.fixture
def make_user(db_session):
    def _make(**overrides):
        data = {"name": "Test", "role": "viewer", **overrides}
        return create_user(db_session, **data)
    return _make

def test_admin_can_delete(make_user):
    admin = make_user(role="admin")
    assert admin.can_delete()
Enter fullscreen mode Exit fullscreen mode

No more ten fixtures for ten slightly different users.

5. Mock at the boundary, not everywhere

Mock the things you don't control — network, time, third-party APIs — and let your own code run for real. Over-mocking produces tests that pass while the app is broken.

def test_sends_welcome_email(make_user, mocker):
    send = mocker.patch("myapp.email.send")     # patch where it's USED
    register(make_user())
    send.assert_called_once()
Enter fullscreen mode Exit fullscreen mode

The classic gotcha: patch the name in the module that uses it, not where it's defined.

6. Markers to slice the suite

Tag tests so you can run a fast subset locally and the full suite in CI.

@pytest.mark.slow
def test_full_import_pipeline(): ...
Enter fullscreen mode Exit fullscreen mode
pytest -m "not slow"     # quick feedback loop
pytest                   # everything, in CI
Enter fullscreen mode Exit fullscreen mode

Register markers in pyproject.toml so a typo doesn't silently skip tests.

7. Put shared fixtures in conftest.py

Fixtures in a conftest.py are auto-discovered by every test in that directory and below — no imports. Keep global fixtures at the root conftest.py, and package-specific ones in nested ones.

tests/
├── conftest.py          # engine, app, client
├── api/
│   ├── conftest.py      # authed_client
│   └── test_users.py
└── unit/
    └── test_format.py
Enter fullscreen mode Exit fullscreen mode

8. Assert on behavior, not implementation

Test what the function does, not how. assert result == expected survives refactors; asserting that an internal helper was called three times breaks the moment you tidy the code.

The payoff

Fixtures kill setup duplication, scopes keep the suite fast, parametrize collapses repetition, and factory fixtures make varied data trivial. Together they produce the property that matters most: a suite people actually run, that fails for real reasons, and that doesn't fight you when the code changes.

Standing this up cleanly — conftest layout, database and HTTP-client fixtures, factories, coverage config — is the same scaffolding every project needs. The Python Testing Toolkit bundles these patterns as ready-to-use fixtures and config so a new repo has a maintainable suite from the first commit.

Bottom line

Maintainable tests aren't about discipline alone — they're about using pytest's composition tools so the easy path is also the clean one. Lean on fixtures, parametrize, and good conftest structure, and your test suite becomes an asset instead of a chore.

Top comments (0)