Skip to content
Integration Testing: Best Practices and Patterns

Integration Testing: Best Practices and Patterns

DodaTech Updated Jun 19, 2026 7 min read

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?

ApproachSpeedFidelityUse Case
In-memory SQLiteFastLowSimple queries, model layer
TestcontainersSlowHighReal database features, stored procedures
Docker composeMediumHighFull 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.24s

Seed 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 users

Best 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/postgres

Common 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

  1. What does integration testing verify? That different components (code, database, API) work together correctly at their boundaries.

  2. What is Testcontainers? A library that spins up real Docker containers (PostgreSQL, Redis, etc.) for tests, then tears them down.

  3. What is the best approach for managing test data? Programmatic seed data created per test or per test module, cleaned up after each run.

  4. How should you run integration tests in CI? Use service containers (Docker) for dependencies, separate from unit tests, run on pull requests.

  5. 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 is the difference between integration and E2E testing?
Integration tests verify component boundaries (code → database, service → API). E2E tests verify complete user journeys in a browser. Integration tests are faster and more focused.
How many integration tests should I write?
Focus on critical paths: database queries, API endpoints, message processing. 50-100 integration tests is common. More than that suggests you might be testing things that unit tests should cover.
Should I use mocks in integration tests?
Generally no. If you’re mocking the database, it’s no longer an integration test. Use real dependencies (possibly in Docker containers).
How do I handle authentication in integration tests?
Create a test helper that generates a valid JWT or session cookie with test credentials. Include auth tokens via headers in your test requests.
Can integration tests run in parallel?
Yes, if each test uses its own database or sandbox. Testcontainers supports parallel execution. Parallel tests need independent data and ports.

What’s Next

TutorialWhat You’ll Learn
End-to-End Testing with PlaywrightFull browser-level testing
Mocking in Tests GuideWhen to mock instead of integrate
Test-Driven Development GuideWriting 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