Testing in Practice · Foundations: Strategy, pytest and Test Doubles
pytest Fixtures, Parametrisation and Markers
Share setup with fixtures (including scopes and teardown), cover many cases with parametrize, and organise suites with markers.
What you'll build
A test suite for a coupon service: a fixture builds a fresh service per test (with teardown), tmp_path provides a temporary file, parametrize checks a table of cases in one test function, and a custom marker tags slow tests so CI can choose what to run.
The idea
- Fixtures provide test setup by name;
yieldfixtures run teardown after the test. Scopes (function,module,session) control reuse. - Built-in fixtures:
tmp_path(a temporary directory),monkeypatch(environment and attributes),capsys(captured output). @pytest.mark.parametrizeruns the same test with many inputs; each case reports separately.- Markers (
@pytest.mark.slow) let you select or skip groups:pytest -m "not slow". Register them in configuration.
The code
# file: coupons.py
import json
class CouponService:
def __init__(self, path):
self.codes = json.loads(path.read_text())
self.closed = False
def discount(self, code: str, total: float) -> float:
rule = self.codes.get(code.upper())
if rule is None or total < rule["min_total"]:
return 0.0
return round(total * rule["percent"] / 100, 2)
def close(self):
self.closed = True
# file: conftest.py
import json
import pytest
from coupons import CouponService
def pytest_configure(config):
config.addinivalue_line("markers", "slow: tests that take a while")
@pytest.fixture
def service(tmp_path):
path = tmp_path / "coupons.json"
path.write_text(json.dumps({"WELCOME10": {"percent": 10, "min_total": 0}, "BIG20": {"percent": 20, "min_total": 100}}))
svc = CouponService(path)
yield svc
svc.close() # teardown runs after each test
# file: test_coupons.py
import pytest
@pytest.mark.parametrize(("code", "total", "expected"), [
("WELCOME10", 50, 5.0),
("welcome10", 50, 5.0), # case-insensitive
("BIG20", 99.99, 0.0), # below the minimum
("BIG20", 100, 20.0), # boundary
("UNKNOWN", 100, 0.0),
])
def test_discount(service, code, total, expected):
assert service.discount(code, total) == expected
@pytest.mark.slow
def test_many_lookups(service):
assert sum(service.discount("WELCOME10", 10) for _ in range(10_000)) == pytest.approx(10_000)
# file: main.py
import pytest
pytest.main(["-v", "-p", "no:cacheprovider", "-m", "not slow", "--no-header"])
Output
============================= test session starts ============================== collecting ... collected 6 items / 1 deselected / 5 selected test_coupons.py::test_discount[WELCOME10-50-5.0] PASSED [ 20%] test_coupons.py::test_discount[welcome10-50-5.0] PASSED [ 40%] test_coupons.py::test_discount[BIG20-99.99-0.0] PASSED [ 60%] test_coupons.py::test_discount[BIG20-100-20.0] PASSED [ 80%] test_coupons.py::test_discount[UNKNOWN-100-0.0] PASSED [100%] ======================= 5 passed, 1 deselected in <time> ========================
Run this on your computer — see “Get Started” for setup.
The slow test was deselected by the marker expression, and each parametrised case shows up as its own test.
Common mistakes
- Shared mutable fixtures with
moduleorsessionscope that leak state between tests. - Copy-pasted test functions that differ only in input values; use parametrize.
- Unregistered markers (typos silently select nothing); register them and use
--strict-markers.
Key points
- Fixtures build setup by name, with teardown after yield.
- parametrize turns tables of cases into separate tests.
- Markers organise suites; register them.
Further reading: pytest fixtures · Parametrizing tests · Markers
Practice questions
How does a yield fixture handle teardown?
Answer
Code before yield sets up and the yielded value goes to the test; code after yield runs after the test finishes, even if it failed.
What's the risk of session-scoped fixtures?
Answer
They're shared across tests; if a test mutates them, later tests see the changes and become order-dependent.
How do you skip slow tests in a fast CI stage?
Answer
Mark them (@pytest.mark.slow), register the marker, and run pytest -m 'not slow'.
Open in the app to mark lessons complete, take notes and track your progress.