Advanced Python Unit Testing¶
What this guide covers
Beyond mocking — this guide dives into pytest mastery, fixture architecture, parametrize patterns, property-based testing, test isolation, coverage strategies, CI integration, and the industry practices that separate hobbyist tests from production-grade test suites.
1. Pytest Architecture & Fixture Design¶
The Fixture Scope Ladder¶
Choosing the wrong scope is the #1 cause of slow or flaky test suites.
| Scope | Created | Destroyed | Use for |
|---|---|---|---|
function (default) |
Per test | After each test | Mutable state, DB rows |
class |
Per test class | After class | Shared setup within a group |
module |
Per file | After file | Expensive imports, schema setup |
session |
Once per run | After all tests | DB connections, Docker containers |
import pytest
import boto3
from myapp.db import Database
# ─── Session scope: one DB connection for the whole run ───────────────────────
@pytest.fixture(scope="session")
def db_engine():
engine = Database.create_engine("postgresql://localhost/testdb")
yield engine
engine.dispose()
# ─── Module scope: create schema once per file ────────────────────────────────
@pytest.fixture(scope="module")
def db_schema(db_engine):
db_engine.run_migrations()
yield
db_engine.drop_all_tables()
# ─── Function scope: clean slate per test ─────────────────────────────────────
@pytest.fixture
def db_session(db_engine, db_schema):
session = db_engine.begin_transaction()
yield session
session.rollback() # ← key pattern: rollback instead of delete
Rollback, don't delete
Rolling back a transaction after each test is 10–100x faster than DELETE FROM table cleanup. It's also atomic — no partial state leaks between tests.
Fixture Factories — The Most Underused Pattern¶
Instead of one rigid fixture, return a factory function so tests control what they need:
import pytest
from myapp.models import User
@pytest.fixture
def make_user(db_session):
"""Factory fixture — tests call make_user() with custom args."""
created = []
def _factory(name="Alice", email=None, role="member", **kwargs):
email = email or f"{name.lower()}@example.com"
user = User(name=name, email=email, role=role, **kwargs)
db_session.add(user)
db_session.flush()
created.append(user)
return user
yield _factory
# Cleanup all users created in this test
for u in created:
db_session.delete(u)
def test_admin_can_delete(make_user):
admin = make_user(name="Bob", role="admin")
regular = make_user(name="Carol")
assert admin.can_delete(regular) is True
assert regular.can_delete(admin) is False
Fixture Parametrization — Matrix Testing¶
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def db(request):
"""Run every test that uses this fixture against 3 DB backends."""
backend = request.param
engine = create_engine_for(backend)
yield engine
engine.dispose()
def test_insert_user(db):
# This test runs 3 times — once per backend
db.execute("INSERT INTO users (name) VALUES ('test')")
result = db.execute("SELECT * FROM users").fetchall()
assert len(result) == 1
autouse — Invisible Global Setup¶
@pytest.fixture(autouse=True)
def reset_feature_flags():
"""Automatically reset feature flags after every test — no opt-in needed."""
from myapp import flags
original = flags.snapshot()
yield
flags.restore(original)
@pytest.fixture(autouse=True, scope="session")
def silence_third_party_loggers():
import logging
logging.getLogger("boto3").setLevel(logging.CRITICAL)
logging.getLogger("urllib3").setLevel(logging.CRITICAL)
Sharing Fixtures Across Projects with Plugins¶
For monorepos or shared test infrastructure, package your fixtures as a pytest plugin:
import pytest
from .factories import UserFactory, OrderFactory
@pytest.fixture
def user_factory(db):
return UserFactory(db)
@pytest.fixture
def order_factory(db):
return OrderFactory(db)
# Register in setup.cfg or pyproject.toml:
# [options.entry_points]
# pytest11 =
# myorg_helpers = test_helpers.pytest_plugin
2. Parametrize — The Right Way¶
Basic Parametrize¶
import pytest
@pytest.mark.parametrize("email,valid", [
("user@example.com", True),
("user@sub.domain.io", True),
("not-an-email", False),
("@nodomain.com", False),
("user@", False),
("", False),
])
def test_email_validation(email, valid):
from myapp.validators import is_valid_email
assert is_valid_email(email) is valid
IDs for Readable Output¶
import pytest
cases = [
pytest.param(0, False, id="zero"),
pytest.param(-1, False, id="negative"),
pytest.param(1, True, id="one"),
pytest.param(1000, True, id="large-positive"),
]
@pytest.mark.parametrize("n,expected", cases)
def test_is_positive(n, expected):
from myapp.math_utils import is_positive
assert is_positive(n) is expected
Output: PASSED test_math.py::test_is_positive[zero] — instantly readable.
Indirect Parametrize — Dynamic Fixtures¶
@pytest.fixture
def authenticated_client(request):
role = request.param # receives the parametrize value
user = create_user(role=role)
client = TestClient(app)
client.login(user)
return client
@pytest.mark.parametrize(
"authenticated_client,expected_status",
[("admin", 200), ("member", 403), ("guest", 401)],
indirect=["authenticated_client"],
)
def test_admin_endpoint(authenticated_client, expected_status):
r = authenticated_client.get("/admin/dashboard")
assert r.status_code == expected_status
Cartesian Product — parametrize Stacking¶
@pytest.mark.parametrize("currency", ["USD", "GBP", "EUR"])
@pytest.mark.parametrize("amount", [0, 1, 100, 9999])
def test_format_currency(amount, currency):
# Runs 4 × 3 = 12 combinations automatically
from myapp.fmt import format_currency
result = format_currency(amount, currency)
assert isinstance(result, str)
assert currency in result or str(amount) in result
3. Property-Based Testing with Hypothesis¶
Traditional tests use hand-crafted examples. Hypothesis generates thousands of inputs automatically — it finds edge cases you'd never think of.
First Property-Based Test¶
from hypothesis import given, strategies as st
@given(st.integers())
def test_abs_always_non_negative(n):
assert abs(n) >= 0
@given(st.text())
def test_strip_never_adds_chars(s):
assert len(s.strip()) <= len(s)
@given(st.lists(st.integers()))
def test_sorted_length_preserved(lst):
assert len(sorted(lst)) == len(lst)
Custom Strategies for Domain Objects¶
from hypothesis import given, strategies as st, assume
from hypothesis.strategies import composite
@composite
def valid_order(draw):
quantity = draw(st.integers(min_value=1, max_value=1000))
price = draw(st.decimals(min_value="0.01", max_value="9999.99", places=2))
discount = draw(st.floats(min_value=0.0, max_value=0.99))
return {"quantity": quantity, "price": float(price), "discount": discount}
@given(valid_order())
def test_total_is_never_negative(order):
from myapp.billing import calculate_total
total = calculate_total(**order)
assert total >= 0
@given(valid_order(), valid_order())
def test_bulk_order_never_cheaper_per_unit(small, large):
assume(large["quantity"] > small["quantity"] * 2)
from myapp.billing import unit_price
# Bulk pricing should be same or lower per unit
assert unit_price(**large) <= unit_price(**small)
Stateful Testing — Simulate User Sessions¶
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant, initialize
class ShoppingCartMachine(RuleBasedStateMachine):
@initialize()
def new_cart(self):
from myapp.cart import Cart
self.cart = Cart()
@rule(item=st.text(min_size=1), qty=st.integers(min_value=1, max_value=10))
def add_item(self, item, qty):
self.cart.add(item, qty)
@rule(item=st.text(min_size=1))
def remove_item(self, item):
self.cart.remove(item)
@invariant()
def total_is_non_negative(self):
assert self.cart.total() >= 0
@invariant()
def quantity_counts_are_non_negative(self):
for qty in self.cart.items.values():
assert qty >= 0
TestShoppingCart = ShoppingCartMachine.TestCase
Hypothesis Database — Reproducing Failures¶
Hypothesis persists failing examples in .hypothesis/. Add this to .gitignore to keep it local, or commit it to always reproduce known failures:
# Explicitly reproduce a specific failure
from hypothesis import given, settings, HealthCheck
from hypothesis import reproduce_failure
@settings(suppress_health_check=[HealthCheck.too_slow])
@given(st.text())
def test_parser(s):
from myapp.parser import parse
parse(s) # should not crash
4. Testing Exceptions & Error Paths¶
Basic Exception Testing¶
import pytest
def test_divide_by_zero():
from myapp.math_utils import divide
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_invalid_input_raises_value_error():
from myapp.math_utils import divide
with pytest.raises(ValueError, match=r"operands must be numeric"):
divide("ten", 2)
Asserting Exception Attributes¶
def test_payment_exception_carries_transaction_id():
from myapp.payments import charge, PaymentError
with pytest.raises(PaymentError) as exc_info:
charge(user_id=999, amount=-50)
err = exc_info.value
assert err.error_code == "INVALID_AMOUNT"
assert err.user_id == 999
assert "negative" in str(err).lower()
Testing Warning Emission¶
import warnings
import pytest
def test_deprecated_function_warns():
from myapp.legacy import old_calculate
with pytest.warns(DeprecationWarning, match="use calculate_v2 instead"):
old_calculate(10)
Exhaustive Error Path Matrix¶
import pytest
@pytest.mark.parametrize("user_id,amount,exc,code", [
(None, 100, ValueError, "MISSING_USER"),
(1, -50, ValueError, "INVALID_AMOUNT"),
(1, 0, ValueError, "ZERO_AMOUNT"),
(9999, 100, LookupError, "USER_NOT_FOUND"),
(1, 999999, OverflowError, "LIMIT_EXCEEDED"),
])
def test_charge_error_paths(user_id, amount, exc, code):
from myapp.payments import charge, PaymentError
with pytest.raises(exc) as ei:
charge(user_id=user_id, amount=amount)
assert ei.value.error_code == code
5. Database Testing Strategies¶
SQLAlchemy — Transaction Rollback Pattern¶
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from myapp.models import Base
@pytest.fixture(scope="session")
def engine():
return create_engine("postgresql://localhost/test_db")
@pytest.fixture(scope="session")
def tables(engine):
Base.metadata.create_all(engine)
yield
Base.metadata.drop_all(engine)
@pytest.fixture
def db_session(engine, tables):
connection = engine.connect()
transaction = connection.begin()
Session = sessionmaker(bind=connection)
session = Session()
yield session
session.close()
transaction.rollback() # ← entire test rolled back, zero cleanup cost
connection.close()
Testing with SQLite In-Memory (Fast Integration Tests)¶
@pytest.fixture(scope="session")
def engine():
# In-memory SQLite — no disk I/O, instant startup
return create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
)
SQLite vs Production DB
SQLite doesn't support all SQL features (e.g., RETURNING, some JSON ops). Use it for logic tests; keep a PostgreSQL fixture for schema-sensitive tests.
Testing Migrations with Alembic¶
@pytest.fixture(scope="session")
def migrated_db(engine):
"""Apply real Alembic migrations to test DB — catches migration bugs."""
from alembic.config import Config
from alembic import command
cfg = Config("alembic.ini")
cfg.set_main_option("sqlalchemy.url", str(engine.url))
command.upgrade(cfg, "head")
yield engine
command.downgrade(cfg, "base")
Factory Boy — Maintainable Test Data¶
import factory
from factory.alchemy import SQLAlchemyModelFactory
from myapp.models import User, Order, Product
class UserFactory(SQLAlchemyModelFactory):
class Meta:
model = User
sqlalchemy_session_persistence = "flush"
name = factory.Faker("name")
email = factory.LazyAttribute(lambda o: f"{o.name.lower().replace(' ', '.')}@example.com")
role = "member"
class ProductFactory(SQLAlchemyModelFactory):
class Meta:
model = Product
name = factory.Sequence(lambda n: f"Product {n}")
price = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)
sku = factory.Faker("ean8")
class OrderFactory(SQLAlchemyModelFactory):
class Meta:
model = Order
user = factory.SubFactory(UserFactory)
product = factory.SubFactory(ProductFactory)
qty = factory.Faker("random_int", min=1, max=10)
def test_order_total(db_session):
OrderFactory._meta.sqlalchemy_session = db_session
order = OrderFactory(qty=3, product__price="10.00")
assert order.total() == pytest.approx(30.0)
6. Testing CLI Applications¶
Using click.testing.CliRunner¶
import click
@click.command()
@click.argument("name")
@click.option("--count", default=1, type=int)
def greet(name, count):
for _ in range(count):
click.echo(f"Hello, {name}!")
from click.testing import CliRunner
from myapp.cli import greet
def test_greet_basic():
runner = CliRunner()
result = runner.invoke(greet, ["Alice"])
assert result.exit_code == 0
assert "Hello, Alice!" in result.output
def test_greet_with_count():
runner = CliRunner()
result = runner.invoke(greet, ["Bob", "--count", "3"])
assert result.output.count("Hello, Bob!") == 3
def test_greet_missing_arg():
runner = CliRunner()
result = runner.invoke(greet, [])
assert result.exit_code != 0
assert "Missing argument" in result.output
Testing File I/O in CLI¶
def test_export_writes_file():
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(export_cmd, ["--output", "report.csv"])
assert result.exit_code == 0
with open("report.csv") as f:
content = f.read()
assert "id,name,amount" in content
Typer CLI Testing¶
import typer
app = typer.Typer()
@app.command()
def process(file: str, dry_run: bool = False):
...
# tests/test_cli.py
from typer.testing import CliRunner
from myapp.cli import app
runner = CliRunner()
def test_dry_run_flag():
result = runner.invoke(app, ["data.csv", "--dry-run"])
assert result.exit_code == 0
assert "DRY RUN" in result.stdout
7. Snapshot Testing¶
Snapshot testing captures a known-good output and fails if it ever changes. Perfect for large JSON responses, HTML renders, or complex data structures.
from syrupy.assertion import SnapshotAssertion
def test_user_serialization(snapshot: SnapshotAssertion):
from myapp.serializers import UserSerializer
user = {"id": 1, "name": "Alice", "role": "admin", "created_at": "2024-01-01"}
result = UserSerializer(user).render()
assert result == snapshot
def test_report_json(snapshot):
from myapp.reports import generate_monthly_report
data = generate_monthly_report(month=1, year=2024)
assert data == snapshot
Run pytest --snapshot-update to generate/update snapshots. Commit snapshot files to version control.
When to use snapshots
Great for: API response shapes, rendered templates, serializer output. Avoid for: Values that change per run (timestamps, UUIDs) — scrub those before snapshotting.
def test_order_response(snapshot):
order = create_order()
data = order.to_dict()
# Scrub volatile fields before snapshotting
data.pop("created_at")
data.pop("id")
assert data == snapshot
8. Mutation Testing¶
Coverage tells you what lines were run. Mutation testing tells you if your tests actually catch bugs. It introduces small code changes (mutations) and checks whether your tests fail.
pip install mutmut
mutmut run --paths-to-mutate myapp/
mutmut results
mutmut show 5 # see the surviving mutation
Example: if your source has if x > 0: and mutmut changes it to if x >= 0: — do your tests catch this? If not, you have a gap.
Interpreting Results¶
| Status | Meaning |
|---|---|
killed |
✅ Your tests caught the mutation — good |
survived |
❌ Mutation went undetected — test gap |
timeout |
Mutation created an infinite loop |
suspicious |
Tests passed but with different output |
Industry hack
Don't aim for 100% mutation score — it's expensive to run on large codebases. Run mutmut on your critical business logic only and include it in CI as a nightly job, not every PR.
# Run only on a specific module
mutmut run --paths-to-mutate myapp/billing.py --tests-dir tests/unit/test_billing.py
9. Coverage — Beyond the Number¶
Setup¶
[tool.pytest.ini_options]
addopts = "--cov=myapp --cov-report=term-missing --cov-report=html --cov-fail-under=85"
[tool.coverage.run]
branch = true # branch coverage, not just line coverage
omit = [
"*/migrations/*",
"*/conftest.py",
"*/tests/*",
"myapp/settings*.py",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError",
"if TYPE_CHECKING:",
"@(abc\\.)?abstractmethod",
]
Branch Coverage vs Line Coverage¶
def process(items):
if not items: # ← line 1
return [] # ← line 2: only hit with empty list
return [x * 2 for x in items] # ← line 3
# With line coverage:
# test_process([1, 2]) → 100% (hits lines 1, 3 — line 2 missed but counted as covered!)
# With branch coverage:
# test_process([1, 2]) → 75% (the False branch of `if not items` not taken)
# test_process([]) needed to hit 100% branch coverage
Always use branch coverage
Line coverage is misleading. branch = true in your config is non-negotiable for meaningful coverage metrics.
Pragma No Cover — Use Sparingly¶
def debug_dump(obj): # pragma: no cover
"""Only used during manual debugging."""
import json
print(json.dumps(obj, indent=2))
class AbstractBase:
def process(self) -> None:
raise NotImplementedError # pragma: no cover
Coverage Diff — Only Measure New Code¶
In CI, use diff-cover to enforce coverage only on changed lines:
pip install diff-cover
# In CI (after running pytest --cov)
git diff origin/main...HEAD > changes.diff
diff-cover coverage.xml --compare-branch=origin/main --fail-under=90
This prevents legacy code from dragging down new coverage requirements.
10. Test Performance & Parallelism¶
Run Tests in Parallel with pytest-xdist¶
pip install pytest-xdist
pytest -n auto # use all CPU cores
pytest -n 4 # use 4 workers
pytest -n auto --dist=loadscope # group by module (avoids DB conflicts)
Parallel test safety
Tests must be completely independent — no shared mutable state, no fixed port numbers, no sequential database IDs. Use --dist=loadscope for DB tests to keep a module on one worker.
Profile Slow Tests¶
pip install pytest-durations
pytest --durations=10 # show 10 slowest tests
pytest --durations=0 # show all, sorted by duration
Mark and Skip Slow Tests¶
import pytest
def pytest_addoption(parser):
parser.addoption("--run-slow", action="store_true", default=False)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: marks tests as slow")
def pytest_collection_modifyitems(config, items):
if not config.getoption("--run-slow"):
skip = pytest.mark.skip(reason="pass --run-slow to run")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip)
@pytest.mark.slow
def test_full_etl_pipeline():
# Takes 30 seconds — only run when explicitly requested
...
pytest-randomly — Catch Order Dependencies¶
If tests only pass in a specific order, you have a hidden dependency. pytest-randomly surfaces this by shuffling test order on every run.
11. Industry Best Practices & Hacks¶
The AAA Pattern — Always, Without Exception¶
Every test should have exactly three sections:
def test_invoice_applies_discount():
# Arrange — set up state
invoice = Invoice(subtotal=100.0)
coupon = Coupon(code="SAVE10", discount_pct=10)
# Act — one action
invoice.apply_coupon(coupon)
# Assert — verify outcome
assert invoice.total == 90.0
assert invoice.applied_coupon == "SAVE10"
One Assert Per Concept (Not One Assert Per Test)¶
# ❌ Too many unrelated assertions — hard to know what failed
def test_user_creation():
user = create_user("Alice", "alice@example.com")
assert user.id is not None
assert user.name == "Alice"
assert user.email == "alice@example.com"
assert user.created_at is not None
assert user.is_active is True
assert user.role == "member"
assert send_welcome_email.called
# ✅ Group by concept — each test has a clear purpose
def test_user_has_correct_identity(new_user):
assert new_user.name == "Alice"
assert new_user.email == "alice@example.com"
def test_user_defaults_are_set(new_user):
assert new_user.is_active is True
assert new_user.role == "member"
def test_welcome_email_sent_on_creation(mock_email, new_user):
mock_email.send.assert_called_once()
Test Naming Convention¶
def test_charge_card_when_amount_negative_raises_value_error(): ...
def test_get_user_when_not_found_returns_none(): ...
def test_send_email_when_smtp_down_retries_three_times(): ...
def test_parse_csv_when_header_missing_raises_parse_error(): ...
pytest.approx — Never Use == for Floats¶
# ❌ Will randomly fail due to floating point
assert 0.1 + 0.2 == 0.3 # False!
# ✅ Use approx
assert 0.1 + 0.2 == pytest.approx(0.3)
assert calculate_tax(100) == pytest.approx(8.25, abs=0.01)
assert [0.1, 0.2] == pytest.approx([0.1, 0.2])
Freezing Time with freezegun¶
from freezegun import freeze_time
from datetime import datetime
@freeze_time("2024-06-15 09:00:00")
def test_morning_greeting():
from myapp.greetings import get_greeting
assert get_greeting() == "Good morning!"
@freeze_time("2024-06-15 20:00:00")
def test_evening_greeting():
from myapp.greetings import get_greeting
assert get_greeting() == "Good evening!"
# As a context manager
def test_subscription_expiry():
with freeze_time("2024-01-01") as frozen:
sub = Subscription(duration_days=30)
frozen.move_to("2024-02-01")
assert sub.is_expired() is True
faker — Realistic Test Data¶
from faker import Faker
fake = Faker()
def test_user_profile_stores_all_fields():
user = User(
name = fake.name(),
email = fake.email(),
address = fake.address(),
phone = fake.phone_number(),
dob = fake.date_of_birth(minimum_age=18),
)
saved = user_repo.save(user)
assert saved.id is not None
assert saved.email == user.email
pytest-recording — Record and Replay HTTP¶
@pytest.mark.vcr()
def test_github_api():
"""First run hits real API and saves cassette. Future runs replay it."""
import requests
r = requests.get("https://api.github.com/repos/python/cpython")
data = r.json()
assert data["language"] == "Python"
pytest --record-mode=once # record cassettes once, replay forever
pytest --record-mode=none # always replay, never hit network (CI default)
Logging Capture¶
def test_warning_logged_on_missing_config(caplog):
import logging
with caplog.at_level(logging.WARNING):
from myapp.config import load_config
load_config(path="/nonexistent/path")
assert "Config file not found" in caplog.text
assert caplog.records[0].levelname == "WARNING"
Stdout / Stderr Capture¶
def test_print_output(capsys):
from myapp.reporter import print_summary
print_summary(items=[1, 2, 3])
captured = capsys.readouterr()
assert "Total: 3 items" in captured.out
assert captured.err == ""
Temporary Files & Directories¶
def test_config_written_to_disk(tmp_path):
config_file = tmp_path / "config.yaml"
from myapp.config import write_default_config
write_default_config(path=config_file)
assert config_file.exists()
content = config_file.read_text()
assert "database:" in content
monkeypatch — pytest's Built-in Patcher¶
def test_uses_env_database_url(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite:///test.db")
from myapp.config import get_db_url
assert get_db_url() == "sqlite:///test.db"
def test_home_directory(monkeypatch, tmp_path):
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
from myapp.storage import get_config_dir
assert str(tmp_path) in str(get_config_dir())
def test_patch_class_attribute(monkeypatch):
from myapp import settings
monkeypatch.setattr(settings, "MAX_RETRIES", 1)
# settings.MAX_RETRIES is 1 only for this test
monkeypatch vs patch
Use monkeypatch for simple attribute/env swaps in pytest. Use unittest.mock.patch when you need MagicMock features (call assertions, side_effect, etc.).
12. Anti-Patterns to Eliminate¶
❌ Test Interdependence¶
# ❌ Tests rely on each other's side effects
class TestUserFlow:
def test_1_create_user(self):
self.user = User.create("Alice") # sets class state
def test_2_activate_user(self):
self.user.activate() # depends on test_1 running first!
# ✅ Each test is fully self-contained
@pytest.fixture
def alice(db_session):
return User.create("Alice", session=db_session)
def test_activate_user(alice):
alice.activate()
assert alice.is_active is True
❌ Testing Implementation, Not Behaviour¶
# ❌ Tests internals — breaks when you refactor
def test_uses_cache():
svc = UserService()
svc.get_user(1)
assert svc._cache["user:1"] is not None # testing private state
# ✅ Tests observable behaviour
def test_repeated_calls_return_same_result():
svc = UserService()
first = svc.get_user(1)
second = svc.get_user(1)
assert first == second
❌ Overspecified Mocks¶
# ❌ Every method mocked, even ones not relevant to this test
def test_place_order():
mock_svc = MagicMock()
mock_svc.validate_address.return_value = True
mock_svc.check_stock.return_value = True
mock_svc.calculate_tax.return_value = 8.0
mock_svc.send_email.return_value = None # not relevant here!
mock_svc.log_event.return_value = None # not relevant here!
...
# ✅ Mock only what the test cares about
def test_place_order_calculates_correct_total():
mock_svc = MagicMock()
mock_svc.calculate_tax.return_value = 8.0
order = place_order(service=mock_svc, subtotal=100.0)
assert order.total == 108.0
❌ Magic Numbers¶
# ❌ What is 86400?
assert session.expires_in == 86400
# ✅ Self-documenting
ONE_DAY_IN_SECONDS = 86400
assert session.expires_in == ONE_DAY_IN_SECONDS
❌ time.sleep() in Tests¶
# ❌ Makes your suite 30s slower for every async job test
def test_background_job():
trigger_job()
time.sleep(5) # hoping the job finishes...
assert job.is_complete()
# ✅ Poll with a timeout
import time
def wait_for(condition, timeout=5.0, interval=0.05):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if condition():
return True
time.sleep(interval)
raise TimeoutError("Condition not met in time")
def test_background_job():
trigger_job()
wait_for(lambda: job.is_complete())
assert job.result == "done"
13. CI/CD Integration¶
GitHub Actions — Full Test Pipeline¶
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Lint
run: ruff check myapp/ tests/
- name: Type check
run: mypy myapp/
- name: Run tests
env:
DATABASE_URL: postgresql://test:test@localhost/testdb
AWS_ACCESS_KEY_ID: testing
AWS_SECRET_ACCESS_KEY: testing
AWS_DEFAULT_REGION: us-east-1
run: |
pytest -n auto \
--cov=myapp \
--cov-report=xml \
--cov-fail-under=85 \
--durations=10
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: coverage.xml
- name: Diff coverage on PR
if: github.event_name == 'pull_request'
run: |
pip install diff-cover
diff-cover coverage.xml --compare-branch=origin/main --fail-under=90
pyproject.toml — Centralised Test Config¶
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = [
"--strict-markers", # fail on unknown marks
"--strict-config",
"-ra", # show reasons for all non-passing tests
"--tb=short",
]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: requires live services",
"smoke: quick sanity checks",
]
[tool.coverage.run]
branch = true
source = ["myapp"]
omit = ["*/migrations/*", "*/tests/*"]
[tool.coverage.report]
fail_under = 85
show_missing = true
skip_covered = true
Pre-commit Hooks¶
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.0
hooks:
- id: ruff
args: [--fix]
- repo: local
hooks:
- id: pytest-smoke
name: Run smoke tests
entry: pytest -m smoke --tb=short -q
language: system
pass_filenames: false
always_run: true
14. Cheat Sheet¶
Pytest Flags You Should Know¶
| Flag | Purpose |
|---|---|
-x |
Stop on first failure |
-v |
Verbose output |
-s |
Don't capture stdout (useful with print()) |
-k "test_name" |
Run tests matching pattern |
-m "slow" |
Run tests with a specific mark |
--lf |
Re-run only last failed tests |
--ff |
Run failed tests first, then rest |
-n auto |
Parallel execution (requires pytest-xdist) |
--tb=short |
Shorter tracebacks |
--co |
Collect only — show what would run |
--durations=10 |
Show 10 slowest tests |
--pdb |
Drop into debugger on failure |
--snapshot-update |
Update syrupy snapshots |
--record-mode=once |
Record VCR cassettes |
Library Quick Reference¶
| Need | Library | Install |
|---|---|---|
| Mock objects | unittest.mock |
built-in |
| Parallel tests | pytest-xdist |
pip install pytest-xdist |
| Coverage | pytest-cov |
pip install pytest-cov |
| Fake data | faker |
pip install faker |
| Time freezing | freezegun |
pip install freezegun |
| Property-based | hypothesis |
pip install hypothesis |
| HTTP mocking (requests) | responses |
pip install responses |
| HTTP mocking (httpx) | respx |
pip install respx |
| AWS mocking | moto |
pip install moto[all] |
| Snapshot testing | syrupy |
pip install syrupy |
| HTTP record/replay | vcrpy |
pip install vcrpy |
| Model factories | factory-boy |
pip install factory-boy |
| Mutation testing | mutmut |
pip install mutmut |
| Diff coverage | diff-cover |
pip install diff-cover |
| Random order | pytest-randomly |
pip install pytest-randomly |
| Test timing | pytest-durations |
pip install pytest-durations |
The Testing Pyramid (Industry Standard)¶
/\
/ \
/ E2E\ ← Few, slow, expensive
/──────\
/ Integ. \ ← Some, medium speed
/──────────\
/ Unit Tests \ ← Many, fast, cheap
/______________\
- Unit tests: 70% of your suite — pure functions, service logic, validators
- Integration tests: 20% — DB queries, real HTTP calls with VCR, module interactions
- E2E tests: 10% — full user journeys, browser automation (Playwright/Selenium)