Back to blog
Backend Systemsintermediate

Testcontainers in .NET: Real Integration Tests with Real Databases

Master Testcontainers in .NET — spin up real SQL Server, Redis, and RabbitMQ in Docker for integration tests. Covers WebApplicationFactory, shared containers, EF Core migrations, test isolation, and CI/CD setup.

LearnixoApril 14, 202614 min read
.NETC#TestcontainersIntegration TestingDockerEF CorexUnitTesting
Share:š•

The Problem with Mocked Tests

Unit tests with mocked databases are fast, but they lie.

C#
// āŒ This test passes — but is it testing anything real?
var mockRepo = new Mock<IOrderRepository>();
mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(new Order { Id = 1 });

var service = new OrderService(mockRepo.Object);
var result  = await service.GetOrderAsync(1);

result.Should().NotBeNull();   // āœ… passes — but you never touched SQL Server

This test passes even if:

  • Your EF Core query has a typo that would fail against a real database
  • Your migration has a broken column name
  • Your index is missing and the query times out in production
  • Your GROUP BY logic is wrong

Mocks test your mock, not your code.

Integration tests against a real database catch the bugs that matter. The problem was always: "a real database is slow to set up and tears down messily." Testcontainers solves this.


What Is Testcontainers?

Testcontainers is a library that spins up real Docker containers — SQL Server, PostgreSQL, Redis, RabbitMQ, anything — programmatically from your test code. Each test run gets a fresh, isolated container. No shared state, no leftover data, no "works on my machine" problems.

Test starts
    ↓
Testcontainers pulls the Docker image (cached after first run)
    ↓
Container starts (SQL Server / Redis / etc.)
    ↓
Your test runs against the real service
    ↓
Container is destroyed after the test

The containers are ephemeral — they exist only for the duration of the test. You get the fidelity of a real database with the isolation of a mock.


Setup

Bash
# Install Testcontainers
dotnet add package Testcontainers.MsSql       # SQL Server
dotnet add package Testcontainers.PostgreSql  # PostgreSQL
dotnet add package Testcontainers.Redis       # Redis
dotnet add package Testcontainers.RabbitMq    # RabbitMQ
dotnet add package Testcontainers             # base package (others depend on it)

# Test framework
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Microsoft.NET.Test.Sdk
dotnet add package FluentAssertions
dotnet add package Microsoft.AspNetCore.Mvc.Testing  # for API integration tests

Requirements:

  • Docker Desktop (Windows/Mac) or Docker Engine (Linux) must be running
  • Works on CI — GitHub Actions, Azure DevOps, and most CI runners have Docker available

Your First Container Test

C#
// The simplest possible Testcontainers test — SQL Server
public class SimpleContainerTest : IAsyncLifetime
{
    private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder()
        .WithPassword("Strong_Passw0rd!")
        .Build();

    // IAsyncLifetime — start container before tests run
    public Task InitializeAsync() => _sqlContainer.StartAsync();

    // Stop and remove container after tests finish
    public Task DisposeAsync() => _sqlContainer.DisposeAsync().AsTask();

    [Fact]
    public async Task Can_Connect_And_Query()
    {
        // Get the connection string — port is dynamically assigned
        var connectionString = _sqlContainer.GetConnectionString();

        await using var connection = new SqlConnection(connectionString);
        await connection.OpenAsync();

        var result = await connection.QueryFirstAsync<int>("SELECT 1");
        result.Should().Be(1);
    }
}

The container starts on a random available port — no port conflicts between parallel test runs or CI agents.


Integration Testing with EF Core

This is the real power — test your actual EF Core queries, migrations, and domain logic against SQL Server:

C#
// SqlServerTestFixture.cs — shared setup for all EF Core tests
public class SqlServerTestFixture : IAsyncLifetime
{
    private readonly MsSqlContainer _container = new MsSqlBuilder()
        .WithPassword("Strong_Passw0rd!")
        .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
        .Build();

    public AppDbContext CreateDbContext()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(_container.GetConnectionString())
            .Options;

        return new AppDbContext(options);
    }

    public async Task InitializeAsync()
    {
        await _container.StartAsync();

        // Run EF Core migrations — tests run against your actual schema
        await using var db = CreateDbContext();
        await db.Database.MigrateAsync();
    }

    public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}

// Test class — receives the fixture via xUnit's IClassFixture
public class OrderRepositoryTests : IClassFixture<SqlServerTestFixture>
{
    private readonly SqlServerTestFixture _fixture;

    public OrderRepositoryTests(SqlServerTestFixture fixture)
        => _fixture = fixture;

    [Fact]
    public async Task GetByIdAsync_ReturnsOrder_WhenExists()
    {
        await using var db = _fixture.CreateDbContext();
        var repo = new OrderRepository(db);

        // Arrange — insert real data
        var order = Order.Create(CustomerId.New());
        order.AddItem(ProductId.New(), 2, new Money(25m, "GBP"));
        await db.Orders.AddAsync(order);
        await db.SaveChangesAsync();

        // Act — real SQL query against real SQL Server
        var result = await repo.GetByIdAsync(order.Id);

        // Assert
        result.Should().NotBeNull();
        result!.Id.Should().Be(order.Id);
        result.Items.Should().HaveCount(1);
        result.Items[0].Quantity.Should().Be(2);
    }

    [Fact]
    public async Task GetByCustomerAsync_ReturnsOnlyCustomersOrders()
    {
        await using var db = _fixture.CreateDbContext();
        var repo = new OrderRepository(db);

        var customerId1 = CustomerId.New();
        var customerId2 = CustomerId.New();

        var order1 = Order.Create(customerId1);
        var order2 = Order.Create(customerId1);
        var order3 = Order.Create(customerId2);   // different customer

        db.Orders.AddRange(order1, order2, order3);
        await db.SaveChangesAsync();

        var results = await repo.GetByCustomerAsync(customerId1);

        results.Should().HaveCount(2);
        results.Should().AllSatisfy(o => o.CustomerId.Should().Be(customerId1));
    }
}

Test Isolation: Clean Database Between Tests

If tests share a container, leftover data causes flaky tests. Three strategies:

Strategy 1: Respawn (Recommended)

Respawn resets the database to its initial state between tests — far faster than dropping and recreating the database:

Bash
dotnet add package Respawn
C#
public class SqlServerTestFixture : IAsyncLifetime
{
    private readonly MsSqlContainer _container = new MsSqlBuilder()
        .WithPassword("Strong_Passw0rd!")
        .Build();

    private Respawner _respawner = default!;
    private string    _connectionString = string.Empty;

    public string ConnectionString => _connectionString;

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        _connectionString = _container.GetConnectionString();

        // Run migrations once
        await using var db = CreateDbContext();
        await db.Database.MigrateAsync();

        // Initialise Respawn — it inspects the schema and generates DELETE statements
        await using var conn = new SqlConnection(_connectionString);
        await conn.OpenAsync();
        _respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
        {
            DbAdapter      = DbAdapter.SqlServer,
            TablesToIgnore = ["__EFMigrationsHistory"],   // keep migrations table
        });
    }

    // Call this between tests to wipe all data
    public async Task ResetDatabaseAsync()
    {
        await using var conn = new SqlConnection(_connectionString);
        await conn.OpenAsync();
        await _respawner.ResetAsync(conn);
    }

    public AppDbContext CreateDbContext()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(_connectionString)
            .Options;
        return new AppDbContext(options);
    }

    public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}
C#
// Each test resets the database before running
public class OrderRepositoryTests : IClassFixture<SqlServerTestFixture>, IAsyncLifetime
{
    private readonly SqlServerTestFixture _fixture;

    public OrderRepositoryTests(SqlServerTestFixture fixture) => _fixture = fixture;

    // Reset before each test — clean slate guaranteed
    public Task InitializeAsync() => _fixture.ResetDatabaseAsync();
    public Task DisposeAsync()    => Task.CompletedTask;

    [Fact]
    public async Task CreateOrder_PersistsCorrectly()
    {
        // Arrange
        await using var db = _fixture.CreateDbContext();

        var order = Order.Create(CustomerId.New());
        order.AddItem(ProductId.New(), 3, new Money(10m, "GBP"));

        // Act
        db.Orders.Add(order);
        await db.SaveChangesAsync();

        // Assert — query in a fresh DbContext (no caching)
        await using var db2 = _fixture.CreateDbContext();
        var saved = await db2.Orders.Include(o => o.Items).FirstAsync(o => o.Id == order.Id);

        saved.Items.Should().HaveCount(1);
        saved.Total.Amount.Should().Be(30m);
    }
}

Strategy 2: Transaction Rollback

Wrap each test in a transaction that rolls back:

C#
public class TransactionalTestBase : IAsyncLifetime
{
    private readonly SqlServerTestFixture _fixture;
    private IDbContextTransaction _transaction = null!;

    protected AppDbContext Db { get; private set; } = null!;

    protected TransactionalTestBase(SqlServerTestFixture fixture)
        => _fixture = fixture;

    public async Task InitializeAsync()
    {
        Db           = _fixture.CreateDbContext();
        _transaction = await Db.Database.BeginTransactionAsync();
    }

    public async Task DisposeAsync()
    {
        // Always rolls back — data never committed
        await _transaction.RollbackAsync();
        await Db.DisposeAsync();
    }
}

public class OrderTests : TransactionalTestBase, IClassFixture<SqlServerTestFixture>
{
    public OrderTests(SqlServerTestFixture f) : base(f) {}

    [Fact]
    public async Task Test_WithAutoRollback()
    {
        Db.Orders.Add(Order.Create(CustomerId.New()));
        await Db.SaveChangesAsync();
        // rolled back automatically in DisposeAsync — no cleanup needed
    }
}

Strategy 3: One Container Per Test Class

Each test class gets its own fresh container. Isolated but slower (more Docker startup time):

C#
// Each test class creates and destroys its own container
public class OrderTests : IAsyncLifetime
{
    private readonly MsSqlContainer _container = new MsSqlBuilder().Build();
    private AppDbContext _db = null!;

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        _db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(_container.GetConnectionString()).Options);
        await _db.Database.MigrateAsync();
    }

    public async Task DisposeAsync()
    {
        await _db.DisposeAsync();
        await _container.DisposeAsync();
    }
}

Full API Integration Tests with WebApplicationFactory

Test your entire HTTP pipeline — middleware, auth, controllers, EF Core — against a real database:

C#
// CustomWebApplicationFactory.cs
public class CustomWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder()
        .WithPassword("Strong_Passw0rd!")
        .Build();

    private readonly RedisContainer _redisContainer = new RedisBuilder()
        .Build();

    public async Task InitializeAsync()
    {
        // Start all containers in parallel
        await Task.WhenAll(
            _sqlContainer.StartAsync(),
            _redisContainer.StartAsync());
    }

    // Override the app's configuration with container connection strings
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Replace SQL Server connection string
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(options =>
                options.UseSqlServer(_sqlContainer.GetConnectionString()));

            // Replace Redis connection string
            services.RemoveAll<IConnectionMultiplexer>();
            services.AddSingleton<IConnectionMultiplexer>(
                ConnectionMultiplexer.Connect(_redisContainer.GetConnectionString()));
        });

        builder.UseEnvironment("Testing");
    }

    // Run migrations after containers start but before tests run
    public new async Task InitializeAsync()
    {
        await base.InitializeAsync();   // starts containers

        await using var scope = Services.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.MigrateAsync();
    }

    public new async Task DisposeAsync()
    {
        await base.DisposeAsync();
        await Task.WhenAll(
            _sqlContainer.DisposeAsync().AsTask(),
            _redisContainer.DisposeAsync().AsTask());
    }
}
C#
// OrdersApiTests.cs
public class OrdersApiTests : IClassFixture<CustomWebApplicationFactory>, IAsyncLifetime
{
    private readonly CustomWebApplicationFactory _factory;
    private readonly HttpClient _client;

    public OrdersApiTests(CustomWebApplicationFactory factory)
    {
        _factory = factory;
        _client  = factory.CreateClient();
    }

    public Task InitializeAsync() => _factory.ResetDatabaseAsync();
    public Task DisposeAsync()    => Task.CompletedTask;

    [Fact]
    public async Task POST_CreateOrder_Returns201_WithLocation()
    {
        // Arrange — seed a customer
        await using var scope = _factory.Services.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        var customer = new Customer { Name = "Alice", Email = "alice@example.com" };
        db.Customers.Add(customer);
        await db.SaveChangesAsync();

        var request = new CreateOrderRequest(
            CustomerId: customer.Id,
            Items:      [new(ProductId: 1, Quantity: 2, UnitPrice: 25m)],
            Address:    "123 Main St");

        // Act — real HTTP call through the full pipeline
        var response = await _client.PostAsJsonAsync("/api/orders", request);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);
        response.Headers.Location.Should().NotBeNull();

        var body = await response.Content.ReadFromJsonAsync<CreateOrderResponse>();
        body!.OrderId.Should().BeGreaterThan(0);

        // Verify it's actually in the database
        var order = await db.Orders.FindAsync(body.OrderId);
        order.Should().NotBeNull();
        order!.CustomerId.Should().Be(customer.Id);
    }

    [Fact]
    public async Task POST_CreateOrder_Returns400_WhenItemsEmpty()
    {
        var request = new CreateOrderRequest(
            CustomerId: 1,
            Items:      [],   // empty — should fail validation
            Address:    "123 Main St");

        var response = await _client.PostAsJsonAsync("/api/orders", request);

        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);

        var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
        problem!.Errors.Should().ContainKey("Items");
    }

    [Fact]
    public async Task GET_Order_Returns401_WhenNotAuthenticated()
    {
        var response = await _client.GetAsync("/api/orders/1");
        response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
    }
}

Testing with Redis

C#
public class CacheServiceTests : IAsyncLifetime
{
    private readonly RedisContainer _container = new RedisBuilder().Build();
    private IConnectionMultiplexer _redis = null!;

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        _redis = await ConnectionMultiplexer.ConnectAsync(_container.GetConnectionString());
    }

    public async Task DisposeAsync()
    {
        await _redis.DisposeAsync();
        await _container.DisposeAsync();
    }

    [Fact]
    public async Task SetAsync_ThenGetAsync_ReturnsValue()
    {
        var cache   = new RedisCacheService(_redis);
        var key     = "order:123";
        var order   = new OrderDto(123, "ORD-001", 99.99m);

        await cache.SetAsync(key, order, TimeSpan.FromMinutes(5));
        var cached = await cache.GetAsync<OrderDto>(key);

        cached.Should().NotBeNull();
        cached!.Id.Should().Be(123);
        cached.Reference.Should().Be("ORD-001");
    }

    [Fact]
    public async Task GetAsync_ReturnsNull_AfterExpiry()
    {
        var cache = new RedisCacheService(_redis);
        await cache.SetAsync("short-lived", "value", TimeSpan.FromMilliseconds(100));

        await Task.Delay(200);   // wait for expiry

        var result = await cache.GetAsync<string>("short-lived");
        result.Should().BeNull();
    }
}

Testing Message Bus with RabbitMQ

C#
public class OrderEventTests : IAsyncLifetime
{
    private readonly RabbitMqContainer _container = new RabbitMqBuilder()
        .WithUsername("guest")
        .WithPassword("guest")
        .Build();

    public Task InitializeAsync() => _container.StartAsync();
    public Task DisposeAsync()    => _container.DisposeAsync().AsTask();

    [Fact]
    public async Task PublishOrderConfirmed_ConsumerReceivesEvent()
    {
        var connectionString = _container.GetConnectionString();
        var received         = new TaskCompletionSource<OrderConfirmedEvent>();

        // Set up consumer first
        var factory    = new ConnectionFactory { Uri = new Uri(connectionString) };
        var connection = await factory.CreateConnectionAsync();
        var channel    = await connection.CreateChannelAsync();

        await channel.QueueDeclareAsync("order-confirmed", durable: false, exclusive: false);

        var consumer = new AsyncEventingBasicConsumer(channel);
        consumer.ReceivedAsync += async (_, args) =>
        {
            var body  = Encoding.UTF8.GetString(args.Body.ToArray());
            var event = JsonSerializer.Deserialize<OrderConfirmedEvent>(body)!;
            received.SetResult(event);
            await channel.BasicAckAsync(args.DeliveryTag, multiple: false);
        };

        await channel.BasicConsumeAsync("order-confirmed", autoAck: false, consumer: consumer);

        // Publish
        var publisher = new OrderEventPublisher(connectionString);
        await publisher.PublishOrderConfirmedAsync(new OrderConfirmedEvent(
            OrderId:    Guid.NewGuid(),
            CustomerId: Guid.NewGuid(),
            Total:      99.99m));

        // Wait for the consumer to receive it (timeout after 5 seconds)
        var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));

        result.Total.Should().Be(99.99m);
    }
}

Shared Container Across All Test Classes (Collection Fixture)

Starting a fresh container for every test class is slow. Use xUnit's Collection Fixture to share one container across multiple test classes:

C#
// SharedContainerFixture.cs — one container for the entire test suite
public class SharedContainerFixture : IAsyncLifetime
{
    public MsSqlContainer SqlContainer { get; } = new MsSqlBuilder()
        .WithPassword("Strong_Passw0rd!")
        .Build();

    public string ConnectionString => SqlContainer.GetConnectionString();
    private Respawner _respawner = null!;

    public async Task InitializeAsync()
    {
        await SqlContainer.StartAsync();

        // Apply migrations once
        using var db = CreateDbContext();
        await db.Database.MigrateAsync();

        // Initialise Respawner
        await using var conn = new SqlConnection(ConnectionString);
        await conn.OpenAsync();
        _respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
        {
            DbAdapter      = DbAdapter.SqlServer,
            TablesToIgnore = ["__EFMigrationsHistory"],
        });
    }

    public async Task ResetAsync()
    {
        await using var conn = new SqlConnection(ConnectionString);
        await conn.OpenAsync();
        await _respawner.ResetAsync(conn);
    }

    public AppDbContext CreateDbContext()
        => new(new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(ConnectionString).Options);

    public Task DisposeAsync() => SqlContainer.DisposeAsync().AsTask();
}

// Register the collection
[CollectionDefinition("SharedDb")]
public class SharedDbCollection : ICollectionFixture<SharedContainerFixture> { }

// All test classes in the [Collection("SharedDb")] share the same container
[Collection("SharedDb")]
public class OrderRepositoryTests : IAsyncLifetime
{
    private readonly SharedContainerFixture _db;
    public OrderRepositoryTests(SharedContainerFixture db) => _db = db;

    public Task InitializeAsync() => _db.ResetAsync();   // wipe data, keep schema
    public Task DisposeAsync()    => Task.CompletedTask;

    [Fact]
    public async Task GetById_ReturnsOrder() { /* ... */ }
}

[Collection("SharedDb")]
public class CustomerRepositoryTests : IAsyncLifetime
{
    private readonly SharedContainerFixture _db;
    public CustomerRepositoryTests(SharedContainerFixture db) => _db = db;

    public Task InitializeAsync() => _db.ResetAsync();
    public Task DisposeAsync()    => Task.CompletedTask;

    [Fact]
    public async Task GetAll_ReturnsAllCustomers() { /* ... */ }
}

One container starts, both test classes use it, Respawn wipes data between each test. Fast and isolated.


Running in CI (GitHub Actions)

YAML
# .github/workflows/tests.yml
name: Integration Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest   # Docker is available by default

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "9.x"

      - name: Run integration tests
        run: dotnet test --configuration Release --logger "console;verbosity=normal"
        env:
          # Testcontainers auto-detects Docker — no extra config needed on GitHub Actions
          TESTCONTAINERS_RYUK_DISABLED: "false"

Testcontainers auto-detects the Docker socket. On GitHub Actions (Ubuntu runner), Docker is available with no additional setup. For Azure DevOps, use a ubuntu-latest agent — it also has Docker pre-installed.


Performance Tips

1. Share containers across test classes (Collection Fixture)
   → One SQL Server container for all 50 tests: 5s startup vs 250s

2. Use Respawn instead of dropping/recreating the database
   → Respawn DELETE: ~50ms vs database drop+migrate: ~3s

3. Run tests in parallel with isolated containers
   → xUnit parallelises test classes by default
   → Each Collection Fixture = one container — safe

4. Layer your tests: unit → integration → E2E
   → Unit tests: ~500ms total (no containers)
   → Integration tests: ~30s total (containers start once)
   → Don't run Testcontainers tests on every file save — only on PR/push

5. Cache Docker images in CI
   - GitHub Actions caches Docker layers between runs
   - SQL Server 2022 image is ~1.5GB — cached after first pull

Common Mistakes

C#
// āŒ Starting a new container per test method — catastrophically slow
[Fact]
public async Task Test1()
{
    await using var container = new MsSqlBuilder().Build();
    await container.StartAsync();   // 5s startup Ɨ 100 tests = 500s
    // ...
}

// āœ… Share via IClassFixture or CollectionFixture — start once, share
public class Tests : IClassFixture<SqlServerTestFixture> { ... }

// āŒ Not cleaning up between tests — data bleeds between tests
[Fact]
public async Task Test_A_InsertsSomeData() { /* inserts 5 rows */ }

[Fact]
public async Task Test_B_CountsRows()      { /* expects 0 rows — gets 5 */ }

// āœ… Reset with Respawn in InitializeAsync of each test
public Task InitializeAsync() => _fixture.ResetAsync();

// āŒ Using .Result or .GetAwaiter().GetResult() in async test setup
public void Initialize() { _container.StartAsync().Result; }   // can deadlock

// āœ… Use IAsyncLifetime for async setup/teardown
public Task InitializeAsync() => _container.StartAsync();

When to Use Testcontainers vs What

āœ… Testcontainers: integration tests for repositories, services with real DB
āœ… Testcontainers: full API tests via WebApplicationFactory
āœ… Testcontainers: testing cache behaviour (Redis), messaging (RabbitMQ/ServiceBus emulator)
āœ… Unit tests (no containers): pure domain logic, value objects, business rules
āœ… In-memory EF Core: only for tests that don't touch SQL-specific features
āŒ Testcontainers: not for unit tests — too slow, not the right tool
āŒ Testcontainers: not for tests that only test pure C# logic

Key Takeaways

  • Mocked databases lie — they pass when your real query would fail; Testcontainers tests what actually runs in production
  • One container per collection, not per test — share via ICollectionFixture, reset data with Respawn
  • Respawn is the right tool for database cleanup — it's 50Ɨ faster than drop/migrate
  • IAsyncLifetime is the xUnit hook for async setup and teardown — always use it, never .Result
  • WebApplicationFactory + container = a full E2E test of your entire HTTP pipeline against real infrastructure
  • CI is trivial — GitHub Actions and Azure DevOps both have Docker; Testcontainers just works
  • WithImage("...") lets you pin the exact Docker image version — same image in dev and CI, no surprises
  • Testcontainers doesn't replace unit tests — it complements them. Unit test the logic, integration test the persistence

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.