Python Integration Testing Patterns — Deep Dive
Database integration with transaction rollback
The transaction rollback pattern provides fast, isolated database testing. Each test runs inside a transaction that’s rolled back after the test, so no data persists:
# conftest.py
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
@pytest.fixture(scope="session")
def engine():
return create_engine("postgresql://test:test@localhost:5432/testdb")
@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):
"""Each test gets a transactional session that rolls back."""
connection = engine.connect()
transaction = connection.begin()
session = sessionmaker(bind=connection)()
# Handle nested transactions (SAVEPOINT)
nested = connection.begin_nested()
@event.listens_for(session, "after_transaction_end")
def restart_savepoint(session, transaction):
nonlocal nested
if transaction.nested and not transaction._parent.nested:
nested = connection.begin_nested()
yield session
session.close()
transaction.rollback()
connection.close()
The nested savepoint handler is crucial. If the code under test calls session.commit(), the savepoint is committed (not the outer transaction). The outer transaction still rolls back at the end, providing isolation while allowing the tested code to commit normally.
Testcontainers for reproducible infrastructure
Testcontainers spins up Docker containers for external services, ensuring tests use real implementations:
# conftest.py
import pytest
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:16-alpine") as pg:
yield pg
@pytest.fixture(scope="session")
def redis():
with RedisContainer("redis:7-alpine") as r:
yield r
@pytest.fixture(scope="session")
def db_url(postgres):
return postgres.get_connection_url()
@pytest.fixture(scope="session")
def redis_url(redis):
host = redis.get_container_host_ip()
port = redis.get_exposed_port(6379)
return f"redis://{host}:{port}/0"
@pytest.fixture
def app(db_url, redis_url):
"""Application configured with test infrastructure."""
return create_app(
database_url=db_url,
redis_url=redis_url,
testing=True,
)
Containers start once per session and are destroyed after all tests complete. The startup cost (5-15 seconds) is amortized across hundreds of tests.
API integration testing patterns
Test the full HTTP stack using framework test clients:
# tests/integration/test_user_api.py
import pytest
from fastapi.testclient import TestClient
class TestUserAPI:
"""Integration tests for the user management API."""
def test_create_and_retrieve_user(self, client, db_session):
"""Full lifecycle: create user, verify storage, retrieve via API."""
# Create
response = client.post("/api/users", json={
"name": "Alice Johnson",
"email": "alice@example.com",
})
assert response.status_code == 201
user_id = response.json()["id"]
# Verify in database (not just API response)
from models import User
db_user = db_session.query(User).get(user_id)
assert db_user is not None
assert db_user.email == "alice@example.com"
# Retrieve via API
response = client.get(f"/api/users/{user_id}")
assert response.status_code == 200
assert response.json()["name"] == "Alice Johnson"
def test_duplicate_email_rejected(self, client):
"""Database constraint prevents duplicate emails."""
payload = {"name": "Alice", "email": "dupe@example.com"}
client.post("/api/users", json=payload)
response = client.post("/api/users", json=payload)
assert response.status_code == 409
assert "already exists" in response.json()["detail"]
def test_authentication_required(self, client):
"""Protected endpoints reject unauthenticated requests."""
response = client.get("/api/users/me")
assert response.status_code == 401
def test_pagination_with_real_data(self, client, make_user):
"""Pagination works correctly with actual database records."""
for i in range(25):
make_user(name=f"User {i}")
page1 = client.get("/api/users?page=1&per_page=10")
assert len(page1.json()["items"]) == 10
assert page1.json()["total"] == 25
page3 = client.get("/api/users?page=3&per_page=10")
assert len(page3.json()["items"]) == 5
These tests catch issues that unit tests with mocked repositories miss: SQL errors, serialization problems, middleware interactions, and pagination edge cases.
Testing event-driven architectures
For systems using message queues, integration tests verify the full publish-consume cycle:
# tests/integration/test_order_events.py
import pytest
import json
import time
@pytest.fixture
def event_bus(redis_url):
"""In-memory event bus backed by Redis for testing."""
from myapp.events import RedisPubSub
bus = RedisPubSub(redis_url)
yield bus
bus.close()
class TestOrderEventFlow:
def test_order_creation_publishes_event(self, client, event_bus):
"""Creating an order publishes an OrderCreated event."""
received_events = []
event_bus.subscribe("order.created", received_events.append)
client.post("/api/orders", json={
"items": [{"sku": "WIDGET-1", "quantity": 2}],
})
# Allow async processing
time.sleep(0.5)
assert len(received_events) == 1
event = json.loads(received_events[0])
assert event["type"] == "order.created"
assert event["data"]["items"][0]["sku"] == "WIDGET-1"
def test_inventory_updates_on_order(self, client, db_session, event_bus):
"""Inventory service processes OrderCreated events."""
# Set initial stock
from models import Inventory
db_session.add(Inventory(sku="WIDGET-1", stock=10))
db_session.commit()
# Create order (triggers event → inventory handler)
client.post("/api/orders", json={
"items": [{"sku": "WIDGET-1", "quantity": 3}],
})
time.sleep(1.0) # Wait for async processing
inventory = db_session.query(Inventory).filter_by(sku="WIDGET-1").first()
assert inventory.stock == 7
External API integration with record/replay
For third-party APIs, use VCR.py to record real responses and replay them in tests:
import pytest
import vcr
class TestPaymentIntegration:
@vcr.use_cassette("tests/cassettes/stripe_charge_success.yaml")
def test_successful_payment(self, payment_service):
"""First run records real Stripe API call; subsequent runs replay."""
result = payment_service.charge(
amount=2999,
currency="usd",
source="tok_visa",
)
assert result.status == "succeeded"
assert result.amount == 2999
@vcr.use_cassette("tests/cassettes/stripe_charge_declined.yaml")
def test_declined_payment(self, payment_service):
result = payment_service.charge(
amount=2999,
currency="usd",
source="tok_chargeDeclined",
)
assert result.status == "failed"
assert "declined" in result.error_message
Cassettes store the HTTP interaction, making tests fast and deterministic while ensuring they were validated against real APIs at recording time. Periodically re-record cassettes to catch API changes.
CI pipeline architecture for integration tests
Separate integration tests from unit tests in CI for optimal feedback:
# .github/workflows/test.yml
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pytest tests/unit/ -x --tb=short -q
# Fast feedback: ~30 seconds
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_PASSWORD: test
ports: ["5432:5432"]
redis:
image: redis:7
ports: ["6379:6379"]
steps:
- uses: actions/checkout@v4
- run: pytest tests/integration/ -x --tb=short -q
# Thorough check: ~3-5 minutes
e2e-tests:
needs: [unit-tests, integration-tests]
runs-on: ubuntu-latest
steps:
- run: pytest tests/e2e/ --tb=short
# Full system: ~10-15 minutes
Unit tests run in parallel with integration tests. End-to-end tests only run if both pass. This gives developers fast feedback on the most common failures while still catching integration issues before merge.
Measuring integration test effectiveness
Track these metrics to keep your integration suite healthy:
- Unique bug detection: How many bugs do integration tests catch that unit tests miss? If the answer is “almost none,” the integration tests may be redundant.
- Execution time per test: Integration tests over 10 seconds each need investigation. Slow tests are usually doing too much or have inefficient setup.
- Flakiness rate: Integration tests are more flaky than unit tests due to external dependencies. Keep this under 1% by using deterministic data, proper timeouts, and container health checks.
One thing to remember: Integration tests are the most cost-effective investment for catching real-world bugs. Unit tests prove components work in isolation. Integration tests prove they work in production. The gap between those two is where most production bugs live.
See Also
- Python Acceptance Testing Patterns How Python teams verify software does what real users actually asked for.
- Python Approval Testing How approval testing lets you verify complex Python output by comparing it to a saved 'golden' copy you already checked.
- Python Behavior Driven Development Get an intuitive feel for Behavior Driven Development so Python behavior stops feeling unpredictable.
- Python Browser Automation Testing How Python can control a web browser like a robot to test websites automatically.
- Python Chaos Testing Applications Why breaking your own Python systems on purpose makes them stronger.