Integration tests that lie
There's a kind of test suite that's green for years while the bugs it exists to catch sail through underneath it. It looks like this: the repository is mocked, the message broker is faked, the database is SQLite "because it's close enough", and the assertions all pass — because of course they do. A mocked test proves the interactions you scripted happened, that your mock behaves the way you configured it to. It doesn't prove your code survives contact with the engine you didn't run.
The lies accumulate quietly. SQLite happily accepts the string that SQL Server rejects outright. On a database at compatibility level 150 or later (150 on SQL Server 2019, 160 on SQL Server 2022), a real INSERT past a column's length throws error 2628, which opens with "string or binary data would be truncated" and then names the table, column, and value; older compatibility levels raise the shorter, table-agnostic version of that same message as error 8152 instead, unless trace flag 460 is on server-wide or for the session — it's a global-or-session switch, not a per-database one — which pulls anything under 150 into 2628 anyway. Either way that happens under ANSI_WARNINGS being ON, which is how a SqlClient session comes up in practice — Microsoft's own page only names ODBC, OLE DB, and JDBC as setting that automatically, so check @@OPTIONS yourself and see ((8 & @@OPTIONS) = 8 means ON) — and flipping that setting off, a habit some older or migrated code still carries, gets you SQLite's failure mode instead: silent truncation.
The in-memory fake has the same kind of gap: it preserves an ordering your real broker mainly depends on everything published on a single channel, one consumer, no message priorities, and requeued messages returned in the order they arrived — RabbitMQ's own preserving-order guidance has a couple more fine-print items past these, but break any of the four above and its docs describe exactly what happens: publish from more than one channel and the sequences interleave, priorities jump the line, a second consumer turns ordinary redelivery into visible reordering, and requeueing out of order shuffles the queue no matter how many consumers there are. The mock returns what your actual query would return — with its actual collation, its actual null handling — only if you happened to script it to. Test green, production red, and the difference is precisely the part you faked.
Run the real thing inside the test
Testcontainers starts actual infrastructure — real SQL Server, real RabbitMQ, real Redis — as throwaway Docker containers owned by the test run:
private readonly MsSqlContainer _sql =
new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04").Build();
public async Task InitializeAsync()
{
await _sql.StartAsync();
// connection string for THIS test run, nothing shared:
var connectionString = _sql.GetConnectionString();
}
(That constructor needs Testcontainers.MsSql 4.10.0 or later; earlier versions don't have it.)
With xUnit, that pairs with IAsyncLifetime: InitializeAsync runs before the test body, DisposeAsync after. Put it directly on the test class, as above, and xUnit repeats that dance for every [Fact] — it creates a fresh instance of the class per test, so the container comes up and goes down with each one. Share a single container across a whole class instead with IClassFixture<T>, or across several classes with ICollectionFixture<T>. Mind xUnit's own default while you do: one test collection per class, run in parallel — up to as many IClassFixture<T> containers at once as your machine has CPU threads, xUnit's own default cap, not one at a time. Ten classes on a ten-thread CI runner really can mean ten containers; on a 4-thread one it's four. Cap the parallelism explicitly or pool them into a shared ICollectionFixture<T> if that's not what you want. Either way: no shared "integration environment" that's broken every other Tuesday, no test data left behind by the previous run, no collisions between two developers running the suite at the same time.
What you get for the added startup time:
- Migrations tested against the real dialect. The script that works on SQLite and fails on SQL Server now fails in CI, where it's cheap.
- Queries with real semantics. Collation, isolation levels, constraint behavior — the exact things mocks abstract away are the exact things that page you at night.
- A broker that behaves like your broker. Payload round-trip, acknowledgment, redelivery — tested through RabbitMQ itself, not a list pretending to be one.
The honest costs
It needs a Docker-compatible runtime wherever the tests run. The major Linux CI runners ship Docker out of the box (GitHub Actions' ubuntu-latest, for instance) — but that's not universal: GitHub's hosted macOS runners don't ship Docker at all, and Windows runners' Docker story is weaker than Linux's. Even where Docker runs, the images are x86-64 only: Microsoft doesn't test or support them under emulation (Rosetta 2, Prism, QEMU), which rules out running them natively on an Apple Silicon Mac or an ARM CI runner. Budget at least 2GB of RAM free for the SQL Server container itself too, Microsoft's own stated minimum. The SQL Server image alone is upward of 500MB compressed, so the first pull on a clean machine or CI runner can cost minutes rather than seconds; once it's cached, a fresh container comes up in roughly 10-25 seconds depending on the machine, with the low end more typical. Keep unit tests as the fast inner loop, share a container across a class with a fixture instead of restarting one per test, and let the container-backed suite be the layer that runs on every PR rather than every keystroke. (Microsoft's own integration-tests guidance still calls the SQLite provider "the recommended choice for in-memory testing" — fine advice next to EF Core's fully-fake in-memory database, a different job than standing in for the engine your code actually ships against.) And a genuine engine won't fix bad tests — it fixes the gap between what your tests exercise and what production runs.