A mocked database will happily let you violate a unique constraint it never actually enforces. That's not a passing test — it's a test lying to you about what your real database would have done.
Lesson 310 closed with a deliberate warning: mock what you don't own, but don't mock away the database you actually depend on. And lesson 309's example leaned on EF Core's in-memory provider as a quick stand-in for a real database — useful for a first pass, but flagged even then as not behaving identically to a real relational engine in every respect. Both of those loose threads point at the same underlying problem: your database is not just another dependency you can wave away with a fake, because its real, specific behavior — constraint enforcement, transaction semantics, exact SQL translation — is often precisely what you need a test to verify.
But the obvious alternative — pointing tests at a real, shared database — comes with its own well-earned reputation for misery: flaky failures caused by other tests' leftover data, slow contention when several developers or CI jobs hit it at once, and state that never resets cleanly between runs. Testcontainers exists to give you the real behavior without the shared-database pain.
In this lesson, you'll learn exactly what problem Testcontainers solves, how it manages real, disposable Docker containers scoped to a single test run, how to wire one into an xUnit integration test, and precisely when to reach for it instead of the mocking you learned in lesson 310.
Testcontainers is a library that starts a real, genuine Docker container — a real Postgres, a real SQL Server, a real Redis — programmatically, from your test code, right before your tests need it, and tears it down automatically when they're done. Not a simulation of a database. An actual one, running in an actual container, disposable and scoped to exactly one test run.
Testcontainers is an open-source library, with an official .NET client, that talks directly to the Docker Engine API to pull an image, start a container, wait until it's genuinely ready to accept connections, expose its mapped port back to your test process, and — critically — stop and remove it afterward. It is real infrastructure, managed programmatically, for the lifetime of a test — not a mock, not an in-memory approximation, and not a shared, long-lived resource anyone has to remember to clean up by hand.
Testcontainers refuses the trade-off entirely: it gives you a genuinely real database engine — the same one production actually uses — while keeping it completely disposable and private to a single test run, exactly like the fresh, uniquely-named database from lesson 309, except now backed by real Postgres or SQL Server rather than an approximation. Containers (lesson 298) already made "the same real runtime, wrapped tightly enough to start and stop on demand" cheap and fast — Testcontainers simply points that exact capability at your test suite.
| Approach | Real DB behavior? | Isolated per run? | Speed |
|---|---|---|---|
| Mocked / in-memory provider (310, 309) | No | Yes | Fastest |
| Shared dev/test database | Yes | No — the core problem | Slow under contention |
| Testcontainers (this lesson) | Yes | Yes | Slower than in-memory, but genuinely isolated and correct |
Nothing here makes mocking obsolete — a pure-mock unit test is still the right default for the exhaustive edge-case coverage lesson 308 taught, precisely because of its speed. Testcontainers earns its (real, non-zero) cost specifically for the integration tests (309) where actual database behavior is the whole point of the test.
The .NET Testcontainers client provides a fluent builder for common images, and xUnit's IAsyncLifetime interface gives you exactly the async start/stop hooks a container needs:
public class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("ordersdb")
.WithUsername("test")
.WithPassword("test")
.Build();
public string ConnectionString => _container.GetConnectionString();
// Runs once before any test in this fixture uses it
public Task InitializeAsync() => _container.StartAsync();
// Runs once after all tests using this fixture have finished
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}StartAsync() pulls the postgres:16-alpine image if it isn't already cached locally, starts a real Postgres container, waits until it's genuinely accepting connections, and maps its port to a free port on the host. GetConnectionString() then returns a real, working connection string pointing at that container — no manual port bookkeeping required.
public class OrderRepositoryTests : IClassFixture<PostgresFixture>
{
private readonly PostgresFixture _fixture;
public OrderRepositoryTests(PostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task AddAsync_DuplicateOrderNumber_ThrowsDueToRealUniqueConstraint()
{
// Arrange — a real DbContext, pointed at the real container
var options = new DbContextOptionsBuilder<OrdersDbContext>()
.UseNpgsql(_fixture.ConnectionString)
.Options;
await using var db = new OrdersDbContext(options);
await db.Database.MigrateAsync();
var repo = new OrderRepository(db);
await repo.AddAsync(new Order { OrderNumber = "ORD-1001" });
// Act & Assert — a REAL unique constraint violation,
// exactly what production Postgres would throw. An in-memory
// provider would silently let this succeed.
await Assert.ThrowsAsync<DbUpdateException>(
() => repo.AddAsync(new Order { OrderNumber = "ORD-1001" }));
}
}Meaning: This test proves something the in-memory provider from lesson 309 structurally cannot: that a real unique constraint, defined in a real migration, actually gets enforced by a real database engine. That's precisely the kind of confidence mocking or an in-memory fake can't provide — it isn't testing your logic here at all, it's testing your database's own real behavior, exactly as production will experience it.
WebApplicationFactory (309)The real payoff comes from combining this lesson with lesson 309's WebApplicationFactory: a full HTTP-level integration test, running against your real ASP.NET Core pipeline, backed by a real, disposable Postgres — no shared database, no in-memory approximation, anywhere in the chain.
public class OrdersApiIntegrationTests
: IClassFixture<WebApplicationFactory<Program>>, IClassFixture<PostgresFixture>
{
private readonly HttpClient _client;
public OrdersApiIntegrationTests(
WebApplicationFactory<Program> factory, PostgresFixture postgres)
{
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<OrdersDbContext>>();
services.AddDbContext<OrdersDbContext>(options =>
options.UseNpgsql(postgres.ConnectionString));
});
}).CreateClient();
}
[Fact]
public async Task PostOrder_DuplicateOrderNumber_Returns409Conflict()
{
await _client.PostAsJsonAsync("/api/orders", new { OrderNumber = "ORD-2001" });
var response = await _client.PostAsJsonAsync("/api/orders", new { OrderNumber = "ORD-2001" });
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
}Every layer here is now real: real HTTP pipeline (309), real database constraint enforcement (this lesson), real end-to-end behavior — and the container's isolation means this test class can run safely alongside every other, with no shared state to collide over.
A cardboard backdrop painted to look like a real room is fast and cheap to set up, but if an actor actually leans on a "wall," it'll fall right through — it never behaved like the real thing to begin with. That's mocking the database entirely. A shared, permanent set that every production in the building uses is real, but the crew is constantly fighting over who left a prop where, and last week's rehearsal debris keeps turning up in this week's scene. That's a shared test database. Testcontainers is a real, fully-built set, rented and delivered fresh for exactly this one show, and struck (torn down) the moment the show wraps — genuinely real, and genuinely nobody else's problem to clean up.
docker CLI itself uses — to pull the requested image if it isn't cached, and create a new container from it. Exactly the containerization mechanics lesson 298 covered, now driven programmatically from test code.StartAsync() once the database is genuinely ready, avoiding a race where your first test query hits a database that hasn't finished booting.GetConnectionString() reflects the real, actual port chosen.This is worth stating precisely because it's easy to lump together with lesson 310's material. Moq generates a fake, in-memory stand-in for an interface. Testcontainers starts a genuine, unmodified Postgres (or SQL Server, or Redis) binary, running for real, inside a real container. The only thing "fake" about it is its lifetime — it's real infrastructure, deliberately made disposable.
The in-memory provider is genuinely useful for a quick first pass, as lesson 309 showed — but it doesn't translate LINQ to real SQL, doesn't enforce real constraints, and doesn't reproduce real concurrency or transaction behavior. Anywhere those specifics matter to what you're actually verifying, it's testing something meaningfully different from your real database, not a faster version of the same thing.
Creating and starting a fresh container inside every [Fact]'s own setup — container startup, even for a lightweight image, is real overhead measured in seconds, and paying that cost hundreds of times makes a suite unbearably slow.
Share one container per test class (via IClassFixture, as shown above) or even per full test run, and isolate individual tests by resetting or scoping data inside that one shared container instead — exactly the trade-off lesson 309's isolation-strategy table already laid out.
Testcontainers-backed tests pass locally and mysteriously fail (or hang) in CI, because the CI runner has no Docker daemon reachable, or lacks permission to talk to the Docker socket.
Confirm the CI environment genuinely provides Docker access before relying on Testcontainers there — most modern CI providers support this, but it's a real environment requirement, not something to assume by default.
Reaching for a full Postgres container to unit-test a piece of business logic that has nothing to do with database behavior — paying real startup cost for zero additional confidence.
Keep lesson 308's fast unit tests and lesson 310's mocks as the default for logic and external services you don't own; reserve Testcontainers specifically for verifying real behavior against the database you actually own.
IAsyncLifetime (or a class fixture), share one container per test class rather than per test, and combine it with WebApplicationFactory (309) for full, real, end-to-end integration coverage.You've learned why neither mocking the database away nor sharing one is good enough, and how Testcontainers gives you real, disposable infrastructure instead. Let's confirm it clicked.
1. What does Testcontainers actually manage when a test starts a Postgres container through it?
Correct: B
Why B is correct: Testcontainers is explicitly not a mock — it talks to the real Docker Engine API to run a genuine, unmodified Postgres container, real behavior included, and tears it down afterward.
Why A is incorrect: This describes mocking (lesson 310), which is a fundamentally different tool solving a different problem than Testcontainers.
Why C is incorrect: Testcontainers manages a local (or CI-local) disposable container, not a shared, persistent cloud resource — sharing across runs is exactly the shared-database problem it avoids.
Why D is incorrect: There's no fake SQL parser involved — the actual Postgres engine runs, unmodified, inside the container.
Reinforcement: "Real, but disposable" is the whole value proposition — get this distinction from mocking right.
2. A team's tests use EF Core's in-memory provider and consistently pass, but a duplicate-order bug — caused by a missing unique constraint — reaches production anyway. What does this lesson say is the most likely explanation?
Correct: B
Why B is correct: This is exactly the "false confidence" problem the lesson opens with — the in-memory provider doesn't reproduce real constraint enforcement, so a passing test tells you nothing reliable about what a real database would actually do.
Why A is incorrect: This is expected, documented behavior of the in-memory provider, not a defect — it was never designed to fully replicate a real engine's constraint behavior.
Why C is incorrect: The issue isn't unit testing in general — it's specifically that this particular test double doesn't reproduce real database semantics; a Testcontainers-backed test would have caught it.
Why D is incorrect: A shared database would introduce a different set of problems (flakiness, contention) without necessarily being any better positioned to catch this specific constraint issue during CI — Testcontainers is the fix this lesson recommends, not a shared database.
Reinforcement: A test double that doesn't reproduce real constraint enforcement can pass while production would fail — exactly the gap Testcontainers closes.
3. A developer starts a fresh Testcontainers-managed Postgres container inside the setup of every single [Fact] method in a large test class, and the suite becomes painfully slow. What does this lesson recommend instead?
Correct: B
Why B is correct: This is exactly Mistake 1 from the lesson — container startup is real, non-trivial overhead; sharing one container per class (or run) while isolating individual tests' data is the standard fix.
Why A is incorrect: This reintroduces exactly the flaky, shared-state problem Testcontainers was adopted to eliminate in the first place.
Why C is incorrect: The image is typically cached locally after the first pull anyway — the real overhead this mistake describes is repeated container startup itself, not image pulling.
Why D is incorrect: Testcontainers works fine with many tests sharing one container via IClassFixture — that's precisely the recommended pattern.
Reinforcement: Share the container; isolate the data — not the other way around.
4. Following this lesson's rule of thumb, which dependency should be mocked (310), and which should use Testcontainers (this lesson)?
Correct: B
Why B is correct: This is the exact rule of thumb the lesson states: mock what you don't own (an external service), and reach for Testcontainers for infrastructure you do own and where real behavior genuinely matters — most commonly your own database.
Why A is incorrect: This reverses the guidance — you don't control a third-party gateway's containerization, and mocking your own database defeats the purpose of verifying its real behavior.
Why C is incorrect: The two tools are complementary, each solving a different part of the same test suite — this lesson never suggests mocking should be abandoned.
Why D is incorrect: Testcontainers has real startup cost that mocking doesn't — mocking stays the right, fast default for dependencies that don't need real infrastructure behavior verified.
Reinforcement: Mock what you don't own; Testcontainer what you own and need to trust for real.
You now know how to get real, trustworthy database behavior into your integration tests without the pain of a shared database. Next: putting the whole HTTP contract of an API under test, end to end.
dotnetmadeeasy.com — Learn C# and .NET, the right way.