Zum Inhalt

Python Deep Dive: Writing Testable Code with Pytest, Fixtures, Parametrization & Mocking

Executive Summary: Untested code is legacy code the instant it is written. However, bolted-on test suites that rely on brittle unittest.TestCase class hierarchies or excessive unittest.mock.patch monkey-patching break every time an internal helper is refactored. pytest is the undisputed testing standard in the Python ecosystem—replacing boilerplate classes with plain assert introspection, composable Dependency-Injected Fixtures (yield), data-driven @pytest.mark.parametrize matrices, and built-in filesystem/environment sandboxes (tmp_path & monkeypatch).

  • Testable Architecture First


    Separate pure domain logic (Functional Core) from external I/O adapters (Imperative Shell) so 90% of tests need zero mocks.

  • Scoped yield Fixtures


    Manage setup and deterministic teardown (function, module, session scopes) cleanly via dependency-injected conftest.py fixtures.

  • Data-Driven @parametrize


    Execute 20 boundary, unicode, and error edge-cases through a single test function using @pytest.mark.parametrize.

  • tmp_path & monkeypatch


    Isolate filesystem writes, environment variables, and HTTP calls so your test suite runs in milliseconds with zero network access.


1. Designing for Testability: Functional Core, Imperative Shell

Why is some code effortless to test while other code requires 30 lines of fragile mock.patch() setup? Because untestable code mixes I/O side effects (reading environment variables, calling datetime.now(), querying databases, sending HTTP requests) directly inside business calculation functions.

%%{init: {'flowchart': {'wrappingWidth': 600, 'subGraphTitleMargin': {'top': 15, 'bottom': 15}}}}%%
flowchart TB
    subgraph ImpShell["🌐 Imperative Shell (Thin I/O Adapter Layer)"]
        HTTP["FastAPI / Flask Handler"]
        DB["PostgreSQL / HTTPX Client"]
        CLOCK["System Clock & os.environ"]
    end

    subgraph FuncCore["💎 Functional Core (100% Pure Python Logic)"]
        PURE["calculate_invoice_discount()<br/>validate_firewall_cidr()<br/>(Deterministic Input -> Output<br/>Zero Mocks Needed!)"]
    end

    HTTP -->|"Passes Plain Data"| PURE
    DB -->|"Passes Plain Data"| PURE
    CLOCK -->|"Passes Timestamp"| PURE

Refactoring Untestable Code into Pure Dependency-Injected Code

# Hard to test: coupled to real system clock, os.environ, and live HTTP call!
import os
from datetime import datetime
import requests

def check_license_valid() -> bool:
    api_url = os.environ["LICENSE_URL"]
    resp = requests.get(api_url)
    expires = datetime.fromisoformat(resp.json()["expires_at"])
    return datetime.now() < expires
# File: src/licensing.py
from datetime import datetime


def is_license_active(expires_iso: str, current_utc: datetime) -> bool:
    """Pure function: trivial to test across leap years and exact boundary seconds!"""
    expires_dt = datetime.fromisoformat(expires_iso)
    if expires_dt.tzinfo is None or current_utc.tzinfo is None:
        raise ValueError("Both timestamps must be timezone-aware (UTC).")
    return current_utc < expires_dt

2. Pytest Setup & Configuration in pyproject.toml

Configure pytest and coverage.py directly inside pyproject.toml so every developer and CI runner enforces strict flags (--strict-markers, -ra, and minimum 90% branch coverage):

# File: pyproject.toml
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
addopts = [
    "-ra",
    "--strict-markers",
    "--strict-config",
    "--tb=short",
]
markers = [
    "integration: marks tests requiring a local PostgreSQL or container instance",
]

[tool.coverage.run]
branch = true
source = ["src"]

[tool.coverage.report]
fail_under = 90
show_missing = true

3. Composable Fixtures (yield), tmp_path & monkeypatch

In pytest, a Fixture is a function decorated with @pytest.fixture whose return (or yield) value is injected automatically into any test function that declares the fixture's name as a parameter.

  • Everything before yield is the Setup phase.
  • Everything after yield is the Teardown phase (guaranteed to execute even if the test assertion fails!).
%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
    SETUP["🔧 1. Fixture Setup<br/>Create In-Memory SQLite<br/>BEGIN Transaction"]
    YIELD["⚡ 2. yield db_conn<br/>Injects Connection Into<br/>test_create_user(db_conn)"]
    TEAR["🧹 3. Fixture Teardown<br/>ROLLBACK & Close<br/>(Always Runs!)"]

    SETUP --> YIELD
    YIELD --> TEAR
# File: tests/conftest.py
"""Shared Pytest fixtures with deterministic setup and teardown."""

import sqlite3
from collections.abc import Generator
from pathlib import Path
import pytest


@pytest.fixture(scope="function")
def memory_db() -> Generator[sqlite3.Connection, None, None]:
    # 1. SETUP: Initialize isolated in-memory SQLite schema
    conn = sqlite3.connect(":memory:")
    conn.row_factory = sqlite3.Row
    conn.execute("CREATE TABLE nodes (hostname TEXT PRIMARY KEY, ram_gb INTEGER NOT NULL)")
    conn.commit()

    # 2. INJECT into test function
    yield conn

    # 3. TEARDOWN: Always close connection cleanly after test finishes
    conn.close()

4. Data-Driven Testing with @pytest.mark.parametrize

Instead of copy-pasting 6 almost-identical test functions (or putting a for-loop inside a test that stops at the very first failure), use @pytest.mark.parametrize so pytest tracks and reports each test vector independently:

# File: tests/test_licensing_and_storage.py
from datetime import datetime, timezone
from pathlib import Path
import pytest

from src.licensing import is_license_active


@pytest.mark.parametrize(
    ("expires_iso", "now_iso", "expected"),
    [
        pytest.param("2026-12-31T23:59:59+00:00", "2026-11-06T12:00:00+00:00", True, id="active-before-expiry"),
        pytest.param("2026-11-06T12:00:00+00:00", "2026-11-06T12:00:00+00:00", False, id="exact-expiry-second"),
        pytest.param("2025-01-01T00:00:00+00:00", "2026-11-06T12:00:00+00:00", False, id="expired-last-year"),
    ],
)
def test_is_license_active_matrix(expires_iso: str, now_iso: str, expected: bool) -> None:
    now_utc = datetime.fromisoformat(now_iso)
    assert is_license_active(expires_iso, now_utc) is expected


def test_naive_datetime_raises_value_error() -> None:
    naive_now = datetime(2026, 11, 6, 12, 0, 0)  # Missing tzinfo!
    with pytest.raises(ValueError, match="timezone-aware"):
        is_license_active("2026-12-31T00:00:00+00:00", naive_now)


def test_isolated_filesystem_and_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    # 1. monkeypatch safely sets/overrides environment variables for this test only
    monkeypatch.setenv("NHDOC_ENV", "staging")

    # 2. tmp_path provides a unique temporary pathlib.Path directory per test
    cfg_file = tmp_path / "node.conf"
    cfg_file.write_text("host=srv001.nejat-hakan.de\n", encoding="utf-8")

    assert cfg_file.read_text(encoding="utf-8").startswith("host=srv001")

5. Essential Pytest CLI Flags for Daily Engineering

Command Engineering Purpose
pytest -v Verbose output showing every parametrized test ID.
pytest -x --lf Stop immediately on the first failure (-x) and re-run only the Last Failed (--lf) tests on the next run!
pytest -k "license and not slow" Run only tests matching the boolean substring expression -k.
pytest --durations=10 Profile test execution speed and print the 10 slowest tests in your suite.
pytest -s (--capture=no) Disable stdout/stderr capturing so interactive breakpoint() (pdb) sessions work in the terminal.

Where to Go Next

Combine your automated test suite with FastAPI dependency overrides, uv packaging, and Linux automation:

  • Testing FastAPI Microservices


    Use app.dependency_overrides and TestClient to test REST APIs with zero external infrastructure.

    FastAPI Guide

  • Fast Test Execution with uv


    Execute your test suite inside reproducible lockfile environments via uv run pytest.

    Package Manager uv

  • Self-Hosted CI/CD with Forgejo & GitLab


    Run your pytest and ruff quality gates automatically on every git push using self-hosted runners.

    Forgejo Git Server