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.tomlto 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.raisesassignment —with 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
pytestto dev dependencies; documentpytestandpytest --covin README. - Set
testpathsandpythonpathinpyproject.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-covthresholds on critical packages. - Run pytest before Playwright in CI for fast feedback.
- Use
pytest-xdist -n autoon 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.pyshares 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
- Python fundamentals explained — syntax, venv, and production habits
- FastAPI fundamentals explained — dependency injection and TestClient
- Software testing fundamentals explained — pyramid, AAA, and flaky-test hygiene
- Playwright E2E testing explained — browser automation and CI traces