Python Integration Testing Patterns — Core Concepts
What makes a test an integration test
An integration test verifies that two or more components work correctly together. Unlike unit tests that isolate a single function with mocked dependencies, integration tests use real (or close-to-real) implementations.
The boundary is sometimes fuzzy. A test that hits a real database but mocks the email sender is partially integrated. In practice, most teams use “integration test” to mean any test that crosses a component boundary — calling a real database, making a real HTTP request, or reading a real file system.
The testing pyramid perspective
The classic testing pyramid places integration tests in the middle:
- Unit tests (base): Many, fast, test individual functions
- Integration tests (middle): Fewer, slower, test component interactions
- End-to-end tests (top): Fewest, slowest, test the full system
Integration tests exist because unit tests with mocks can pass while the real system fails. A mocked database query always returns what you told it to. A real database might return data in a different order, with different types, or throw connection errors your mock never simulated.
Database integration testing
The most common Python integration test pattern involves databases. Two approaches work well:
Transaction rollback: Start a database transaction before each test, run the test, then rollback. Tests get a real database but leave no traces. This is fast because no data actually persists between tests.
Test database: Create a fresh database (or schema) for the test suite. Tests run against this database, and it’s destroyed after the suite. This is slower but more realistic, catching issues like migration problems and constraint violations.
Most Python teams use the transaction rollback approach for speed, switching to a fresh database only for tests that verify migrations or database-specific behavior.
API integration testing
For web applications, integration tests often hit the API layer with a test client. Frameworks like Flask and FastAPI provide test clients that send HTTP requests without starting a real server:
The test client sends requests through the full middleware stack, request parsing, route matching, and response serialization — everything except the actual network layer. This catches bugs in routing, serialization, authentication middleware, and request validation that unit tests miss.
External service management
Integration tests that depend on external services (databases, Redis, Elasticsearch) need those services available. Three patterns manage this:
Docker containers: Use pytest-docker or testcontainers-python to start services in containers before tests run. Portable and reproducible, but adds startup time.
CI service containers: CI platforms like GitHub Actions and GitLab CI support service containers. Define the services in your CI config and they’re available during test runs.
Conditional skipping: Skip integration tests when services aren’t available. Use pytest.mark.skipif to check for database connectivity. This lets developers run unit tests locally without full infrastructure.
Isolation strategies
Integration tests are inherently stateful. Tests that share a database can interfere with each other. Three isolation strategies prevent this:
Transaction isolation: Wrap each test in a transaction that rolls back. Fast and clean, but doesn’t work for code that manages its own transactions.
Schema isolation: Each test (or test class) gets its own database schema. More isolated but slower to set up.
Data-level isolation: Tests use unique identifiers (random UUIDs, prefixed names) to avoid collisions. Less clean but works when transaction control isn’t possible.
Common anti-patterns
Testing implementation, not behavior: An integration test that checks “the ORM executed exactly this SQL query” is fragile. Test the observable outcome: “after calling create_user, the user exists in the database with the correct attributes.”
Slow feedback loops: If integration tests take 20 minutes, developers stop running them. Keep the integration suite under 5 minutes by parallelizing and using fast isolation strategies.
Incomplete cleanup: Tests that leave data behind cause cascading failures. Every test should clean up after itself, or use a pattern (transaction rollback, fresh containers) that handles cleanup automatically.
One thing to remember: Integration tests catch a fundamentally different category of bugs than unit tests — the bugs that live in the spaces between components. A balanced test suite needs both.
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.