Python Atlas
Tooling, web & data

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) == expected

Parametrization 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")
    yield

Built-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.text

For 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.py

Put 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=True for 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

  1. Parametrize valid and invalid discount boundaries.
  2. Write a temporary-file fixture that returns a parsed configuration.
  3. Replace a mock-heavy test with a small fake implementing a protocol.
  4. Test a CLI command's exit code, stdout, and stderr.
  5. 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

pytest fundamentals

1Three discount cases use the same setup and assertion but different boundary inputs. A fourth case needs a database and asserts emitted events. How should the tests be organized?
2A pricing function accepts a RateProvider protocol. Which test double best preserves refactorability when only returned rates and requested currencies matter?