pytest — fixtures, parametrise, and plugins
Layered fixtures, parametrise for coverage without repetition, and the plugins worth adding to CI.
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
- Write tests that read like specifications.
- Use fixtures for setup / teardown / dependency injection.
- Use
parametrizefor table-driven tests. - Mock and patch external dependencies.
- Measure coverage and integrate CI.
1. Install + first test
uv add --dev pytest pytest-cov pytest-mock pytest-asyncio# 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) == -3Run:
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 printsDiscovery rules
- Files matching
test_*.pyor*_test.py. - Functions/methods starting with
test_. - Classes starting with
Test(no__init__).
Configure in pyproject.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
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
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 == 42match 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.
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) == 5Setup + teardown via yield
@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
@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)
| Fixture | What |
|---|---|
tmp_path | pathlib.Path to a unique temp directory |
tmp_path_factory | session-scoped factory |
monkeypatch | patch attrs / env vars / dict items; auto-undone |
capsys | capture stdout/stderr; capsys.readouterr() |
caplog | capture log records |
mocker (from pytest-mock) | wraps unittest.mock |
recwarn | capture warnings |
5. Parametrize — table-driven tests
@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) == expectedOutput:
test_add[2-3-5] PASSED
test_add[-1--2--3] PASSED
test_add[0-0-0] PASSED
test_add[1000-2000-3000] PASSEDEach row is a separate test — failures are localised.
Nested / cross-product
@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
@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) == expectedParametrize fixtures
@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
@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 failingRun subsets:
uv run pytest -m "slow"
uv run pytest -m "not slow"Register custom marks in pyproject.toml to avoid warnings:
[tool.pytest.ini_options]
markers = ["slow: long-running tests", "integration: hits external systems"]7. Mocking — pytest-mock + unittest.mock
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
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.
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
# 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 == 200With 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:
# uv add --dev syrupy
def test_render(snapshot):
assert render_page(data) == snapshotFirst 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
[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 = trueuv run pytest # runs tests + coverage
# Open htmlcov/index.html to see line-level coverageTips:
- 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
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
# 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# 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)
- Write 5 tests for a function
factorial(n)including edge cases (0, negative). - Add a fixture for sample data; use it in 3 tests.
- Use
parametrizeto test 10 cases ofis_palindrome(s). - Write a test that captures stdout with
capsysand asserts on it. - Use
monkeypatch.setenvto test config reading. - Use
mocker.patchto fake an HTTP call. - Add coverage; aim for >85% on a small module.
- Bonus: write an async test for
fetch_urlusingpytest-asyncio+httpx.MockTransport.
Common pitfalls
- Tests depending on each other (state leaks between tests).
- Over-mocking — test ends up checking the mocks, not the code.
- Patching the wrong import path.
- Slow tests that touch network / disk needlessly.
- Coverage worship (100% via trivial getters).
- Forgetting
@pytest.mark.asyncio(orasyncio_mode = "auto"). - Using
unittest.TestCasestyle in a pytest project — works, but you lose features.
Self-check
- What does
assertrewriting do? - When use session-scoped fixture vs function-scoped?
monkeypatchvsmocker.patch?- How does
parametrizework? - What's the AAA pattern?
References
- pytest docs: https://docs.pytest.org/.
- Python Testing with pytest, Brian Okken.
- Architecture Patterns with Python, Percival & Gregory (testing chapters).
- pytest plugins list: https://docs.pytest.org/en/stable/reference/plugin_list.html.
- syrupy: https://github.com/syrupy-project/syrupy.
Sign in to save your progress and earn badges.