Python Testing: Pytest, Mocking, and Async Tests

Writing fast, reliable tests requires understanding pytest fixture scopes, knowing where and how to patch external dependencies without leaking mocks, and testing asynchronous web services cleanly.


1. Fixture Scopes and Lifecycle in conftest.py

pytest fixtures use dependency injection. A test declares what it needs as arguments, and pytest resolves them. Scopes determine how often a fixture executes:

ScopeFrequencyBest Suited For
function (default)Runs before & after each test functionIsolated database transactions, temporary files, clean mock state
classRuns once per test classGrouped tests sharing read-only state
moduleRuns once per test .py fileExpensive fixtures shared within a single test module
sessionRuns once across the entire test runStarting Docker containers (Testcontainers), global DB connection pools
# conftest.py
import pytest
from collections.abc import Generator
 
@pytest.fixture(scope="session")
def global_db_engine():
    # Setup runs ONCE for the entire pytest execution:
    print("\n[GLOBAL] Initializing test database connection pool...")
    engine = {"pool": "connected", "dsn": "postgres://localhost:5432/test_db"}
    yield engine
    # Teardown runs when all tests complete:
    print("\n[GLOBAL] Dropping test database connection pool.")
 
@pytest.fixture(scope="function")
def db_session(global_db_engine) -> Generator[dict, None, None]:
    # Setup runs before EACH test function:
    session = {"engine": global_db_engine["dsn"], "txn_id": "active_txn"}
    print("\n  [TXN] Beginning isolated transaction...")
    yield session
    # Teardown rolls back changes:
    print("  [TXN] Rolling back transaction (clean state preserved).")

2. Table-Driven Tests with @pytest.mark.parametrize

Parametrization runs the exact same test logic across multiple input/output fixtures without duplicating test functions:

import pytest
 
def calculate_tier_discount(price: float, tier: str) -> float:
    discounts = {"bronze": 0.0, "silver": 0.10, "gold": 0.20}
    if tier not in discounts:
        raise ValueError(f"Unknown tier: {tier}")
    return price * (1.0 - discounts[tier])
 
@pytest.mark.parametrize("price,tier,expected_price", [
    (100.0, "bronze", 100.0),
    (100.0, "silver", 90.0),
    (100.0, "gold", 80.0),
    (50.0,  "gold", 40.0),
])
def test_discounts(price, tier, expected_price):
    assert calculate_tier_discount(price, tier) == expected_price
 
def test_invalid_tier_raises_error():
    # Verify expected exceptions cleanly:
    with pytest.raises(ValueError, match="Unknown tier: platinum"):
        calculate_tier_discount(100.0, "platinum")

3. The #1 Python Mocking Gotcha: Where to Patch

The single most common source of broken tests in Python is patching an object in the wrong module:

CRITICAL

The Golden Patch Rule:
Patch where an object is LOOKED UP / IMPORTED, NOT where it is originally DEFINED!

The Scenario:

Imagine src/services/billing.py has:

# src/services/billing.py
import requests
 
def charge_customer(card_id: str):
    return requests.post("https://api.stripe.com/charge", json={"card": card_id})

The Test:

from unittest.mock import patch
 
# ❌ WRONG: Patching requests where it was defined!
# with patch("requests.post") ... (billing.py already imported its own local reference!)
 
# ✅ CORRECT: Patch requests inside the module that uses it:
@patch("src.services.billing.requests.post")
def test_charge_customer(mock_post):
    mock_post.return_value.status_code = 200
    mock_post.return_value.json.return_value = {"charge_id": "ch_123"}
 
    from src.services.billing import charge_customer
    response = charge_customer("card_xyz")
    assert response.status_code == 200

4. Mocking Strategies Comparison Matrix

ToolScopeBest Used ForTrade-Off
unittest.mock.patchContext manager / DecoratorMocking third-party functions, external HTTP clients, and librariesString import path can be typo-prone
pytest.monkeypatchFunction fixtureModifying os.environ, sys.argv, and dictionary keys during testsManual restore logic, slightly more verbose
FastAPI dependency_overridesApplication DI engineSwapping real DB sessions or Auth services with mock repositoriesOnly applies to FastAPI dependency trees

5. Testing FastAPI Endpoints with httpx.AsyncClient & Overrides

Test asynchronous FastAPI endpoints in-memory without starting live TCP network ports:

import pytest
from httpx import ASGITransport, AsyncClient
from fastapi import FastAPI, Depends
 
app = FastAPI()
 
# Real production dependency:
def get_user_repository():
    return "production_postgresql_repository"
 
@app.get("/users/{user_id}")
def get_user(user_id: int, repo=Depends(get_user_repository)):
    return {"user_id": user_id, "source": repo}
 
@pytest.mark.asyncio
async def test_get_user_endpoint():
    # 1. Inject mock dependency:
    app.dependency_overrides[get_user_repository] = lambda: "mock_in_memory_repo"
 
    # 2. Test ASGI app in-memory using ASGITransport (super fast!):
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client:
        response = await client.get("/users/42")
 
    # 3. Always clean up dependency overrides to prevent test leakage:
    app.dependency_overrides.clear()
 
    assert response.status_code == 200
    assert response.json() == {"user_id": 42, "source": "mock_in_memory_repo"}

Next Steps