For years the standard advice for testing data access in .NET was the EF Core in-memory provider: no setup, no Docker, tests that run in milliseconds. The catch is in its name. It isn’t a database; it’s a list of objects with a LINQ front, and it doesn’t enforce most of what your real database does. The tests pass, and the bugs ship.
Testcontainers removes the reason to compromise: it starts a real database in Docker from inside your test run and throws it away at the end. This article puts both approaches through the same four tests and measures what the real thing costs. Everything below ran on .NET 10 with Testcontainers 4.16, EF Core 10, Npgsql and PostgreSQL 17, on a 4-core machine.
Four rules the model declares
A small shop: customers with orders. The model declares four rules any real schema would have:
protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<Customer>(c =>
{
c.HasIndex(x => x.Email).IsUnique();
c.Property(x => x.Name).HasMaxLength(20);
c.HasMany(x => x.Orders).WithOne().HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
});
model.Entity<Order>().Property(o => o.Total).HasPrecision(10, 2);
}
And four tests, written once in an abstract base class, so each provider runs exactly the same code:
[Fact]
public async Task Two_customers_cannot_share_an_email()
{
await using var db = CreateDb();
db.Customers.Add(new Customer { Email = "ana@example.com", Name = "Ana" });
db.Customers.Add(new Customer { Email = "ana@example.com", Name = "Ana Maria" });
await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
}
The others check that a 30-character name is rejected, that a customer with orders can’t be deleted, and that a total of 10.005 is stored as 10.01.
The result
Failed InMemoryCustomerTests.A_name_longer_than_20_characters_is_rejected
Failed InMemoryCustomerTests.A_customer_with_orders_cannot_be_deleted
Failed InMemoryCustomerTests.Totals_are_stored_with_two_decimal_places
Failed InMemoryCustomerTests.Two_customers_cannot_share_an_email
Passed PostgresCustomerTests.Two_customers_cannot_share_an_email [106 ms]
Passed PostgresCustomerTests.A_name_longer_than_20_characters_is_rejected [56 ms]
Passed PostgresCustomerTests.A_customer_with_orders_cannot_be_deleted [125 ms]
Passed PostgresCustomerTests.Totals_are_stored_with_two_decimal_places [13 ms]
The in-memory provider failed all four. It saved the duplicate email, the long name and the deleted customer without a word, and handed back 10.005 as if precision didn’t exist. PostgreSQL rejected each one with the error your production code would actually see:
23505: duplicate key value violates unique constraint "IX_Customers_Email"
22001: value too long for type character varying(20)
23503: update or delete on table "Customers" violates foreign key constraint "FK_Orders_Customers_CustomerId" on table "Orders"
This is the real point of integration tests. A test against the in-memory provider would never have caught that two sign-ups racing with the same email both succeed, or that a long name from a form blows up in production. And it can’t test the code that handles those errors (turning a 23505 into “this email is already registered”), because the error never happens. Microsoft’s own documentation now recommends against using the in-memory provider to test applications for this reason.
Advertisement
The setup
A fixture starts one container for the whole test collection, creates the schema, and prepares Respawn to empty the tables between tests:
public sealed class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.Build();
private Respawner _respawner = null!;
public async Task InitializeAsync()
{
await _container.StartAsync();
await using (var db = CreateDb()) await db.Database.EnsureCreatedAsync();
await using var conn = new NpgsqlConnection(_container.GetConnectionString());
await conn.OpenAsync();
_respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
SchemasToInclude = ["public"],
});
}
public ShopDb CreateDb() => new(new DbContextOptionsBuilder<ShopDb>()
.UseNpgsql(_container.GetConnectionString()).Options);
public async Task ResetAsync()
{
await using var conn = new NpgsqlConnection(_container.GetConnectionString());
await conn.OpenAsync();
await _respawner.ResetAsync(conn);
}
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}
[CollectionDefinition("postgres")]
public class PostgresCollection : ICollectionFixture<PostgresFixture>;
The test class joins the collection and resets the data before each test:
[Collection("postgres")]
public class PostgresCustomerTests(PostgresFixture postgres) : CustomerTests, IAsyncLifetime
{
protected override ShopDb CreateDb() => postgres.CreateDb();
public Task InitializeAsync() => postgres.ResetAsync();
public Task DisposeAsync() => Task.CompletedTask;
}
The container gets a random free port, a generated password and a fresh database; nothing on the machine needs configuring beyond Docker. A small companion container, Ryuk, removes everything when the test process ends, even if it crashes, so failed runs don’t leave databases behind.
What it costs
The numbers from the runs:
container start: 2,862 – 2,908 ms (image already pulled)
schema: 452 – 754 ms
reset per test: median 3.3 ms, max 14 ms (104 resets)
100 tests: 757 ms in total, 7.6 ms each, insert + query + reset
whole run: 8 tests, both providers, 5.4 s wall clock
The container costs about three seconds, once per test run, not per test. After that, each test pays a few milliseconds of reset and the real cost of its own queries. A hundred database tests took under a second on top of the start-up. That’s slower than the in-memory provider, but not in any way you’d notice in a pull request’s CI run, and the tests now mean something.
The first run on a machine also pulls the image (PostgreSQL’s Alpine image is a download of a little over 100 MB); CI runners can cache it.
Choices that keep it fast
- One container per collection, not per test. Starting a container per test class would multiply those three seconds by every class.
ICollectionFixtureshares one; tests in a collection run one after another, so they never see each other’s data. - Reset by deleting, not by recreating. Respawn reads the foreign keys once and deletes in the right order, in a few milliseconds. Recreating the schema took 450–750 ms each time.
- Same schema as production. Here
EnsureCreatedbuilds it from the model; in a real project, apply your migrations instead, so the migrations get tested too. - Pin the image version.
postgres:17-alpine, notlatest, so a new release doesn’t change your test results overnight. Match production’s major version. - Several collections for parallelism. xUnit runs different collections in parallel; give large suites a few collections, each with its own container, when the run gets long.
Beyond databases
The same pattern covers most of what an application talks to. Testcontainers has modules for SQL Server, Redis, RabbitMQ, Kafka, Azure’s emulators and more, and a generic builder for any image. Message handling, caching and retries can be tested against the real thing, with the same fixture shape.
When the in-memory provider is still fine
For logic that only needs some objects to query (a service that filters and maps), a fake repository or the in-memory provider is quick and good enough. The line is simple: if the test is about what the database does (constraints, transactions, SQL translation, concurrency), the test needs the database. With Testcontainers that costs three seconds per run, and it removes a whole class of “works on my machine” bugs.


