Skip to content

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
conftest.py
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:

conftest.py
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)
tests/test_permissions.py
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:

packages/test_helpers/pytest_plugin.py
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.

pip install hypothesis

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:

# .gitignore
.hypothesis/
# 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

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

pip install factory-boy
tests/factories.py
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)
tests/test_orders.py
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

myapp/cli.py
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}!")
tests/test_cli.py
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

myapp/cli.py
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.

pip install syrupy
tests/test_api_snapshots.py
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

pip install pytest-cov
pyproject.toml
[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

conftest.py
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               # fast tests only
pytest --run-slow    # include slow tests

pytest-randomly — Catch Order Dependencies

pip install pytest-randomly
pytest -p randomly --randomly-seed=last   # reproduce same order

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

test_{unit_of_work}_{state_under_test}_{expected_behaviour}
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

pip install 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

pip install faker
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

pip install pytest-recording vcrpy
@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

.github/workflows/test.yml
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

.pre-commit-config.yaml
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)