pytest fundamentals
Write expressive tests with fixtures, parametrization, temporary resources, and boundary-focused mocks.
pytest fundamentals
A valuable test states one behavior, arranges only what matters, and fails with enough context to
diagnose the regression. pytest builds on normal Python functions and assert statements.
Install and configure
uv add --dev pytest[tool.pytest.ini_options]
addopts = ["-ra", "--strict-markers", "--strict-config"]
testpaths = ["tests"]
xfail_strict = true
markers = [
"integration: uses a real external boundary",
"slow: takes noticeably longer than unit tests",
]Run the suite:
uv run pytest
uv run pytest tests/unit/test_pricing.py
uv run pytest -k "discount and not expired"
uv run pytest -m "not integration"Start with observable behavior
from decimal import Decimal
import pytest
from shop.pricing import discounted_price
def test_discounted_price_applies_percentage() -> None:
result = discounted_price(Decimal("80.00"), percentage=25)
assert result == Decimal("60.00")
def test_discounted_price_rejects_negative_percentage() -> None:
with pytest.raises(ValueError, match="percentage"):
discounted_price(Decimal("80.00"), percentage=-1)Test the public result and meaningful side effects. Avoid asserting private helper calls merely because they exist today.
Parametrize a rule matrix
@pytest.mark.parametrize(
("price", "percentage", "expected"),
[
(Decimal("100.00"), 0, Decimal("100.00")),
(Decimal("100.00"), 100, Decimal("0.00")),
(Decimal("19.99"), 10, Decimal("17.99")),
],
ids=["no-discount", "free", "rounds-to-cents"],
)
def test_discounted_price_cases(
price: Decimal,
percentage: int,
expected: Decimal,
) -> None:
assert discounted_price(price, percentage) == expectedParametrization is best when inputs change but the behavior under test remains the same. If rows need different setup or assertions, split them into named tests.
Fixtures manage resources
from collections.abc import Iterator
from pathlib import Path
import pytest
@pytest.fixture
def config_file(tmp_path: Path) -> Path:
path = tmp_path / "settings.toml"
path.write_text('currency = "USD"\n', encoding="utf-8")
return path
@pytest.fixture
def enabled_feature(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setenv("SHOP_FEATURE", "enabled")
yieldBuilt-in fixtures such as tmp_path, monkeypatch, capsys, and caplog isolate common process
resources. pytest reverses monkeypatch changes after the test.
Use yield for teardown:
import sqlite3
from collections.abc import Iterator
@pytest.fixture
def connection() -> Iterator[sqlite3.Connection]:
connection = sqlite3.connect(":memory:")
connection.execute(
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"
)
try:
yield connection
finally:
connection.close()Default fixture scope is function, which maximizes isolation. Increase scope only when setup cost
is measured and shared state cannot leak between tests.
Mock at a boundary
Suppose application code depends on a protocol:
from typing import Protocol
class RateProvider(Protocol):
def rate_for(self, currency: str) -> float: ...
def convert_usd(amount: float, currency: str, provider: RateProvider) -> float:
if amount < 0:
raise ValueError("amount cannot be negative")
return amount * provider.rate_for(currency)A small fake is often clearer than a general mock:
class FixedRateProvider:
def __init__(self, rate: float) -> None:
self.rate = rate
self.requested: list[str] = []
def rate_for(self, currency: str) -> float:
self.requested.append(currency)
return self.rate
def test_convert_usd_uses_requested_currency() -> None:
provider = FixedRateProvider(0.8)
result = convert_usd(10.0, "EUR", provider)
assert result == 8.0
assert provider.requested == ["EUR"]Use unittest.mock when call behavior itself matters:
from unittest.mock import create_autospec
def test_convert_usd_calls_provider_once() -> None:
provider = create_autospec(RateProvider, instance=True)
provider.rate_for.return_value = 0.8
assert convert_usd(10.0, "EUR", provider) == 8.0
provider.rate_for.assert_called_once_with("EUR")Patch the name looked up by the code under test, not necessarily where the object was originally defined. Dependency injection usually avoids that ambiguity.
Capture logs and CLI output
import logging
def test_invalid_row_is_logged(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
process_row({"id": ""})
assert "missing id" in caplog.textFor output:
def test_main_prints_summary(capsys: pytest.CaptureFixture[str]) -> None:
exit_code = main(["summarize", "data.csv"])
captured = capsys.readouterr()
assert exit_code == 0
assert "3 records" in captured.out
assert captured.err == ""Prefer passing CLI arguments into main(argv) rather than mutating global sys.argv.
Test discovery and layout
tests/
├── unit/
│ ├── test_pricing.py
│ └── test_validation.py
├── integration/
│ └── test_repository.py
└── conftest.pyPut broadly shared fixtures in conftest.py; keep specialized fixtures near their tests. Importing
from one test module into another creates hidden coupling—move reusable test helpers into a regular
helper module.
Common pitfalls
- Fixture forests: tests become unreadable when fixtures depend on many invisible fixtures.
- Autouse overuse: reserve
autouse=Truefor universal isolation, not convenience. - Mocking your own internals: refactors break tests even when behavior is unchanged.
- Mocking HTTP everywhere: keep fast client fakes, but add integration tests for serialization, headers, and real transport behavior.
- Random data without a seed: failures become difficult to reproduce.
- Catching broad exceptions in tests: assert the exact expected exception and useful message.
Practice
- Parametrize valid and invalid discount boundaries.
- Write a temporary-file fixture that returns a parsed configuration.
- Replace a mock-heavy test with a small fake implementing a protocol.
- Test a CLI command's exit code, stdout, and stderr.
- Add an integration marker and prove the default local command can exclude it.
LEARNING RECORD
Finish this lesson
Mark it complete when you can explain the main decision without looking.
KNOWLEDGE CHECK