Integration Testing: Strategies, Patterns, and Best Practices
Integration testing verifies that multiple software modules or components work together correctly — it bridges the gap between fast-but-isolated unit tests and slow-but-comprehensive end-to-end tests.
What You’ll Learn
- Integration testing strategies: big bang, top-down, bottom-up, and sandwich
- How to set up database integration tests with test containers
- Contract testing with Pact for microservice interactions
- When to use real dependencies vs mocks in integration tests
- How to organize and maintain an integration test suite
Why Integration Testing Matters
Unit tests prove individual components work in isolation. But components that each work perfectly in isolation can fail when connected — wrong data shapes, missing fields, incorrect error propagation, timing issues. Integration tests catch these mismatches. A study by Google found that 35% of production bugs were integration issues that unit tests missed entirely. Integration testing is essential for catching these failures before they reach users.
Durga Antivirus Pro runs integration tests between signature databases, scan engines, and reporting services — a data format mismatch between any two components could cause missed detections.
Learning Path
flowchart LR
A[Unit Testing] --> B[Integration Testing<br/>You are here]
B --> C[Contract Testing]
C --> D[E2E Testing]
D --> E[CI/CD Pipeline]
style B fill:#f90,color:#fff
Integration Testing Strategies
Big Bang
All components are integrated at once and tested together.
Pros: Simple to plan, tests the full system. Cons: Hard to isolate failures. When the test fails, you don’t know which component caused it.
Top-Down
Test high-level components first, stubbing lower-level ones. Gradually replace stubs with real implementations.
Pros: Validates major workflows early. Cons: Lower-level components get tested later.
Bottom-Up
Test low-level components first, then compose them into higher-level tests.
Pros: Core infrastructure is validated first. Cons: No user-facing workflow testing until late.
Sandwich (Hybrid)
Combine top-down and bottom-up — test the UI layer and data layer simultaneously, meeting in the middle.
Pros: Balanced coverage. Cons: More complex to orchestrate.
Most teams use a hybrid approach: bottom-up for data access layers, top-down for API and UI layers.
Database Integration Testing
Testing database interactions requires careful setup. Use testcontainers for isolated, repeatable database tests:
# test_user_repository.py
import pytest
from testcontainers.postgres import PostgresContainer
from user_repository import UserRepository
@pytest.fixture(scope="module")
def postgres():
with PostgresContainer("postgres:16") as pg:
yield pg
@pytest.fixture
def repo(postgres):
# Set up connection and schema
connection_url = postgres.get_connection_url()
repo = UserRepository(connection_url)
repo.migrate() # Create tables
yield repo
repo.cleanup() # Drop tables
def test_insert_and_find_user(repo):
user = repo.create_user("alice@test.com", "Alice")
found = repo.find_by_email("alice@test.com")
assert found.id == user.id
assert found.name == "Alice"
def test_returns_none_for_missing_user(repo):
result = repo.find_by_email("missing@test.com")
assert result is None
def test_prevents_duplicate_email(repo):
repo.create_user("bob@test.com", "Bob")
with pytest.raises(IntegrityError):
repo.create_user("bob@test.com", "Bob Again")Key Principles
- Fresh database per test run: Never share database state between tests
- Clean up after each test: Drop or truncate tables
- Use transactions: Wrap each test in a transaction and roll back
- Speed matters: Keep schema light and data minimal
Contract Testing with Pact
Contract testing verifies that two services (consumer and provider) agree on the API contract between them. Pact is the most popular contract testing framework:
// consumer-side test (frontend service)
const { Pact } = require('@pact-foundation/pact');
const API = require('./api');
const provider = new Pact({
consumer: 'WebFrontend',
provider: 'UserService',
});
beforeAll(() => provider.setup());
afterEach(() => provider.verify());
afterAll(() => provider.finalize());
test('get user returns expected data', async () => {
await provider.addInteraction({
state: 'user alice exists',
uponReceiving: 'a request for user alice',
withRequest: {
method: 'GET',
path: '/users/alice',
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: 1,
name: 'Alice',
email: 'alice@test.com',
},
},
});
const user = await API.getUser('alice');
expect(user.name).toBe('Alice');
});The consumer generates a pact file that is shared with the provider. The provider runs its own tests to verify it can satisfy the contract:
# provider-side test (UserService)
from pact import Verifier
def test_user_service_contract():
verifier = Verifier(provider="UserService")
verifier.verify_with_broker(
broker_url="https://pact-broker.example.com",
provider_version="1.0.0",
)API Integration Testing
Test REST endpoints with real HTTP calls:
# test_api_integration.py
import pytest
import requests
BASE_URL = "http://localhost:8000/api"
class TestUserAPI:
def test_create_user(self):
response = requests.post(
f"{BASE_URL}/users",
json={"name": "Alice", "email": "alice@test.com"}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Alice"
assert "id" in data
def test_get_user(self):
# Create first
create_resp = requests.post(
f"{BASE_URL}/users",
json={"name": "Bob", "email": "bob@test.com"}
)
user_id = create_resp.json()["id"]
# Then retrieve
response = requests.get(f"{BASE_URL}/users/{user_id}")
assert response.status_code == 200
assert response.json()["name"] == "Bob"
def test_get_nonexistent_user(self):
response = requests.get(f"{BASE_URL}/users/99999")
assert response.status_code == 404Message Queue Integration
Test interactions with message brokers:
# test_event_publisher.py
import json
from event_publisher import EventPublisher
def test_publishes_user_registered_event():
publisher = EventPublisher(host="localhost", port=5672)
channel = publisher.connect()
# Set up test consumer
messages = []
def callback(ch, method, properties, body):
messages.append(json.loads(body))
channel.basic_consume(
queue="test_queue",
on_message_callback=callback,
auto_ack=True
)
# Publish event
publisher.publish("user.registered", {"user_id": 1, "email": "test@test.com"})
# Verify message received
assert len(messages) == 1
assert messages[0]["user_id"] == 1
assert messages[0]["email"] == "test@test.com"Common Integration Testing Mistakes
1. Using In-Memory Databases for Tests
SQLite in-memory behaves differently from PostgreSQL in production. You’ll pass tests but hit production bugs.
Fix: Use testcontainers or a real database instance matching production.
2. Shared Test Data
Tests that depend on each other’s data produce non-deterministic failures.
Fix: Each test sets up its own data. Clean up after each test.
3. Testing Through the UI
End-to-end browser tests are not integration tests. They’re slower and more brittle.
Fix: Test at the API layer for integration. Reserve E2E for critical user journeys.
4. Ignoring Network Failures
Integration tests that always succeed mask network issues.
Fix: Test timeout handling, retry logic, and graceful degradation.
5. Too Many End-to-End Tests Mistaken as Integration
A test that hits the database AND an external API AND a browser is E2E, not integration.
Fix: Each integration test should test one integration point, not the whole chain.
6. No Test for Error Propagation
Test what happens when a downstream service returns 500 or times out.
Fix: Test both happy paths and error paths for every integration point.
7. Flaky Tests Due to Timing
Async operations with hardcoded timeouts produce flaky tests.
Fix: Use retry mechanisms with backoff, or poll for expected state.
Practice Questions
1. What is the difference between big bang and incremental integration testing?
Big bang integrates everything at once; incremental integrates one component at a time. Incremental makes failure isolation easier.
2. Why should integration tests use real databases instead of in-memory substitutes?
In-memory databases have different behavior — SQL features, transaction isolation, locking — that mask production bugs.
3. What is contract testing and when would you use it?
Contract testing verifies that a consumer and provider agree on API contracts. Use it in microservice architectures where services are developed independently.
4. How do you ensure integration tests are isolated?
Each test creates its own data, uses its own transaction (rolled back after), and does not depend on other tests’ state.
5. What should you test beyond the happy path in integration tests?
Error handling — timeouts, 500 errors, invalid data, missing resources, and network failures.
Challenge: Set up a testcontainers-based integration test suite for a microservice that connects to PostgreSQL and Redis. Implement tests for data persistence, cache invalidation, and error scenarios.
FAQ
What’s Next
| Tutorial | What You’ll Learn |
|---|---|
| Contract Testing with Pact | Formal contract testing for microservices |
| End-to-End Testing Guide | Browser-level testing with Playwright |
| Test Automation Frameworks | Comparing testing tools and frameworks |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Updated 2026-06-20.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro