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
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()
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
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()
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()
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(): ...
pytest -m "not slow" # quick feedback loop
pytest # everything, in CI
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
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)