Integration tests live in the middle of the testing pyramid: slower than unit tests, faster and cheaper than end-to-end tests, and essential for catching the failure modes unit tests structurally cannot see. Your user service may have perfect unit test coverage, and it will still fail in production if the SQL query it generates violates a database constraint. Integration tests are how you find that before deployment.
What changed in 2026
- Testcontainers became the standard for spinning up real databases in CI. The Java, Python, Go, and Node.js clients are all stable and fast (~3–5 s to start a Postgres container).
- Docker Compose v2 (now the default) simplified multi-service test environments with
--wait for health checks.
- Vitest and pytest-asyncio make writing async integration tests as simple as unit tests.
- GitHub Actions caching of container images cut integration test CI time by 40–60% for most teams.
What integration tests cover
| Scenario |
Why unit tests miss it |
| SQL query correctness |
Unit tests mock the database |
| HTTP client + real API contract |
Unit tests stub the response |
| Cache hit/miss logic with Redis |
Unit tests mock the cache layer |
| Message queue publish/consume |
Unit tests mock the broker |
| ORM migrations + constraints |
Unit tests use in-memory fakes |
Anywhere two components connect is a seam. Integration tests verify seams.
A minimal Postgres integration test (Node.js + Vitest)
// order.repository.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { GenericContainer, StartedTestContainer } from "testcontainers";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { OrderRepository } from "./order.repository";
let container: StartedTestContainer;
let repo: OrderRepository;
beforeAll(async () => {
container = await new GenericContainer("postgres:16")
.withEnvironment({ POSTGRES_PASSWORD: "test", POSTGRES_DB: "testdb" })
.withExposedPorts(5432)
.start();
const pool = new Pool({
host: container.getHost(),
port: container.getMappedPort(5432),
database: "testdb",
password: "test",
user: "postgres",
});
repo = new OrderRepository(drizzle(pool));
await runMigrations(pool); // apply schema
}, 30_000);
afterAll(() => container.stop());
it("saves and retrieves an order", async () => {
const order = { id: "ord-1", amount: 99 };
await repo.save(order);
const found = await repo.findById("ord-1");
expect(found).toMatchObject(order);
});
Test isolation strategies
| Strategy |
How |
Best for |
| Wrap in transaction, rollback |
Begin transaction in beforeEach, rollback in afterEach |
Fast, no cleanup code |
| Truncate tables |
DELETE all rows in afterEach |
Works across multiple connections |
| Fresh schema per test |
Create a schema per test, drop after |
Parallel tests with no collision |
| Separate database per test run |
Each CI job gets its own DB |
Maximum isolation, needs provisioning |
Transaction rollback is the fastest and most common approach for PostgreSQL/MySQL. It does not work across services that use separate connections (e.g., testing a service that spawns a worker process).
Python example with pytest + Testcontainers
# test_user_repository.py
import pytest
from testcontainers.postgres import PostgresContainer
from myapp.db import create_engine, run_migrations
from myapp.repositories import UserRepository
@pytest.fixture(scope="session")
def pg():
with PostgresContainer("postgres:16") as pg:
yield pg
@pytest.fixture
def repo(pg):
engine = create_engine(pg.get_connection_url())
run_migrations(engine)
return UserRepository(engine)
def test_find_user_by_email(repo):
repo.create(email="a@example.com", name="Alice")
user = repo.find_by_email("a@example.com")
assert user.name == "Alice"
How to pick
- Service with database queries — write integration tests for every non-trivial query.
- HTTP client wrapping a third-party API — use WireMock or
msw to record/replay real responses.
- Message queue publish/consume — Testcontainers has first-class support for RabbitMQ and Kafka.
- Pure business logic — stick to unit tests; integration tests add cost with no benefit here.
Common mistakes
Shared mutable test data. Tests that depend on the order they run or the presence of rows left by another test are a reliability disaster. Isolate.
Testing too much in one test. An integration test that creates 5 objects, makes 3 service calls, and asserts 10 things is a debugging maze. Keep the scope narrow.
Slow setup not cached. Starting a Postgres container for every test function is needlessly slow. Use session-scoped or suite-scoped fixtures.
Skipping integration tests in CI. Unit tests plus no integration tests means bugs ship. At minimum run the integration suite on every merge to main.
What to skip
- Full application startup for a repository test — you do not need an HTTP server to test a database query.
- Real external API calls in CI — use WireMock, VCR, or
msw to record once and replay; external APIs are slow, flaky, and have rate limits.
- Writing integration tests for every unit — only test the seams; leave logic testing to unit tests.
FAQ
How long should integration tests take?
Aim for under 30 seconds for the full integration suite. If it exceeds 60 seconds, look for redundant container starts or overly broad test scope.
Can integration tests run in parallel?
Yes, with proper isolation. Use separate schemas or databases per parallel worker; never share state.
Should I mock the database in integration tests?
No. If you mock the database, it is a unit test. Use a real database — Testcontainers makes this cheap.
Where do integration tests live in the repo?
Common conventions: tests/integration/ (separate directory), or alongside source files with a .integration.test.ts suffix. Keep them separate so you can run just unit tests locally.
Where to go next
See Unit testing explained in 2026, Mocking explained in 2026, and How to write a Dockerfile in 2026.