Il y a un moment où les tests d’intégration deviennent un combat.
Sur ta machine, tout passe. Sur celle du collègue, la base de données n’est pas la même version. En CI, ça tombe parce qu’un service n’était pas prêt. Et quand tu pointes un environnement partagé “pour aller plus vite”, tu finis avec des tests qui se marchent dessus, des données fantômes, et sans parler de cette petite angoisse avant chaque merge.
Testcontainers règle ça avec une idée quelque peu naïve : au moment du test, tu démarres les vraies dépendances dans Docker, tu joues ton scénario, puis tu détruis tout. Pas d’environnement à maintenir, pas de “base de test” commune, pas de documentation qui vieillit. Juste le test qui embarque son décor. Testcontainers for .NET est construit pour ça et s’appuie sur un runtime compatible Docker, détecté automatiquement dans la plupart des setups.
La scène : ton test, ton API, ta base de données
On va partir sur un exemple volontairement petit : une Minimal API qui expose une todo-list, stockée dans PostgreSQL via EF Core. L’objectif n’est pas d’écrire une application de démo parfaite, mais de montrer le branchement de bout en bout.
Le scénario qu’on vise ressemble à ça :

Le test démarre un PostgreSQL jetable, boot l’API in-memory (comme dans les intégrations ASP.NET Core classiques), puis l’API utilise la chaîne de connexion fournie par le conteneur.
Installer uniquement ce qu’il faut
Côté dépendance, l’approche “module” est la plus confortable : tu ajoutes le module PostgreSQL, et tu as un builder prêt à l’emploi.
dotnet add package Testcontainers.PostgreSql
Tu peux aussi installer le package de base, utile si tu veux piloter un conteneur générique, mais pour Postgres le module te feras gagner du temps.
Le code de l’API
Le modèle et le DbContext
// TodoItem.cs
public sealed class TodoItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = "";
public bool Done { get; set; }
}
// AppDbContext.cs
using Microsoft.EntityFrameworkCore;
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<TodoItem> Todos => Set<TodoItem>();
}
La Minimal API
// Program.cs
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("db")));
var app = builder.Build();
app.MapPost("/todos", async (AppDbContext db, CreateTodoRequest req) =>
{
var item = new TodoItem { Title = req.Title };
db.Todos.Add(item);
await db.SaveChangesAsync();
return Results.Created($"/todos/{item.Id}", new TodoDto(item.Id, item.Title, item.Done));
});
app.MapGet("/todos/{id:guid}", async (AppDbContext db, Guid id) =>
{
var item = await db.Todos.FindAsync(id);
return item is null
? Results.NotFound()
: Results.Ok(new TodoDto(item.Id, item.Title, item.Done));
});
app.Run();
public partial class Program;
public sealed record CreateTodoRequest(string Title);
public sealed record TodoDto(Guid Id, string Title, bool Done);
En prod, tu auras ton appsettings.json et ta chaîne de connexion habituelle. En test, on va la remplacer au démarrage du host.
Démarrer PostgreSQL “dans” le test
La pièce maîtresse, c’est une factory de test qui sait faire deux choses : lancer le conteneur une fois, puis reconfigurer l’API pour pointer dessus.
// ApiFactory.cs
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Testcontainers.PostgreSql;
public sealed class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
.WithImage("postgres:17")
.WithDatabase("integration")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
public Task InitializeAsync() => _db.StartAsync();
public Task DisposeAsync() => _db.DisposeAsync().AsTask();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.RemoveAll(typeof(DbContextOptions<AppDbContext>));
services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(_db.GetConnectionString()));
using var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
});
}
}
Le conteneur est prêt à l’emploi via le builder Postgres, et la connexion s’obtient directement depuis l’instance. Les exemples officiels montrent bien ce démarrage via builder + StartAsync, et l’usage en tests avec un contexte xUnit pour gérer le cycle de vie.
Le test : un vrai appel HTTP, une vraie base de données
// TodoEndpointsTests.cs
using System.Net.Http.Json;
using Xunit;
public sealed class TodoEndpointsTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public TodoEndpointsTests(ApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task Post_then_get_returns_saved_item()
{
var create = await _client.PostAsJsonAsync("/todos", new { Title = "écrire des tests utiles" });
create.EnsureSuccessStatusCode();
var created = await create.Content.ReadFromJsonAsync<TodoDto>();
Assert.NotNull(created);
var get = await _client.GetAsync($"/todos/{created!.Id}");
get.EnsureSuccessStatusCode();
var fetched = await get.Content.ReadFromJsonAsync<TodoDto>();
Assert.NotNull(fetched);
Assert.Equal(created.Id, fetched!.Id);
Assert.Equal(created.Title, fetched.Title);
}
private sealed record TodoDto(Guid Id, string Title, bool Done);
}
À ce stade, tu as un test qui traverse vraiment la stack : sérialisation HTTP, pipeline ASP.NET, EF Core, driver Npgsql, PostgreSQL. Et pourtant, tu lances ça depuis ton IDE comme un test classique.
Pour visualiser complètement la séquence :

Ce que ça change en CI
En CI, la philosophie est la même : le test démarre ce dont il a besoin. La seule condition, c’est d’avoir un runtime Docker accessible sur l’agent, parce que Testcontainers s’appuie dessus.
Et ça, c’est exactement ce que tu veux : si Docker est dispo, le pipeline est autonome. Si Docker ne l’est pas, tu le sais immédiatement, au lieu de découvrir une base “en panne” à mi-chemin du run.
Et maintenant ?
Quand tu commences à empiler les scénarios, le sujet suivant arrive vite : garder l’isolation sans redémarrer la base à chaque test, et remettre l’état à zéro proprement. Testcontainers donne la fondation, et tu peux ensuite choisir ton style : un conteneur par classe de tests, un par collection, ou une base partagée avec reset entre chaque scénario.
Le point important, c’est la prise de contrôle du test : tu testes “comme en prod”, mais sans sacrifier l’expérience dev.
Télécharger le sample sur GitHub
https://github.com/xraboteu/tests-dintegration-en-.net-avec-testcontainers




