Tests d’intégration en .NET avec Testcontainers

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éesOn 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 fautCô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.PostgreSqlTu 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 ; db.Todos.Add(item); await db.SaveChangesAsync(); return Results.Created($"/todos/", new TodoDto(item.Id, item.Title, item.Done)); });
app.MapGet("/todos/", 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 testLa 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)
[Fact] public async Task Post_then_get_returns_saved_item() { var create = await _client.PostAsJsonAsync("/todos", new ); 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); }
[https://github.com/xraboteu/tests-dintegration-en-.net-avec-testcontainers](https://github.com/xraboteu/tests-dintegration-en-.net-avec-testcontainers)