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.

πŸ€” Sound familiar?
  • 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

StrategyWhenTrade-off
Transaction per test, rollback at endSingle-connection workloadsCan't test code that opens its own connection
Truncate / re-seed between testsDefault for most APIsSlightly slower but always correct
One container per test classHeavy migrationsBest parallelism
Per-test schema in shared containerMany small testsComplex; 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 = off in 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.