Guide

pytest fundamentals explained

A discount rule silently treats “10% off orders over $100” as “10% off every order” because someone inverted a comparison. A timezone-aware datetime passes locally and fails in CI at UTC midnight. These bugs live in pure Python — pricing tables, serializers, permission checks — long before a browser opens. The standard library ships unittest, but most modern teams reach for pytest: plain functions, plain assert, rich failure diffs, and a fixture system that replaces repetitive setup/teardown boilerplate. Django, Flask, FastAPI, and SQLAlchemy ecosystems document pytest-first patterns. This guide covers discovery and project layout, writing tests and organizing conftest.py, fixture scopes and dependency injection, @pytest.mark.parametrize, mocking with unittest.mock and pytest-mock, async and marker patterns, API integration tests, a Harbor Commerce order validator worked example, a pytest vs unittest decision table, common pitfalls, and a production checklist. Pair it with our Python fundamentals guide, FastAPI fundamentals guide, and software testing fundamentals for the full quality stack.

What pytest is and why Python teams adopt it

pytest is a test runner and framework. You write functions named test_* (or methods on classes prefixed Test without an __init__), assert with normal Python operators, and pytest collects and executes them. No subclassing unittest.TestCase, no self.assertEqual — when an assertion fails, pytest rewrites the expression and shows left vs right values clearly.

The killer feature is fixtures: functions decorated with @pytest.fixture that pytest injects into tests by parameter name. A database session, temporary directory, authenticated HTTP client, or frozen clock is declared once and composed across modules via conftest.py. That composition beats copy-pasting setUp/tearDown in every class.

When pytest is the right default

  • Greenfield Python services — FastAPI, Flask, Celery workers, data pipelines.
  • Library authors — plugins and parametrized edge-case tables ship with the package.
  • Mixed stacks — pytest runs unittest-style tests too; migration can be incremental.
  • Property-based add-ons — Hypothesis integrates cleanly for fuzzing invariants.

Skip pytest when you are locked into a framework that mandates unittest subclasses with no fixture injection (rare today) or when the team already standardized on another runner with heavy investment.

Project setup and discovery

Install with pip install pytest (or add to [project.optional-dependencies] / dev extras). Typical layout:

myapp/
  src/myapp/
    pricing.py
    orders.py
  tests/
    conftest.py
    test_pricing.py
    test_orders.py
  pyproject.toml

pytest discovers test_*.py and *_test.py files, plus Test* classes and test_* functions inside them. Configure in pyproject.toml:

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra -q --strict-markers"
markers = [
  "slow: integration tests that hit external services",
  "db: tests requiring a database fixture",
]

pythonpath = ["src"] avoids editable-install friction in CI. --strict-markers fails on typos like @pytest.mark.sloww. Run pytest locally; CI calls pytest --cov=myapp --cov-report=xml with pytest-cov when you want coverage gates.

conftest.py hierarchy

Any directory can host a conftest.py — fixtures defined there are visible to tests in that directory and below, not to sibling packages. Root tests/conftest.py holds shared DB engines; tests/api/conftest.py adds an authenticated client fixture only for API tests. pytest loads conftest modules automatically; they are not imported as normal packages.

Writing tests: assert, parametrize, and structure

A minimal test is a function with assertions:

# tests/test_pricing.py
from myapp.pricing import apply_discount

def test_no_discount_below_threshold():
    assert apply_discount(50_00, threshold_cents=100_00, bps=1000) == 50_00

def test_ten_percent_off_above_threshold():
    assert apply_discount(150_00, threshold_cents=100_00, bps=1000) == 135_00

Use arrange-act-assert mentally even without comments: build inputs, call the unit under test, assert outcomes. Name tests after behavior (test_rejects_negative_quantity), not after implementation (test_line_42).

Parametrize tables

@pytest.mark.parametrize runs the same logic across many inputs without copy-paste:

import pytest
from myapp.pricing import bps_to_multiplier

@pytest.mark.parametrize("bps,expected", [
    (0, 1.0),
    (100, 0.99),
    (1000, 0.90),
    (10000, 0.0),
])
def test_bps_to_multiplier(bps, expected):
    assert bps_to_multiplier(bps) == pytest.approx(expected)

pytest.approx handles floating-point comparisons. For exceptions, use pytest.raises(ValueError, match="negative"). Combine parametrize decorators to build Cartesian products — powerful but can explode test count; keep tables readable.

Fixtures: scopes, yield, and dependency graphs

Fixtures replace setup/teardown with explicit dependencies:

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from myapp.db import Base

@pytest.fixture(scope="session")
def engine():
    eng = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(eng)
    yield eng
    eng.dispose()

@pytest.fixture
def db_session(engine):
    Session = sessionmaker(bind=engine)
    session = Session()
    yield session
    session.rollback()
    session.close()

Scopes control lifetime: function (default, per test), class, module, package, session. Expensive resources (Postgres container, browser) belong at session scope with careful isolation; mutable state at session scope causes order-dependent failures.

Yield fixtures and finalizers

Code after yield runs as teardown — equivalent to try/finally. Prefer yield over @pytest.fixture(autouse=True) unless every test in the directory truly needs the side effect. Use request.addfinalizer when teardown must run even if setup failed.

Fixtures can depend on other fixtures by name in the function signature. pytest builds a DAG and executes in topological order — no manual ordering flags. See our SQLAlchemy fundamentals guide for ORM session patterns that pair naturally with db fixtures.

Mocking, monkeypatch, and pytest-mock

Mock at boundaries: HTTP clients, cloud SDKs, the system clock, environment variables. The stdlib unittest.mock.patch works; many teams add pytest-mock for a mocker fixture that auto-stops patches:

def test_fetch_rate_uses_cache(mocker):
    mock_get = mocker.patch("myapp.rates.requests.get")
    mock_get.return_value.json.return_value = {"sol_usd": 150.0}
    from myapp.rates import fetch_sol_usd
    assert fetch_sol_usd() == 150.0
    mock_get.assert_called_once()

Built-in monkeypatch fixture sets env vars and attributes without mock objects: monkeypatch.setenv("API_KEY", "test"), monkeypatch.setattr(module, "now", lambda: fixed_dt). Reset is automatic per test.

What not to mock

  • Do not mock the function you are testing — you prove nothing.
  • Avoid mocking every private helper; test public behavior and inject collaborators.
  • Prefer fakes (in-memory repo) over mocks when behavior matters more than call counts.

Async tests, markers, and API integration

Async code needs pytest-asyncio (or native asyncio support in recent pytest). Mark async tests and provide an event loop fixture:

import pytest

@pytest.mark.asyncio
async def test_wallet_balance(client):
    resp = await client.get("/balance")
    assert resp.status_code == 200

FastAPI ships TestClient (sync) and httpx.AsyncClient with ASGITransport for async tests. Override dependencies via app.dependency_overrides in a fixture to inject fake auth or DB sessions without hitting production services.

Flask uses app.test_client() in a fixture; TESTING = True and in-memory SQLite keep tests fast. For integration tests that hit a real Postgres, mark them @pytest.mark.db and exclude from default CI with pytest -m "not db".

Useful built-in markers

  • @pytest.mark.skip(reason="...") — temporarily disable.
  • @pytest.mark.xfail(strict=True) — expected failure becomes CI failure if it passes.
  • @pytest.mark.parametrize — data-driven cases (above).
  • Custom markers — register in pyproject.toml to avoid warnings.

Worked example: Harbor Commerce order validator

Harbor Commerce validates checkout payloads before payment capture. The rules are pure functions — ideal pytest territory. Simplified module:

# myapp/orders.py
from dataclasses import dataclass

@dataclass(frozen=True)
class LineItem:
    sku: str
    qty: int
    unit_cents: int

def validate_line_items(items: list[LineItem]) -> None:
    if not items:
        raise ValueError("empty cart")
    for item in items:
        if item.qty <= 0:
            raise ValueError(f"invalid qty for {item.sku}")
        if item.unit_cents < 0:
            raise ValueError(f"negative price for {item.sku}")

def order_total_cents(items: list[LineItem]) -> int:
    validate_line_items(items)
    return sum(i.qty * i.unit_cents for i in items)

Tests mix unit cases, parametrize, and exception checks:

import pytest
from myapp.orders import LineItem, order_total_cents, validate_line_items

def test_empty_cart_raises():
    with pytest.raises(ValueError, match="empty cart"):
        validate_line_items([])

@pytest.mark.parametrize("qty", [0, -1])
def test_non_positive_qty_rejected(qty):
    items = [LineItem("HAT-01", qty, 2500)]
    with pytest.raises(ValueError, match="invalid qty"):
        validate_line_items(items)

def test_total_sums_line_extensions():
    items = [
        LineItem("HAT-01", 2, 2500),
        LineItem("MUG-02", 1, 1200),
    ]
    assert order_total_cents(items) == 6200

A client fixture posts JSON to POST /orders and asserts HTTP 422 on bad payloads — integration layer on top of the same invariants. Keep pricing math in pure functions; route handlers only parse and delegate.

pytest vs unittest vs Hypothesis

Need pytest unittest Hypothesis (add-on)
Plain assert with rich diffs Native self.assert* methods Uses pytest or unittest
Composable test setup Fixture DAG setUp/tearDown Strategies as data
Data-driven tables parametrize subTest Generated examples + shrinking
Plugin ecosystem Largest (cov, xdist, asyncio) Stdlib only Property tests
Running in CI pytest -n auto (xdist) python -m unittest Pair with pytest
Browser E2E Use Playwright separately Same Not applicable

Common pitfalls

  • Session-scoped mutable fixtures — one test writes state the next test reads; use function scope or rollback.
  • Fixture overkill — three-line tests do not need a fixture; inline setup is clearer.
  • Testing private functions — brittle on refactors; assert public API contracts.
  • Implicit test order — pytest randomizes by default in some plugins; never depend on execution order.
  • Real network in unit tests — flaky and slow; mock HTTP or use VCR cassettes for integration tiers.
  • Missing pytest.raises assignmentwith pytest.raises(...): fn() must wrap the call, not sit nearby.
  • Coverage theater — 100% lines with no edge assertions; focus on pricing, auth, and serialization.
  • Duplicating E2E in pytest — full checkout flows belong in Playwright; pytest slices one function or one HTTP contract.

Production checklist

  • Add pytest to dev dependencies; document pytest and pytest --cov in README.
  • Set testpaths and pythonpath in pyproject.toml.
  • Centralize shared fixtures in root conftest.py; scope expensive resources carefully.
  • Table-drive parsers, pricing, and validators with parametrize.
  • Mark slow/integration tests; default CI runs pytest -m "not slow".
  • Mock external IO at module boundaries; use dependency overrides in FastAPI tests.
  • Enable pytest-cov thresholds on critical packages.
  • Run pytest before Playwright in CI for fast feedback.
  • Use pytest-xdist -n auto on large suites when tests are isolated.
  • Pin pytest and plugin versions in lockfiles for reproducible CI.

Key takeaways

  • pytest is the default Python test runner — plain asserts, fixtures, and plugins.
  • Fixtures compose setup via dependency injection; conftest.py shares them by directory.
  • parametrize replaces copy-pasted test cases with explicit tables.
  • Mock boundaries, not internals; pair with fakes for repositories and clocks.
  • Playwright handles browser E2E; pytest owns units and HTTP contract tests.

Related reading