Skip to content

// Testing · 06

Integration Tests with Real Dependencies

Test runleads tosame as prodReal databaseleads toReal behaviour

Run the code against a real database, queue and web host.

How it fits together

Integration Tests with Real Dependencies structure«client»Test hoststarts the container onceresets tables per testOrders API (in-process)same middlewaresame migrationsDatabase containerthrowaway, per runreal constraints1 POST /orders2 INSERT order3 unique clash4 409 Conflict
uses / callsfails / error path· numbered steps replay in order
  • Test host uses / calls Orders API (in-process) (1 POST /orders)
  • Orders API (in-process) uses / calls Database container (2 INSERT order)
  • Database container fails / error path Orders API (in-process) (3 unique clash)
  • Orders API (in-process) fails / error path Test host (4 409 Conflict)

Spot it

  • Tests pass but a production query fails
  • A mocked repository hides a SQL error
  • Behaviour differs between test and prod databases

It costs

Slower to start and needs Docker, but it catches what doubles hide.

In code

public sealed class PostgresFixture : IAsyncLifetime{    private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()        .WithImage("postgres:16")        .Build();​    public string ConnectionString => _container.GetConnectionString();​    public async Task InitializeAsync()    {        await _container.StartAsync();        await Migrations.ApplyAsync(ConnectionString);    }​    public Task ResetAsync() => Migrations.TruncateAsync(ConnectionString, "orders");​    public Task DisposeAsync() => _container.DisposeAsync().AsTask();}
Go deeperThe full explanation and what to ask for · 1 min readExpand

A double of the database proves that your code calls the double correctly, not that the query works. Real behaviour lives in what doubles remove: SQL dialect, constraints, transaction isolation, generated keys and timeouts. Run the real engine in a disposable container built from the production migrations, and host the web app in-process so requests pass through routing, serialisation and middleware.

Start the container once per test collection, not per test, and reset only the rows each test needs. Tests must not depend on order or leave data behind. Seed only what a test reads, so a failure points at one fixture.

Do not stand in an in-memory provider for a relational database. Many such providers ignore constraints, transactions and query translation that production enforces, so the test passes where production fails. When NOT to: pure logic with no I/O belongs in unit tests; integration tests are slower and should cover the boundaries, not every branch.

Ask for it

  • throwaway container
  • test fixture
  • in-process test host
  • reset between tests
  • seed data
  • provider parity

Add an integration test project that starts a database container once per run, applies the production migrations, and hosts the Orders API in-process. Truncate the orders table before each test. Add a test that a duplicate order number returns 409 Conflict, proving the real unique index is enforced.