NashTech Blog

Building API Integration Tests in .NET #3: Testcontainers on the Fidelity Ladder

Table of Contents

Building API Integration Tests in .NET #3: Testcontainers on the Fidelity Ladder


1. Introduction

The mock and InMemory article left us on the second rung of the fidelity ladder: production repository code against in-process substitutes — an in-memory fake or EF Core InMemory. That catches handler and LINQ bugs, but not how your app behaves against real external infrastructure.

The third rung swaps those substitutes for throwaway Docker containers started from test code: databases, message brokers, caches, emulators — anything your system depends on that can run as an image.

RungWhat you gainWhat you still defer
TestcontainersReal behavior of containerized dependencies you wire inCoordinated multi-service graphs, live .NET sibling APIs
Mock and InMemoryFast feedback without DockerAnything that only exists in a real broker, DB, or cache process

This article uses PostgreSQL as the worked example because eShop Catalog needs it — including pgvector for semantic search. The same fixture pattern applies if your next dependency is RabbitMQ, Redis, or Azurite; Section 4 maps common options.

Prerequisites: Docker Desktop (or a Docker daemon) on the machine that runs the tests.


2. A quick review — Mock and InMemory

ConceptSummary
Test fidelity ladderSame API tests, increasing infrastructure realism
WebApplicationFactoryHosts the real API in-process; configuration and services are swapped per mode
Repository Mock / EF InMemorySubstitutes that stay inside the test process — no Docker
Why should we climb furtherSubstitutes cannot reproduce provider-specific protocols, extensions, or wire formats

The pattern in this post is unchanged: one CatalogApiTests class, one mode attribute, one fixture switch — only the infrastructure behind the host changes.


3. What is Testcontainers?

Testcontainers is a library that starts throwaway Docker containers from your test process. You choose an image (or build a custom one), configure ports and credentials in code, and the library pulls the image if needed, waits until the dependency is ready, and exposes an address your app can use — a connection string, a URL, or a mapped host port.

The lifecycle is always the same:

  1. Build a container descriptor in the test fixture (PostgreSqlBuilder, RabbitMqBuilder, RedisBuilder, …).
  2. StartAsync() — container runs for the life of the fixture.
  3. Inject the address into the app under test (usually ConfigureHostConfiguration or ConfigureTestServices).
  4. DisposeAsync() — container is removed when tests finish.

No manual docker run. No checked-in docker-compose.yml dedicated to tests. No shared “team Postgres” on localhost that every developer must install and keep in sync.

Testcontainers is not a test framework. It does not replace xUnit or WebApplicationFactory. It is infrastructure glue: real dependencies on demand, wired into the integration test host from the series introduction and mock/InMemory article.


4. What can you containerize?

Testcontainers for .NET ships modules for many common dependencies. The eShop demo uses only PostgreSQL today; the mechanics are identical for other types.

DependencyTestcontainers module (examples)What API tests often validate
PostgreSQL / SQL Server / MySQLPostgreSqlBuilder, MsSqlBuilderEF migrations, SQL dialect, constraints, indexes
RabbitMQ / KafkaRabbitMqBuilder, KafkaBuilderPublish/subscribe, consumer handlers, retry behavior
RedisRedisBuilderCache semantics, TTL, distributed locks
AzuriteAzuriteBuilderBlob/queue/table storage without Azure
Elasticsearch / OpenSearchCommunity or generic modulesIndex mappings, search queries
Custom serviceContainerBuilder with your DockerfileAny image you can build

You are not limited to one container per fixture. A test host can start Postgres and RabbitMQ and Redis in InitializeAsync, inject three connection strings, and wait for each to become healthy — you write that orchestration (or wrap it in a shared library). The messaging article shows messaging with Aspire; the same RabbitMQ image could be started with Testcontainers instead.

eShop in this post: Catalog needs PostgreSQL with pgvector — a Postgres extension for vector similarity search. That is why the case study below picks ankane/pgvector, not because Testcontainers is database-only.


5. When to climb to this rung

When to use it

  • A substitute from the InMemory article cannot model the protocol or provider you need — InMemory EF does not speak PostgreSQL; a no-op IEventBus does not exercise AMQP.
  • Provider-specific behaviour matters: SQL extensions, broker acknowledgements, cache eviction, storage API quirks.
  • CI should hit the real infrastructure without adopting Aspire in the test project.
  • You want isolation — each run gets fresh containers instead of shared dev services.

Blind spots

  • Cold start — first run pulls images; expect roughly 10–30 seconds or could be longer (due to the number of image, image size) before tests execute (warm runs are faster).
  • You own orchestration — startup order, health waits, and wiring multiple containers are your code. The Aspire article introduces Aspire when standardizing that graph is worth the extra packages.
  • Not everything is container-shaped — some teams depend on managed cloud APIs (real AWS S3, Azure OpenAI) where emulators or contract tests are a better fit than Testcontainers alone.

What changes from the InMemory rung (Catalog example)

PieceEF InMemory modeTestcontainers mode
PersistenceIn-process EF providerPostgreSQL in Docker
Other deps (messaging, AI)Test doublesStill test doubles in eShop’s current mode — containers added when you choose
InitializationEnsureCreated on InMemoryMigrateAsync + seed on real Postgres
DockerNot requiredRequired

6. Testcontainers vs Aspire in tests

Both rungs use Docker. The difference is who owns the orchestration layer (see  the series introduction, Section 6).

Testcontainers starts each dependency imperatively and injects addresses into the configuration. In the demo, the container setup is wrapped in a reusable helper — eShop’s CatalogTestcontainersHost for one Postgres instance. You can scale to many container types as you want.

Aspire in tests provides a declarative resource graph, built-in health waits, and AddProject for live .NET sibling services. Worth it when your app already uses Aspire in development and tests need several wired dependencies with less custom glue.

Neither replaces WebApplicationFactory for the API under test. Both replace what sits behind it.


7. The integration pattern (any container)

Regardless of image, Testcontainers follow the same four steps:

  1. Host class — thin wrapper (CatalogTestcontainersHost) that builds the container, calls StartAsync(), returns the address.
  2. Fixture InitializeAsync — start container before WebApplicationFactory boots so configuration is ready.
  3. Configuration injection — overwrite ConnectionStrings:*, EventBus URL, or other keys the production app already reads.
  4. Same tests — HTTP client and assertions unchanged; only the mode attribute or runsettings file switches.

In the demo, Ordering’s OrderingTestcontainersHost repeats the pattern with a standard Postgres image — same steps, different database name. A RabbitMQ variant would return a broker URI and set ConnectionStrings:EventBus instead of catalogdb.


8. Case study: Catalog.API with PostgreSQL

This section is one application of the pattern above — chosen because Catalog persists data in PostgreSQL. Semantic search with pgvector is covered in optional reading at the end of this section; skip it if vector search is not relevant to your app.

Start the database container

internal sealed class CatalogTestcontainersHost : IAsyncDisposable
{
    private PostgreSqlContainer? _postgresContainer;

    public async Task<string> StartAsync()
    {
        _postgresContainer = new PostgreSqlBuilder("ankane/pgvector:latest")
            .WithDatabase("CatalogDB")
            .WithUsername("postgres")
            .WithPassword("postgres")
            .WithCleanUp(true)
            .Build();

        await _postgresContainer.StartAsync();
        return _postgresContainer.GetConnectionString();
    }

    public async ValueTask DisposeAsync()
    {
        if (_postgresContainer is not null)
            await _postgresContainer.DisposeAsync();
    }
}

Why WithCleanUp(true) matters on CI agents

Each test run that starts a container creates a real Docker process on the host that executes the job — your laptop, a GitHub Actions ubuntu-latest VM, or a self-hosted build agent. When the fixture disposes, something must stop and remove that container. If it does not, you get orphans containers (and often anonymous volumes) left behind after the test process exits.

That is painful in CI for several reasons:

ProblemWhat happens
Shared agentsSelf-hosted runners reuse the same machine across PRs. Orphans stack up until someone runs docker ps and wonders why forty Postgres containers are idle.
Resource exhaustionEach container holds memory, disk for writable layers, and sometimes named/anonymous volumes. Enough orphans and the next job fails with no space left on device or Out Of Memory errors — flaky failures unrelated to your code.
Port and name collisionsStale containers can bind ports or retain labels that confuse the next run, especially if a previous job crashed before DisposeAsync.
Slower, noisier pipelinesAgents that never reclaim Docker state need manual cleanup scripts or periodic VM resets — operational toil that shows up as “CI was fine yesterday.”

WithCleanUp(true) tells Testcontainers to remove the container when the fixture is disposed — the normal path when CatalogApiTestSession tears down at the end of the test assembly, or when a single fixture’s DisposeAsync runs.

You still want defensive habits:

  • Always dispose the fixture — implement IAsyncLifetime / IAsyncDisposable on the fixture and let the session own one instance per mode (as eShop does). A test run that is killed mid-flight (dotnet test cancelled, agent timeout) may skip dispose; Testcontainers also runs a resource reaper (Ryuk) on many setups to garbage-collect labeled containers, but do not treat that as a substitute for fixture disposal in your own code.
  • Use WithCleanUp(true) on every builder in the fixture — Postgres, RabbitMQ, Redis — so each dependency you add follows the same rule.
  • On self-hosted CI, schedule an occasional docker container prune / docker system prune job as a safety net, not the primary strategy.

On ephemeral cloud runners (GitHub-hosted ubuntu-latest), the entire VM is discarded after the job, so orphans are less likely to hurt the next PR — but they can still break later steps in the same job (integration tests followed by Docker-based packaging, scan steps, or a second test project on the same agent). Explicit cleanup keeps each job self-contained.

Wire the fixture

case CatalogFunctionalTestMode.Testcontainers:
    _postgresConnectionString = await _testcontainersHost!.StartAsync();
    _ = Services;
    await CatalogDatabaseHelper.EnsurePostgresSeededAsync(Services);
    break;

Inject catalogdb

Production Catalog registers EF against the catalogdb connection name with vector support. Test mode overwrites that key:

case CatalogFunctionalTestMode.Testcontainers:
    settings["ConnectionStrings:catalogdb"] = options.PostgresConnectionString;
    break;

Keep non-DB deps as doubles (for now)

Catalog Testcontainers mode still uses FakeCatalogAI and no-op event services — only persistence is containerized in this mode. Adding a RabbitMqBuilder alongside Postgres would follow the same inject-and-configure steps; Post 5 covers messaging in depth via Aspire.

Migrate, seed, and run the same tests

await context.Database.MigrateAsync();
await seeder.SeedAsync(context);

Annotate the test class (or override via runsettings) and run the same CatalogApiTests as in mock and InMemory modes — pagination, create, update, delete. Persistence helpers read through LoadItemFromPostgresAsync when verifying writes. Testcontainers and Aspire share that path; only the connection string source differs.

9. Case study: Catalog.API with PostgreSQL and pgvector

Optional reading — pgvector and semantic search
Skip this section if you do not use vector search. It explains why eShop picks the ankane/pgvector image and how to test embedding ranking once the basics above are clear.

What the repository executes on PostgreSQL

When semantic search runs with AI enabled, CatalogRepository orders by cosine distance — SQL that only translates correctly against pgvector:

public Task<List<CatalogItem>> GetItemsBySemanticRelevanceAsync(
    Vector vector, int pageIndex, int pageSize, CancellationToken cancellationToken = default) =>
    context.CatalogItems
        .Where(c => c.Embedding != null)
        .OrderBy(c => c.Embedding!.CosineDistance(vector))
        .Skip(pageSize * pageIndex)
        .Take(pageSize)
        .ToListAsync(cancellationToken);

Under Testcontainers, that CosineDistance call becomes provider SQL. Under EF InMemory, the property is ignored entirely (InMemory article).

Existing test: semantic search endpoint (name fallback today)

eShop’s GetCatalogItemWithsemanticrelevance runs in Testcontainers mode with the same HTTP test as every other mode:

[Theory]
[InlineData(2.0)]
public async Task GetCatalogItemWithsemanticrelevance(double version)
{
    var host = await CreateHostAsync(new ApiVersion(version));

    var response = await host.Client.GetAsync(
        "api/catalog/items/withsemanticrelevance?text=Wanderer&PageSize=5&PageIndex=0");

    response.EnsureSuccessStatusCode();
    var result = JsonSerializer.Deserialize<PaginatedItems<CatalogItem>>(body, options);

    Assert.Equal(1, result.Count);
    Assert.Single(result.Data);
}

With the default FakeCatalogAI (IsEnabled => false), the endpoint falls back to name-prefix search — it finds “Wanderer Black Hiking Boots” without touching pgvector. That still validates the HTTP contract on a real Postgres database, but it does not exercise embeddings yet.

Example: asserting pgvector ranking end-to-end

To test the embedding path, enable AI in the test host and seed vectors into Postgres before calling the same endpoint. A minimal stub avoids Ollama while forcing the semantic branch:

// Test-only double — register in ConfigureTestServices for this test class
internal sealed class StubCatalogAI : ICatalogAI
{
    public bool IsEnabled => true;

    public ValueTask<Vector?> GetEmbeddingAsync(string text) =>
        ValueTask.FromResult<Vector?>(new Vector(new float[] { 1f, 0f, 0f }));
    // ... other ICatalogAI members return null or empty as needed
}

In the test, assign embeddings to two catalog rows (same dimension count for every vector). Item A is aligned with the search vector; item B is not:

[Fact]
[CatalogFunctionalTestMode(CatalogFunctionalTestMode.Testcontainers)]
public async Task SemanticSearch_ReturnsClosestEmbeddingFirst()
{
    var host = await CreateHostAsync(new ApiVersion(2.0));

    // Arrange — write embeddings into real pgvector columns
    await using (var scope = host.Fixture.Services.CreateAsyncScope())
    {
        var context = scope.ServiceProvider.GetRequiredService<CatalogContext>();

        var wanderer = await context.CatalogItems.SingleAsync(i => i.Id == 1);
        var other = await context.CatalogItems.SingleAsync(i => i.Id == 2);

        wanderer.Embedding = new Vector(new float[] { 1f, 0f, 0f }); // closest to search vector
        other.Embedding = new Vector(new float[] { 0f, 1f, 0f });   // farther away

        await context.SaveChangesAsync();
    }

    // Act — StubCatalogAI returns [1,0,0] for the search text
    var response = await host.Client.GetAsync(
        "api/catalog/items/withsemanticrelevance?text=hiking&PageSize=1&PageIndex=0");
    response.EnsureSuccessStatusCode();

    var page = JsonSerializer.Deserialize<PaginatedItems<CatalogItem>>(body, options);

    // Assert — pgvector cosine ranking surfaced through HTTP
    Assert.NotNull(page.Data);
    Assert.Equal("Wanderer Black Hiking Boots", page.Data[0].Name);
}

Why this test belongs on the Testcontainers rung:

StepRequires real Postgres + pgvector?
Persist Embedding as vectorYes — column type and extension
OrderBy(Embedding.CosineDistance(...))Yes — provider-specific SQL
HTTP through WebApplicationFactoryYes — full integration path

On EF InMemory, the test never gets past model creation for Embedding. On Repository Mock, InMemoryCatalogRepository stubs semantic search without distance math. Only here does the same assertion prove that ranking logic survives from LINQ through PostgreSQL and back to the API response.


10. Shared fixture vs per-test isolation

eShop reuses one container set per test mode per assembly run via CatalogApiTestSession:

private readonly ConcurrentDictionary<CatalogFunctionalTestMode, Lazy<Task<CatalogApiFixture>>> _fixtures = new();

All tests in that mode share the same Postgres instance. Cold-start cost is paid once per run.

Trade-off: mutable tests can affect later tests. We can mitigate this issue with deterministic IDs in read-heavy scenarios.

ApproachWhen to use
Shared fixture (default)Many tests, mostly reads — amortize container startup
RespawnWrite-heavy suite — reset DB between tests without restarting containers
Container per test class / testStrong isolation — reserve for expensive or order-sensitive cases

Respawn truncates tables or restores a baseline quickly — works for any relational container, not only Postgres.


11. Composing multiple containers

Testcontainers shines when you need more than one dependency without Aspire:

eShop needContainer optionInjected as
Catalog / Ordering dataPostgreSqlBuilderConnectionStrings:catalogdb / ordering equivalent
Transactional messagingRabbitMqBuilderConnectionStrings:EventBus
Distributed cacheRedisBuilderConnectionStrings:Redis or options type
Identity data (without live API)Second PostgreSqlBuilderSeparate connection string

In the eShop demo, OrderingTestcontainersHost today starts one standard Postgres database — same imperative pattern, no pgvector. Identity is still mocked; while the Aspire mode adds a live Identity.API project alongside databases.

To add RabbitMQ with Testcontainers in your own fork:

  1. Start RabbitMqBuilder in InitializeAsync after Postgres is healthy.
  2. Inject the broker URI into ConnectionStrings:EventBus.
  3. Remove no-op IEventBus registrations in ConfigureTestServices.
  4. Run the same HTTP tests — now exercising real publish/consume paths.

The maintenance cost grows with each container you wire. Aspire trades that for a declarative graph when the topology is stable and already modeled in AppHost.


12. Running locally and in CI

Runsettings

<ESHOP_CATALOG_FUNCTIONAL_TEST_MODE>Testcontainers</ESHOP_CATALOG_FUNCTIONAL_TEST_MODE>
<ESHOP_ORDERING_FUNCTIONAL_TEST_MODE>Testcontainers</ESHOP_ORDERING_FUNCTIONAL_TEST_MODE>

CLI

dotnet test tests/Catalog.FunctionalTests --settings eShop.FunctionalTests.Testcontainers.runsettings
dotnet test tests/Catalog.FunctionalTests --filter-trait FunctionalTestMode=testcontainers

Docker must be running locally. Visual Studio: select eShop.FunctionalTests.Testcontainers.runsettings under Configure Run Settings.

GitHub Actions sketch

jobs:
  integration-testcontainers:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'
      - name: Run Testcontainers tests
        run: >
          dotnet test tests/Catalog.FunctionalTests
          --settings eShop.FunctionalTests.Testcontainers.runsettings
          --filter-trait FunctionalTestMode=testcontainers

ubuntu-latest provides Docker; Testcontainers manages images inside the job — no separate services: Postgres block required.

Pros and cons (Testcontainers rung)

ProsCons
Real behavior for any containerized dependencyRequires Docker on dev and CI
Same HTTP tests — mode switch onlyCold start slower than Mock/InMemory
Imperative control — one container or manyMulti-container wiring is manually configured vs auto-configured by Aspire
Fewer packages than Aspire hosting in testsYou maintain orchestration helpers

13. Conclusion

Testcontainers is the first ladder rung that runs real infrastructure in Docker — not only databases. The integration pattern is stable: start containers in the fixture, inject addresses into configurations, keep WebApplicationFactory and HTTP tests unchanged.

eShop demonstrates that pattern with PostgreSQL and pgvector because Catalog semantic search demands it. Your application might start with Redis, RabbitMQ, or SQL Server instead; the fixture shape stays the same.

Stay on mock and InMemory for fast local loops. Climb here when substitutes stop answering your question. Climb to Aspire when orchestrating many dependencies — including live .NET services — should not be hand-written glue.


14. References

This series

Demo repo

Testcontainers & testing

Related NashTech posts

Picture of Minh Kha Giai

Minh Kha Giai

I'm a Senior Automation Test Engineer with more than 7 years in software testing and development. I have experience in building various Automation Frameworks, creating pipelines for continuous integration, providing test approach and applying many testing types. My current research interests include Test Architecture, Mobile Automation, Payment and Financial Services, AI Tests, GIS and Image Processing

Leave a Comment

Suggested Article

Scroll to Top