pytest — fixtures, parametrise, and plugins

Layered fixtures, parametrise for coverage without repetition, and the plugins worth adding to CI.

🧪 Module 6 9 min read Not started

Why this matters

Untested code is broken code waiting to be discovered. pytest is the standard Python test runner — concise, powerful, with the best plugin ecosystem. This lesson takes you from "I can write assert" to "I write fixture-driven, parametrised, mocked tests with good coverage."

Learning objectives

  1. Write tests that read like specifications.
  2. Use fixtures for setup / teardown / dependency injection.
  3. Use parametrize for table-driven tests.
  4. Mock and patch external dependencies.
  5. Measure coverage and integrate CI.

1. Install + first test

powershell
uv add --dev pytest pytest-cov pytest-mock pytest-asyncio
python
# tests/test_math.py
def add(a, b): return a + b

def test_add():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, -2) == -3

Run:

powershell
uv run pytest
uv run pytest -v                          # verbose
uv run pytest tests/test_math.py::test_add  # one test
uv run pytest -k "negative"               # by keyword
uv run pytest -x                          # stop on first failure
uv run pytest -lf                         # last failed
uv run pytest -s                          # show prints

Discovery rules

  • Files matching test_*.py or *_test.py.
  • Functions/methods starting with test_.
  • Classes starting with Test (no __init__).

Configure in pyproject.toml:

toml
[tool.pytest.ini_options]
addopts = "-ra -q --cov=src --cov-report=term-missing"
testpaths = ["tests"]
filterwarnings = ["error", "ignore::DeprecationWarning"]

2. Assertions — assert with introspection

python
def test_div():
    assert 10 / 4 == 2.5

def test_list():
    assert sorted([3, 1, 2]) == [1, 2, 3]

def test_dict_subset():
    actual = {"a": 1, "b": 2, "c": 3}
    assert {"a": 1, "b": 2}.items() <= actual.items()

When assert fails, pytest rewrites the AST to show what each side actually was:

assert sorted([3, 1, 2]) == [1, 2, 4]
       ^^^^^^^^^^^^^^^^^^^
AssertionError: assert [1, 2, 3] == [1, 2, 4]

No self.assertEqual boilerplate. Just assert.


3. Testing exceptions

python
import pytest

def divide(a, b): return a / b

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)

def test_value_error_message():
    with pytest.raises(ValueError, match="must be positive"):
        my_func(-1)

def test_exception_attribute():
    with pytest.raises(MyError) as exc_info:
        my_func()
    assert exc_info.value.code == 42

match is a regex against the exception's string representation.


4. Fixtures — the heart of pytest

A fixture is a function that produces a value (and optionally cleans up). Tests declare fixtures as parameters; pytest injects them.

python
import pytest

@pytest.fixture
def sample_data():
    return [1, 2, 3, 4, 5]

def test_sum(sample_data):
    assert sum(sample_data) == 15

def test_max(sample_data):
    assert max(sample_data) == 5

Setup + teardown via yield

python
@pytest.fixture
def temp_file(tmp_path):
    path = tmp_path / "test.txt"
    path.write_text("hello")
    yield path
    # teardown after the test
    path.unlink(missing_ok=True)

def test_reads(temp_file):
    assert temp_file.read_text() == "hello"

Scopes

python
@pytest.fixture(scope="function")   # default — one per test
@pytest.fixture(scope="class")
@pytest.fixture(scope="module")     # one per test file
@pytest.fixture(scope="session")    # one per pytest run

@pytest.fixture(scope="session")
def db_engine():
    engine = create_engine(...)
    yield engine
    engine.dispose()

Use session scope for expensive setup (DB connection, Docker container). Use function scope (default) for anything that mutates.

conftest.py — share fixtures across files

Place fixtures in tests/conftest.py; all tests in tests/ and subdirectories see them. No imports needed.

Built-in fixtures (always available)

FixtureWhat
tmp_pathpathlib.Path to a unique temp directory
tmp_path_factorysession-scoped factory
monkeypatchpatch attrs / env vars / dict items; auto-undone
capsyscapture stdout/stderr; capsys.readouterr()
caplogcapture log records
mocker (from pytest-mock)wraps unittest.mock
recwarncapture warnings

5. Parametrize — table-driven tests

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

Output:

test_add[2-3-5] PASSED
test_add[-1--2--3] PASSED
test_add[0-0-0] PASSED
test_add[1000-2000-3000] PASSED

Each row is a separate test — failures are localised.

Nested / cross-product

python
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", ["a", "b"])
def test_combo(x, y):
    ...                            # 6 tests (3 × 2)

Parametrize with id for readable names

python
@pytest.mark.parametrize("input,expected", [
    pytest.param("ada@x.com", True, id="valid"),
    pytest.param("nope", False, id="missing-at"),
    pytest.param("", False, id="empty"),
])
def test_is_email(input, expected):
    assert is_email(input) == expected

Parametrize fixtures

python
@pytest.fixture(params=["sqlite", "postgres"])
def db(request):
    return make_db(request.param)

Each test using db runs once per backend.


6. Marks — categorise / skip / xfail

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

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

@pytest.mark.skipif(sys.platform == "win32", reason="UNIX only")
def test_fork(): ...

@pytest.mark.xfail(reason="known bug, see #123")
def test_buggy(): ...               # passes the suite even when failing

Run subsets:

powershell
uv run pytest -m "slow"
uv run pytest -m "not slow"

Register custom marks in pyproject.toml to avoid warnings:

toml
[tool.pytest.ini_options]
markers = ["slow: long-running tests", "integration: hits external systems"]

7. Mocking — pytest-mock + unittest.mock

python
def fetch_and_compute(url, client):
    data = client.get(url).json()
    return data["x"] + data["y"]

def test_fetch_and_compute(mocker):
    fake = mocker.Mock()
    fake.get.return_value.json.return_value = {"x": 1, "y": 2}
    assert fetch_and_compute("http://x", fake) == 3
    fake.get.assert_called_once_with("http://x")

patch an import

python
def test_with_patch(mocker):
    mock_open = mocker.patch("mymod.open", mocker.mock_open(read_data="hello"))
    assert mymod.load() == "hello"

Patch the path where the name is used, not where it's defined. mymod.open is what's called inside mymod.load.

Dependency injection beats mocking

Mocks are sticky and fragile. If a class takes a dependency (e.g., UserService(db)), pass a fake / in-memory impl. Cleaner tests, fewer mock-related bugs.

python
class InMemoryDB:
    def __init__(self): self.data = {}
    def save(self, k, v): self.data[k] = v
    def get(self, k): return self.data.get(k)

def test_register():
    service = UserService(InMemoryDB())
    service.register("Ada", "ada@x.com")
    assert service.find("ada@x.com").name == "Ada"

Reach for mock.patch only when DI isn't possible.


8. Async tests

python
# Install: uv add --dev pytest-asyncio
# pyproject.toml:
# [tool.pytest.ini_options]
# asyncio_mode = "auto"

import pytest, httpx

@pytest.mark.asyncio
async def test_fetch():
    async with httpx.AsyncClient() as client:
        r = await client.get("https://httpbin.org/get")
        assert r.status_code == 200

With asyncio_mode = "auto", you can drop the @pytest.mark.asyncio decorator.


9. Snapshot tests (large outputs)

For comparing large outputs (rendered HTML, JSON), use syrupy:

python
# uv add --dev syrupy
def test_render(snapshot):
    assert render_page(data) == snapshot

First run creates snapshots in __snapshots__/. Future runs compare. Update with --snapshot-update.

Great for serializers, codegen, prompts. Don't overuse — assertions on specific values are more diagnostic.


10. Coverage

toml
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing --cov-report=html"

[tool.coverage.run]
branch = true
omit = ["*/migrations/*", "*/__main__.py"]

[tool.coverage.report]
fail_under = 80
show_missing = true
powershell
uv run pytest                         # runs tests + coverage
# Open htmlcov/index.html to see line-level coverage

Tips:

  • Aim for high coverage on logic (services, parsers, validators).
  • Don't chase 100% — testing trivial getters is noise.
  • Branch coverage > line coverage.
  • Coverage measures execution, not correctness. Pair with property-based tests (Lesson 6.2).

11. Patterns

AAA — Arrange, Act, Assert

python
def test_user_creation():
    # Arrange
    repo = InMemoryUserRepo()
    service = UserService(repo)
    # Act
    user = service.register("Ada", "ada@x.com")
    # Assert
    assert user.id is not None
    assert repo.get(user.id).email == "ada@x.com"

Test one thing per test

If you can't name the test in one sentence, split it.

Avoid global state

Each test should run in isolation. Use fixtures with proper scope; reset state in teardown.

Test boundaries, not internals

Test the public API. Refactoring shouldn't break tests.

Fast feedback

Sub-second test suites stay green. Slow suites get skipped. Mark integration / slow tests separately and run them less often.


12. Worked example: test a small service

python
# src/users/service.py
from dataclasses import dataclass
from typing import Protocol

@dataclass(frozen=True)
class User:
    id: int; name: str; email: str

class UserRepo(Protocol):
    def save(self, user: User) -> None: ...
    def by_email(self, email: str) -> User | None: ...

class UserExists(Exception): pass

class UserService:
    def __init__(self, repo: UserRepo): self.repo = repo
    def register(self, name: str, email: str) -> User:
        if "@" not in email: raise ValueError("bad email")
        if self.repo.by_email(email): raise UserExists(email)
        user = User(id=next_id(), name=name, email=email)
        self.repo.save(user)
        return user
python
# tests/test_user_service.py
import pytest

class InMemoryRepo:
    def __init__(self): self._d: dict[str, User] = {}
    def save(self, user): self._d[user.email] = user
    def by_email(self, email): return self._d.get(email)

@pytest.fixture
def service():
    return UserService(InMemoryRepo())

def test_register_creates_user(service):
    u = service.register("Ada", "ada@x.com")
    assert u.email == "ada@x.com"
    assert service.repo.by_email("ada@x.com") == u

def test_register_rejects_bad_email(service):
    with pytest.raises(ValueError, match="bad email"):
        service.register("Ada", "nope")

def test_register_rejects_duplicate(service):
    service.register("Ada", "ada@x.com")
    with pytest.raises(UserExists):
        service.register("Ada2", "ada@x.com")

@pytest.mark.parametrize("email,valid", [
    ("a@b.com", True),
    ("a@b", True),
    ("nope", False),
    ("", False),
])
def test_email_validation(service, email, valid):
    if valid:
        service.register("X", email)
    else:
        with pytest.raises(ValueError):
            service.register("X", email)

DI (UserService(repo)) makes this fixture-friendly. No mocks needed.


Hands-on lab (2 hours)

  1. Write 5 tests for a function factorial(n) including edge cases (0, negative).
  2. Add a fixture for sample data; use it in 3 tests.
  3. Use parametrize to test 10 cases of is_palindrome(s).
  4. Write a test that captures stdout with capsys and asserts on it.
  5. Use monkeypatch.setenv to test config reading.
  6. Use mocker.patch to fake an HTTP call.
  7. Add coverage; aim for >85% on a small module.
  8. Bonus: write an async test for fetch_url using pytest-asyncio + httpx.MockTransport.

Common pitfalls

  1. Tests depending on each other (state leaks between tests).
  2. Over-mocking — test ends up checking the mocks, not the code.
  3. Patching the wrong import path.
  4. Slow tests that touch network / disk needlessly.
  5. Coverage worship (100% via trivial getters).
  6. Forgetting @pytest.mark.asyncio (or asyncio_mode = "auto").
  7. Using unittest.TestCase style in a pytest project — works, but you lose features.

Self-check

  1. What does assert rewriting do?
  2. When use session-scoped fixture vs function-scoped?
  3. monkeypatch vs mocker.patch?
  4. How does parametrize work?
  5. What's the AAA pattern?

References

Sign in to save your progress and earn badges.