Lesson 19 covered the basics of pytest: write a function, assert something, ask for a fixture. That gets you most of the way. This lesson covers the three patterns that handle the rest: parametrize when you have many similar cases, mocks when you have an external dependency you don’t want to call, and conftest.py when you need to share setup across files.
These three are the difference between “I’d test this if I could” and “let me just bang out the test.”
@pytest.mark.parametrize: the table of cases
A pile of nearly-identical tests is a smell. The classic example:
def test_slugify_simple():
assert slugify("Hello World") == "hello-world"
def test_slugify_accents():
assert slugify("Café") == "cafe"
def test_slugify_punctuation():
assert slugify("foo, bar!") == "foo-bar"
def test_slugify_empty():
assert slugify("") == ""
Each test is one input and one expected output. parametrize collapses them into a table:
import pytest
@pytest.mark.parametrize("raw, expected", [
("Hello World", "hello-world"),
("Café", "cafe"),
("foo, bar!", "foo-bar"),
("", ""),
])
def test_slugify(raw, expected):
assert slugify(raw) == expected
pytest reports each row as a separate test, so a failure tells you which input broke things. Output looks like:
test_slug.py::test_slugify[Hello World-hello-world] PASSED
test_slug.py::test_slugify[Caf\xe9-cafe] PASSED
test_slug.py::test_slugify[foo, bar!-foo-bar] PASSED
test_slug.py::test_slugify[-] PASSED
The auto-generated IDs in the brackets get ugly fast. Use the ids= argument for human-readable names:
@pytest.mark.parametrize("raw, expected", [
("Hello World", "hello-world"),
("Café", "cafe"),
("foo, bar!", "foo-bar"),
("", ""),
], ids=["spaces", "accents", "punctuation", "empty"])
def test_slugify(raw, expected):
assert slugify(raw) == expected
You can stack parametrize decorators to get the cartesian product:
@pytest.mark.parametrize("base", [10, 100, 1000])
@pytest.mark.parametrize("exp", [0, 1, 2])
def test_power(base, exp):
assert power(base, exp) == base ** exp
That generates nine tests: every base crossed with every exponent. Use sparingly — combinatorial explosion is a real failure mode.
For more complex tables, pytest.param lets you attach markers per row:
@pytest.mark.parametrize("path", [
"tests/data/small.json",
"tests/data/big.json",
pytest.param("tests/data/huge.json", marks=pytest.mark.slow),
pytest.param("tests/data/broken.json", marks=pytest.mark.xfail),
])
def test_parse(path):
parse(Path(path))
Now pytest -m "not slow" skips the huge file in your fast development loop.
A note on AI assistance
Parametrize tables are one of the things AI assistants are very good at. Hand them the function signature and two or three sample input/output pairs, and they will generate a comprehensive parametrize block in seconds — often catching format variations you wouldn’t have thought of.
The catch: they almost always test happy paths only. If you don’t explicitly say “include edge cases — empty inputs, None, very long strings, Unicode, malformed input, boundary values,” you’ll get a nicely-formatted block that misses every interesting bug. Prompt for the edges, review the result, and add the case that broke production last quarter — the assistant doesn’t know about that one.
unittest.mock: faking external dependencies
Real code talks to things: HTTP APIs, databases, the file system, the clock. Tests should not. Mocks let you replace a real object with a fake one that records calls and returns whatever you tell it to.
unittest.mock is in the standard library. The two pieces you’ll use are MagicMock and patch.
from unittest.mock import MagicMock
def test_email_sender():
fake_smtp = MagicMock()
sender = EmailSender(smtp_client=fake_smtp)
sender.send("ada@example.com", "subject", "body")
fake_smtp.sendmail.assert_called_once_with(
"noreply@narcismiclaus.com",
["ada@example.com"],
"Subject: subject\n\nbody",
)
MagicMock accepts any method call, returns another MagicMock, and remembers everything that happened to it. assert_called_once_with checks the call signature.
That works when your code accepts the dependency as an argument (constructor injection). When it doesn’t — when the code calls requests.get directly inside the function — you reach for patch:
from unittest.mock import patch
@patch("my_app.weather.requests.get")
def test_get_temperature(mock_get):
mock_get.return_value.json.return_value = {"temp_c": 21.3}
assert get_temperature("Rome") == 21.3
mock_get.assert_called_once_with(
"https://api.weather.example/v1",
params={"city": "Rome"},
)
Or as a context manager when you only need it for part of the test:
def test_get_temperature():
with patch("my_app.weather.requests.get") as mock_get:
mock_get.return_value.json.return_value = {"temp_c": 21.3}
assert get_temperature("Rome") == 21.3
The path-to-mock rule
This is the single most common mocking mistake. The rule:
Patch the name where it’s used, not where it’s defined.
If my_app/weather.py does import requests and then calls requests.get(...), you patch my_app.weather.requests.get. Not requests.get.
Why: import requests binds the name requests inside my_app.weather’s namespace. When you patch requests.get at the source, the local binding in my_app.weather still points to the real function. When you patch my_app.weather.requests.get, you’re patching the binding the actual code reaches for.
Same rule for from x import y:
# my_app/weather.py
from datetime import datetime
def now_iso():
return datetime.now().isoformat()
# tests/test_weather.py
@patch("my_app.weather.datetime") # ← here, not "datetime.datetime"
def test_now_iso(mock_dt):
mock_dt.now.return_value.isoformat.return_value = "2026-05-01T12:00:00"
assert now_iso() == "2026-05-01T12:00:00"
Half of all “but it works in production!” mock confusion comes from getting this wrong.
patch.object
When you have a reference to the object already, patch.object is cleaner than building a string:
from my_app import weather
def test_with_object():
with patch.object(weather, "fetch_raw") as mock_fetch:
mock_fetch.return_value = {"temp_c": 21.3}
assert weather.get_temperature("Rome") == 21.3
Library-specific HTTP mocks
For HTTP work, the dedicated libraries are nicer than raw patch:
responsesforrequests:
import responses
@responses.activate
def test_get_temperature():
responses.get(
"https://api.weather.example/v1",
json={"temp_c": 21.3},
)
assert get_temperature("Rome") == 21.3
pytest-httpxforhttpx:
def test_get_temperature(httpx_mock):
httpx_mock.add_response(
url="https://api.weather.example/v1",
json={"temp_c": 21.3},
)
assert get_temperature("Rome") == 21.3
These match on URL/method/headers and let you assert that the right requests went out, without you needing to remember the exact mock.return_value.json.return_value chain.
conftest.py: shared fixtures, automatic discovery
Once you have more than one test file, you want fixtures defined once and used everywhere. That’s conftest.py.
conftest.py is a magic filename. pytest auto-discovers it: any test in a directory (or subdirectory) automatically sees the fixtures defined in any conftest.py from that directory up to the root. No imports.
A typical layout:
my_project/
pyproject.toml
src/
my_package/
...
tests/
conftest.py # shared by everything
test_helpers.py
unit/
conftest.py # only for unit/
test_pure_functions.py
integration/
conftest.py # only for integration/
test_api.py
test_db.py
The top-level tests/conftest.py for cheap, always-on fixtures:
import os
import pytest
from my_package.config import Settings
@pytest.fixture
def settings():
return Settings(
debug=True,
db_url="sqlite:///:memory:",
api_key="test-key",
)
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
"""Strip user env vars that could leak into tests."""
for var in ("AWS_PROFILE", "DATABASE_URL", "OPENAI_API_KEY"):
monkeypatch.delenv(var, raising=False)
autouse=True means every test gets that fixture applied automatically — perfect for environment hygiene.
The integration/conftest.py for expensive setup that only integration tests need:
import pytest
import docker
@pytest.fixture(scope="session")
def postgres_container():
client = docker.from_env()
container = client.containers.run(
"postgres:16",
environment={"POSTGRES_PASSWORD": "test"},
ports={"5432/tcp": None},
detach=True,
remove=True,
)
wait_for_postgres(container)
yield container
container.stop()
@pytest.fixture
def db(postgres_container):
conn = connect_to(postgres_container)
conn.execute("BEGIN")
yield conn
conn.execute("ROLLBACK")
Unit tests never start a container. Integration tests get one started once for the session. The split is geographic — by directory — and pytest enforces it for free.
pytest_plugins
If you publish reusable fixtures (a company-wide testing library, say), put them in a regular Python module and tell conftest.py to load it:
# tests/conftest.py
pytest_plugins = ["mycorp.testing.fixtures"]
Now every fixture in mycorp.testing.fixtures is available, exactly as if it lived in conftest.py.
A worked example: testing a paid API client
Imagine a function that calls a metered API. You absolutely do not want your test suite to spend money:
# my_app/translate.py
import httpx
def translate(text: str, target: str) -> str:
response = httpx.post(
"https://api.translate.example/v1",
json={"text": text, "target": target},
headers={"Authorization": f"Bearer {load_api_key()}"},
timeout=10,
)
response.raise_for_status()
return response.json()["translation"]
The test, using pytest-httpx and parametrize:
import pytest
@pytest.mark.parametrize("text, target, translation", [
("hello", "it", "ciao"),
("goodbye", "ro", "la revedere"),
("", "it", ""),
], ids=["english-to-italian", "english-to-romanian", "empty-input"])
def test_translate(httpx_mock, text, target, translation):
httpx_mock.add_response(
url="https://api.translate.example/v1",
json={"translation": translation},
)
assert translate(text, target) == translation
def test_translate_raises_on_500(httpx_mock):
httpx_mock.add_response(
url="https://api.translate.example/v1",
status_code=500,
)
with pytest.raises(httpx.HTTPStatusError):
translate("hello", "it")
Three patterns, one test file, no money spent: parametrize for the table, an HTTP mock for the network, and (if httpx_mock came from a conftest.py) a fixture that didn’t have to be re-declared.
The next lesson, 21, leaves runtime testing behind and looks at the static-analysis stack: type checkers and linters that catch a different category of bug before any test even runs.
Citations: pytest documentation, pytest-httpx, responses, unittest.mock. Retrieval 2026-05-01.