Integration tests exist in the awkward middle ground between unit tests that run instantly and end-to-end tests that take forever — and most codebases either skip them entirely or write them so poorly that the suite becomes a maintenance burden. In 2026, the tooling has matured enough that there is no excuse for either extreme. You can have fast, reliable integration tests that developers actually run.
What changed in 2026
- Testcontainers is now the default. The JVM library became cross-language; Go, Python, Node, and Rust all have stable Testcontainers clients. Spinning up a real Postgres for a test suite takes ~5 seconds on modern CI hardware.
- Contract testing grew up. Pact and its ecosystem matured. Consumer-driven contracts are now a standard pattern for microservice boundaries, not just a niche practice.
- Vitest and pytest-anyio handle async properly. Testing async database calls and HTTP handlers without workarounds is now straightforward.
- Shared container lifecycles. Testing frameworks now support module-scoped or session-scoped containers, so you pay the startup cost once per suite, not once per test.
What an integration test is
An integration test exercises the interaction between two or more real components — your code + a real database, your HTTP handler + a real message queue, your service + a real cache. It does not mock the integration boundary.
| Test type |
What it exercises |
Typical runtime |
| Unit test |
One function/class, all deps mocked |
< 1 ms |
| Integration test |
2–3 real components together |
20–500 ms |
| Contract test |
API shape between two services |
50–200 ms |
| E2E test |
Full stack, real browser or client |
1–30 s |
The boundary is judgment: when you mock the database, it becomes a unit test. When you spin up a real database, it becomes an integration test.
Setting up Testcontainers
# Python + pytest example
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine, text
@pytest.fixture(scope="session")
def pg_engine():
with PostgresContainer("postgres:16") as pg:
engine = create_engine(pg.get_connection_url())
# run migrations once
run_migrations(engine)
yield engine
def test_user_created(pg_engine):
with pg_engine.connect() as conn:
conn.execute(text("INSERT INTO users(name) VALUES ('alice')"))
conn.commit()
row = conn.execute(text("SELECT name FROM users WHERE name='alice'")).fetchone()
assert row[0] == "alice"
The scope="session" key is critical: Postgres starts once for the whole test session, not once per test. This cuts a 100-test suite from 8 minutes to under 30 seconds.
What changed in 2026
Testcontainers 2.x introduced reusable containers: add reuse=True and the container survives between test runs in local development, making iterative test runs nearly instant. In CI you still get a fresh container per run for isolation.
Testing an HTTP handler end-to-end (within a service)
// TypeScript + Vitest + Fastify
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { createApp } from '../src/app'
import { GenericContainer } from 'testcontainers'
let app: any
let pg: any
beforeAll(async () => {
pg = await new GenericContainer('postgres:16')
.withEnvironment({ POSTGRES_PASSWORD: 'test' })
.start()
app = await createApp({ dbUrl: pg.getConnectionUri() })
await app.listen({ port: 0 })
})
afterAll(async () => {
await app.close()
await pg.stop()
})
describe('POST /users', () => {
it('creates a user and returns 201', async () => {
const res = await app.inject({
method: 'POST',
url: '/users',
payload: { name: 'bob' },
})
expect(res.statusCode).toBe(201)
expect(res.json()).toMatchObject({ name: 'bob' })
})
})
How to structure your suite
Separate integration tests from unit tests at the file level and in your CI pipeline:
tests/
unit/ # fast, no I/O, run on every save
integration/ # testcontainers, run on push
contract/ # pact or schemathesis, run on push
e2e/ # playwright or k6, run on merge to main
Tag integration tests so you can run them selectively:
# pytest
pytest tests/integration -m integration --timeout=60
# vitest
vitest run --project integration
How to pick what to integration-test
- Database access layer — every query that writes or reads critical state.
- HTTP handlers — each endpoint's happy path and the important error paths (404, 409, 422).
- Message queue consumers — publish a message to the real broker, assert the side effects.
- Cache invalidation logic — the bugs almost always live at the cache + DB boundary.
- Auth middleware — test with a real token verification, not a mocked one.
Skip testing framework code, generated ORM queries you did not write, and things covered thoroughly by the framework's own tests.
Common mistakes
One container per test. Startup cost dominates. Always scope containers to the session or at minimum the module.
Flaky async teardown. Tests fail in CI because previous test data leaks into the next test. Wrap each test in a transaction and roll it back:
@pytest.fixture(autouse=True)
def rollback(pg_engine):
with pg_engine.begin() as conn:
yield conn
conn.rollback()
Testing too much in one test. If a single test exercises three services, a message queue, and a cache — split it. When it fails you will not know which component broke.
Ignoring test data isolation. Use unique IDs (UUIDs) or per-test schemas. Shared row IDs across concurrent tests cause mysterious race condition failures.
What to skip
- Mocking your ORM inside an integration test — that defeats the point.
- Testing Postgres itself — you trust the database; test your queries.
- Slow seed scripts that rebuild 50 MB of data for each test file — seed once at session scope, clean up at test scope.
FAQ
How long should an integration test suite take?
Under 2 minutes for most backends. If it is longer, audit container reuse and parallelism settings first.
Should I use a separate test database?
Yes — never run integration tests against a shared dev or staging database. Testcontainers gives you an ephemeral database per run by default.
How do integration tests differ from E2E tests?
Integration tests stay within the service boundary (or between two well-defined services). E2E tests drive the full stack from the user interface down. Use both; they catch different bugs.
What is contract testing and when do I need it?
Contract testing (Pact, Schemathesis) verifies that a provider API still satisfies what consumers expect. Use it when two teams own two services that talk to each other; without it, integration breakage is discovered in staging, not CI.
Where to go next