Integration Testing: Best Practices and Patterns
Integration testing verifies that different components of your application work together correctly — testing the boundaries between your code and external systems like databases, APIs, message queues, and file systems.
What You’ll Learn
- What integration testing is and how it differs from unit and E2E testing
- Testing database interactions with real and in-memory databases
- API integration tests with test containers and seeded data
- Patterns for reliable, maintainable integration tests
- CI pipeline integration for integration test suites
Why Integration Testing Matters
Unit tests verify that each function works in isolation. But the real bugs often live at the boundaries — the SQL query that returns different results than expected, the API endpoint that sends the wrong format, the message that doesn’t deserialize correctly. Integration tests catch these boundary bugs.
DodaZIP runs integration tests against actual compressed archives to verify that its compression engine handles real-world file formats correctly.
Learning Path
flowchart LR
A[Unit Testing] --> B[Mocking]
B --> C[Integration Testing<br/>You are here]
C --> D[Test Containers]
D --> E[CI Pipeline Integration]
style C fill:#f90,color:#fff
Testing Databases
The key question: should you test against a real database or an in-memory substitute?
| Approach | Speed | Fidelity | Use Case |
|---|---|---|---|
| In-memory SQLite | Fast | Low | Simple queries, model layer |
| Testcontainers | Slow | High | Real database features, stored procedures |
| Docker compose | Medium | High | Full service integration |
In-Memory SQLite (Fast, Lower Fidelity)
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from myapp.models import Base, User
@pytest.fixture
def db_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
def test_create_user(db_session):
user = User(name="Alice", email="alice@example.com")
db_session.add(user)
db_session.commit()
saved = db_session.query(User).filter_by(email="alice@example.com").first()
assert saved is not None
assert saved.name == "Alice"Testcontainers (Slower, Real Database)
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine
@pytest.fixture(scope="module")
def postgres():
with PostgresContainer("postgres:16") as postgres:
engine = create_engine(postgres.get_connection_url())
yield engine
def test_postgres_features(postgres):
# Test PostgreSQL-specific features
with postgres.connect() as conn:
conn.execute("""
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(255) UNIQUE
)
""")
conn.execute(
"INSERT INTO users (name, email) VALUES (%s, %s)",
("Alice", "alice@example.com")
)
result = conn.execute("SELECT * FROM users WHERE email = %s", ("alice@example.com",))
user = result.fetchone()
assert user.name == "Alice"API Integration Tests
Testing API endpoints with real HTTP calls to a test server:
# test_api.py
import pytest
from fastapi.testclient import TestClient
from myapp.main import app
from myapp.database import get_db
from myapp.models import Base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Test database
TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(TEST_DATABASE_URL)
TestingSessionLocal = sessionmaker(bind=engine)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
@pytest.fixture(autouse=True)
def setup_database():
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
def test_create_user():
response = client.post("/users/", json={
"name": "Alice",
"email": "alice@example.com"
})
assert response.status_code == 201
data = response.json()
assert data["name"] == "Alice"
assert data["email"] == "alice@example.com"
assert "id" in data
def test_get_user_not_found():
response = client.get("/users/999")
assert response.status_code == 404
assert response.json()["detail"] == "User not found"
def test_duplicate_email():
client.post("/users/", json={
"name": "Alice",
"email": "alice@example.com"
})
response = client.post("/users/", json={
"name": "Bob",
"email": "alice@example.com"
})
assert response.status_code == 400
assert "already exists" in response.json()["detail"]Expected test output
test_api.py::test_create_user PASSED
test_api.py::test_get_user_not_found PASSED
test_api.py::test_duplicate_email PASSED
3 passed in 1.24sSeed Data Strategies
Integration tests need data. Manage it with seed data:
# conftest.py
@pytest.fixture
def seed_users(db_session):
users = [
User(name="Alice", email="alice@example.com", role="admin"),
User(name="Bob", email="bob@example.com", role="user"),
User(name="Charlie", email="charlie@example.com", role="user"),
]
for user in users:
db_session.add(user)
db_session.commit()
return users
# test_admin.py
def test_admin_can_view_all_users(client, seed_users):
login_response = client.post("/login", json={
"email": "alice@example.com",
"password": "admin123"
})
token = login_response.json()["token"]
response = client.get("/admin/users", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
assert len(response.json()) == 3 # All seeded usersBest practice: Create seed data programmatically (as above) rather than loading SQL files. Programmatic seeds are easier to maintain and version.
JavaScript API Integration Tests
// test/api/users.test.js
const request = require('supertest');
const app = require('../../app');
const { User, sequelize } = require('../../models');
beforeAll(async () => {
await sequelize.sync({ force: true });
});
afterAll(async () => {
await sequelize.close();
});
describe('POST /api/users', () => {
it('creates a new user', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@test.com' })
.expect(201);
expect(response.body).toMatchObject({
name: 'Alice',
email: 'alice@test.com',
});
expect(response.body).toHaveProperty('id');
});
it('rejects duplicate emails', async () => {
await User.create({ name: 'Alice', email: 'alice@test.com' });
await request(app)
.post('/api/users')
.send({ name: 'Bob', email: 'alice@test.com' })
.expect(400);
});
});CI Pipeline Integration
# .github/workflows/integration.yml
name: Integration Tests
on: [pull_request]
jobs:
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install -r requirements.txt
- run: pytest tests/integration/
env:
DATABASE_URL: postgresql://postgres:testpass@localhost:5432/postgresCommon Errors
1. Not Cleaning Database Between Tests
Tests that share state produce random failures. Use autouse=True fixtures with drop_all and create_all per test or per module.
2. Using Production Database Credentials
Never point integration tests at a production database. Use a separate test database or Testcontainers.
3. Ignoring Transaction Management
Tests that don’t roll back changes leave test data in the database. Use transaction rollback or fresh database per test.
4. Hardcoding Ports and URLs
Use environment variables or test fixtures for configuration. Hardcoded ports fail in CI.
5. Tests That Depend on Seed Data Order
If a test assumes User ID 1 exists, it breaks when seed order changes. Query by known attributes instead.
6. Not Testing Error Responses
Most integration tests check the success path. Test 404, 400, 401, 500 responses too.
7. Slow Test Suite
Integration tests are naturally slower. Separate them from unit tests (tests/unit/ vs tests/integration/) and run them in CI only for relevant changes.
Practice Questions
What does integration testing verify? That different components (code, database, API) work together correctly at their boundaries.
What is Testcontainers? A library that spins up real Docker containers (PostgreSQL, Redis, etc.) for tests, then tears them down.
What is the best approach for managing test data? Programmatic seed data created per test or per test module, cleaned up after each run.
How should you run integration tests in CI? Use service containers (Docker) for dependencies, separate from unit tests, run on pull requests.
Why use an in-memory SQLite for testing instead of PostgreSQL? SQLite in-memory is fast and requires no setup. But it doesn’t support PostgreSQL-specific features — use Testcontainers when you need real PostgreSQL behavior.
Challenge: Set up an integration test suite for a small CRUD application (e.g., a todo list API with PostgreSQL). Include tests for: create, read, update, delete, duplicate detection, not-found handling, and input validation. Use Testcontainers or CI service containers.
FAQ
What’s Next
| Tutorial | What You’ll Learn |
|---|---|
| End-to-End Testing with Playwright | Full browser-level testing |
| Mocking in Tests Guide | When to mock instead of integrate |
| Test-Driven Development Guide | Writing tests before code |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Updated 2026-06-19.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro