Integration Tests Catch What Unit Tests Can't β Without the Cost of E2E
Unit tests verify behavior in isolation. End-to-end tests verify the whole system. Integration tests sit in the middle: real database, real HTTP, real ORM mapping, real serialization β but no browser, no flaky CI on every retry, no 20-minute runs. With Testcontainers for the data layer and in-process hosts like WebApplicationFactory for the API, integration tests are fast enough to run on every commit.
- Bugs slip past unit tests because the SQL or mapping is wrong
- You mock the database in tests, then hit the real one in prod and find a NULL constraint you forgot
- CI runs against a shared dev DB and tests step on each other
- You're afraid to test βthe whole APIβ because the suite would take 30 minutes
Modern integration testing is a real Postgres in a container, started on demand, torn down per test class. Fast, isolated, and as close to production as you can get without deploying.
Testcontainers β Real Dependencies, Disposable
Testcontainers (Java, .NET, Node, Python, Go) starts containerized services for tests and tears them down at the end. You get a real Postgres, Redis, Kafka, etc., without managing infra.
// Node.js β Vitest + Testcontainers + Drizzle
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Client } from 'pg';
import { beforeAll, afterAll, beforeEach, expect, it } from 'vitest';
let container: StartedPostgreSqlContainer;
let db: ReturnType<typeof drizzle>;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:17-alpine').start();
const client = new Client({ connectionString: container.getConnectionUri() });
await client.connect();
db = drizzle(client);
await runMigrations(db); // your real migrations
}, 60_000);
afterAll(async () => { await container.stop(); });
beforeEach(async () => {
// Truncate test data between tests for isolation
await db.execute(sql`TRUNCATE TABLE orders, customers RESTART IDENTITY CASCADE`);
});
it('creates an order with the right total', async () => {
const customer = await createCustomer(db, { name: 'Ada' });
const order = await createOrder(db, customer.id, [{ sku: 'X', qty: 2, price: 5 }]);
expect(order.total).toBe(10);
const rows = await db.select().from(orders).where(eq(orders.id, order.id));
expect(rows[0].status).toBe('pending');
});WebApplicationFactory β ASP.NET Core In-Process
Microsoft.AspNetCore.Mvc.Testing spins up your real app in-process β middleware, DI, controllers β and gives you anHttpClient that bypasses TCP entirely. Combine with Testcontainers for the data layer and you have a full integration test that runs in milliseconds per request.
public class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _pg = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.Build();
public async Task InitializeAsync() => await _pg.StartAsync();
public new async Task DisposeAsync() => await _pg.DisposeAsync();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<AppDb>>();
services.AddDbContext<AppDb>(o => o.UseNpgsql(_pg.GetConnectionString()));
});
}
}
public class OrdersEndpointsTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public OrdersEndpointsTests(ApiFactory f) => _client = f.CreateClient();
[Fact]
public async Task POST_orders_returns_201_with_location()
{
var resp = await _client.PostAsJsonAsync("/orders",
new { customerId = Guid.NewGuid(), items = new[] { new { sku = "X", qty = 1 } } });
resp.StatusCode.Should().Be(HttpStatusCode.Created);
resp.Headers.Location.Should().NotBeNull();
}
}Node API Testing β supertest + your real app
import request from 'supertest';
import { buildApp } from '../src/app';
it('GET /health β 200', async () => {
const app = buildApp({ db }); // pass the Testcontainer-backed db
await request(app.callback()).get('/health').expect(200, { ok: true });
});Isolation Strategies for the Database
| Strategy | When | Trade-off |
|---|---|---|
| Transaction per test, rollback at end | Single-connection workloads | Can't test code that opens its own connection |
| Truncate / re-seed between tests | Default for most APIs | Slightly slower but always correct |
| One container per test class | Heavy migrations | Best parallelism |
| Per-test schema in shared container | Many small tests | Complex; rarely worth it |
Stubbing Outbound HTTP β WireMock and MSW
Your integration tests can hit a real DB but you still want deterministic responses from upstream HTTP services. WireMock(Java/.NET) and MSW (Node) record-and-replay or define stub responses you assert against.
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.post('https://payments.example/charge', () =>
HttpResponse.json({ id: 'ch_123', status: 'succeeded' })),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());What to Cover at the Integration Level
- HTTP boundary: routing, model binding, validation, status codes, problem-details on errors.
- Database: real SQL execution, ORM mappings, migrations, constraint violations.
- Authorization: per-endpoint policy with realistic JWTs (issue test tokens with a test-only signer).
- Serialization: dates, decimals, enums, nullable shapes β the things that bite in production but not unit tests.
- Transactions: rollback behavior on failures across multiple writes.
Speed Tips
- Reuse the container across the whole test session when possible (one Postgres, schema per test class).
- Run with
fsync = offin test mode β disk safety doesn't matter for ephemeral data. - Parallelize at the test class level, not per test (each parallel worker gets its own DB).
- Tag slow tests and run them only on PR / nightly, not every push.
Pitfalls
Shared mutable seed data
A global seed used by every test becomes a coupling nightmare β any change cascades. Each test creates the data it needs. Use factories (faker, AutoFixture, factory_bot) to keep this cheap.
Mocking the framework
If you're mocking HttpContextin a controller test, you've written a unit test of the framework, not an integration test of your code. Use WebApplicationFactory instead; the real pipeline catches bugs.
One enormous fixture per test class
Setup that does βeverything any test might needβ produces fast hashes but slow tests and unclear failures. Each test should arrange exactly what it asserts on, no more.

