Skip to content
End To End Tester

Integration Testing

Testing your code against real dependencies — databases, HTTP clients, message brokers — with Testcontainers, and what belongs at this level rather than above or below it.

3 min read · updated 19 September 2026

An integration test runs your code against something real that you did not write: a database, an HTTP API, a broker, the filesystem. It exists because that seam is where your assumptions live, and assumptions are what fail.

A unit test proves your mapping code produces the right SQL string. An integration test proves the database accepts it, returns what you expect, and that the column is wide enough.

#What actually breaks at this level

The defects integration tests catch are boring, frequent and invisible from below:

  • A DECIMAL(10,2) column silently rounding a value the domain computed to four places.
  • A unique constraint your code does not expect, surfacing as a 500 under concurrency.
  • Timezone handling: a timestamp column that drops the offset, so everything is right locally and an hour out in production.
  • An ORM emitting one query per row — correct, and catastrophic at scale.
  • A JSON field that the API returns as a string in one version and a number in the next.
  • A transaction that does not roll back what you thought it did.

None of these can be caught by a test that replaced the dependency with a double, because the double was built from the same wrong assumption as the code.

#The modern shape: Testcontainers

The historical objection to integration tests was operational — you needed a shared test database, which meant coordination, drift, and tests that could not run in parallel. Testcontainers removed that. The dependency is started as a container by the test process, from the same image production runs, and thrown away afterwards.

java
// Java, JUnit 5. One Postgres for the class, ~1s to start, real behaviour.
@Testcontainers
class OrderRepositoryTest {

    @Container
    static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine");

    private OrderRepository repository;

    @BeforeEach
    void setUp() {
        var dataSource = dataSourceFor(POSTGRES);
        Flyway.configure().dataSource(dataSource).load().migrate();  // real migrations
        repository = new OrderRepository(dataSource);
    }

    @Test
    void rejects_a_second_order_with_the_same_reference() {
        repository.save(new Order("REF-1", 4_000));

        assertThatThrownBy(() -> repository.save(new Order("REF-1", 5_000)))
            .isInstanceOf(DuplicateReferenceException.class);   // not a raw SQLException
    }

    @Test
    void round_trips_money_without_losing_precision() {
        repository.save(new Order("REF-2", 1_999_99));

        assertThat(repository.find("REF-2").orElseThrow().amountCents()).isEqualTo(1_999_99);
    }
}

Two things in there matter more than the container. First, the real migrations run — so the test also proves the migration produces the schema the code expects, which is a surprisingly common source of production incidents. Second, the constraint violation is asserted as a domain exception, proving the repository translates infrastructure errors rather than leaking them.

csharp
// C#. Same idea, with a per-test transaction so tests do not see each other.
public class OrderRepositoryTests : IClassFixture<PostgresFixture>, IAsyncLifetime
{
    private readonly PostgresFixture _fixture;
    private NpgsqlTransaction _transaction = null!;

    public async Task InitializeAsync() =>
        _transaction = await _fixture.Connection.BeginTransactionAsync();

    // Rolled back, never committed: every test starts from the same state
    // and the suite is safe to run in parallel across connections.
    public async Task DisposeAsync() => await _transaction.RollbackAsync();
}

#Testing an HTTP dependency

The same principle applies to services you call. Either run a real instance (if it is yours and containerised) or put a programmable server in front — see integration testing with stubs and WireMock.

typescript
// TypeScript. A real HTTP server on a real port: the client's timeout,
// retry and JSON handling are all genuinely exercised.
import { createServer } from 'node:http';

it('retries once on 503 and succeeds', async () => {
  let calls = 0;
  const server = createServer((req, res) => {
    calls += 1;
    if (calls === 1) { res.writeHead(503).end(); return; }
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ rate: 0.79 }));
  }).listen(0);

  const port = (server.address() as { port: number }).port;
  const client = new RatesClient(`http://127.0.0.1:${port}`, { retries: 1 });

  await expect(client.gbpRate()).resolves.toBe(0.79);
  expect(calls).toBe(2);

  server.close();
});

Binding to port 0 gets an ephemeral port, which is what makes this safe to run in parallel. A hardcoded port is the classic reason an integration suite cannot be sharded.

#Keeping the suite honest

Same engine, same version as production. SQLite standing in for Postgres is a fake with a drift problem, and the drift is in exactly the areas — types, collation, isolation — where the bugs are.

Isolate with transactions or with data, not with ordering. Roll back after each test, or give each test its own tenant/prefix. Never rely on tests running in a particular order. See test data management.

One container per class, not per test. Container startup dominates the runtime; reusing it across a class takes a suite from four minutes to forty seconds.

Do not re-test business logic here. If the calculation has a unit test, the integration test should assert that the right value was persisted and read back, not walk through every discount rule again at 200ms a case.

#Where the boundary sits

Above this level is component testing — one whole deployable, through its real interface — and then end-to-end. Below it is the unit suite. The integration layer is the one that has become dramatically cheaper in the last decade, which is why the pyramid is drawn with a fatter middle now than it used to be.

Common questions

What is the difference between integration testing and end-to-end testing?
An integration test exercises one seam — your code against one real dependency — in a process you control. An end-to-end test exercises the whole deployed system through its real interface. The integration test knows exactly what it is testing; the end-to-end test only knows whether the journey worked.
Should integration tests use a real database?
Yes, and specifically the same engine and version as production. Substituting SQLite for Postgres makes the test fast and makes it lie — about types, collations, constraints, isolation levels and every function that is not standard SQL. Testcontainers has made the real thing cheap enough that the substitution is no longer worth its risk.
How many integration tests should I have?
One per behaviour of each seam, not one per method that crosses it. A repository with twelve methods might need six integration tests: the ones covering mapping, constraints, transactions, concurrency, and the two queries with interesting SQL.

Runnable samples for this page

last test results ↗
  • TypeScripttypescript/src/testing-levels/integration-testing

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?