Tests & TDD

Tests d'intégration

WebApplicationFactory, Testcontainers, base en mémoire.

Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 16 min

Un test d'intégration fait tourner ensemble des pièces que les tests unitaires vérifient chacune de leur côté. Pour une API ASP.NET Core, cela veut dire l'application entière — son pipeline, la liaison des paramètres, son conteneur de services, EF Core et une base — appelée par HTTP, sans réseau. Il sert là où les défauts naissent entre les pièces : une requête LINQ que la base ne sait pas traduire, un service mal remplacé, un paramètre mal lié. Les cours xUnit, Doublures de test et TDD portent sur le test unitaire et ses outils ; celui-ci s'appuie sur eux. Tous les exemples forment un seul projet : une petite API de factures en .NET 10, avec EF Core 10.0.12 et PostgreSQL en production par Npgsql 10.0.3, et son projet de test xUnit 2.9.3.

Ce qu'un test d'intégration prouve

Un test unitaire prouve une règle, dépendances remplacées par des doublures. Il ne peut pas prouver que ces doublures se comportent comme ce qu'elles remplacent, ni que l'application est assemblée comme on le croit. Un test d'intégration prouve ce que Program.cs configure : les routes et la liaison des paramètres, l'ordre des middlewares, les enregistrements du conteneur, la sérialisation JSON, et surtout ce que la base fait des requêtes — leur traduction en SQL et les contraintes du schéma. Il coûte plus cher : un démarrage d'application, une base à préparer, et un échec qui désigne la cause moins précisément. On en écrit donc moins, sur les chemins où les pièces se rencontrent.

Voici l'API. Son modèle tient en un contexte EF Core, avec un index unique sur le numéro :

using Microsoft.EntityFrameworkCore;

namespace Facturation.Api;

public sealed class Facture
{
    public int Id { get; set; }
    public required string Numero { get; set; }
    public required string Client { get; set; }
    public decimal Montant { get; set; }
    public DateOnly Echeance { get; set; }
    public bool Payee { get; set; }
}

public sealed record NouvelleFacture(string Numero, string Client, decimal Montant, DateOnly Echeance);

public sealed class FacturationDb(DbContextOptions<FacturationDb> options) : DbContext(options)
{
    public DbSet<Facture> Factures => Set<Facture>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var facture = modelBuilder.Entity<Facture>();
        facture.HasIndex(f => f.Numero).IsUnique();
        facture.Property(f => f.Numero).HasMaxLength(20);
    }
}

Son Program.cs contient un défaut qu'aucun test unitaire ne verra, et qu'un test d'intégration mal outillé ne verra pas non plus ; la quatrième section le trouve.

using Facturation.Api;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// En production, PostgreSQL, par le fournisseur Npgsql.
builder.Services.AddDbContext<FacturationDb>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Facturation")));
builder.Services.AddSingleton(TimeProvider.System);

var app = builder.Build();

app.MapPost("/factures", async (NouvelleFacture saisie, FacturationDb db) =>
{
    if (await db.Factures.AnyAsync(f => f.Numero == saisie.Numero))
    {
        return Results.Conflict();
    }

    var facture = new Facture
    {
        Numero = saisie.Numero,
        Client = saisie.Client,
        Montant = saisie.Montant,
        Echeance = saisie.Echeance,
    };
    db.Factures.Add(facture);
    await db.SaveChangesAsync();
    return Results.Created($"/factures/{facture.Numero}", facture);
});

app.MapGet("/factures/{numero}", async (string numero, FacturationDb db) =>
    await db.Factures.SingleOrDefaultAsync(f => f.Numero == numero) is { } facture
        ? Results.Ok(facture)
        : Results.NotFound());

// Recherche par client, sans tenir compte de la casse.
app.MapGet("/factures", async (string client, FacturationDb db) =>
    await db.Factures
        .Where(f => string.Equals(f.Client, client, StringComparison.OrdinalIgnoreCase))
        .ToListAsync());

// Factures non payees dont l'echeance est passee, selon l'horloge injectee.
app.MapGet("/factures/echues", async (FacturationDb db, TimeProvider horloge) =>
{
    var aujourdhui = DateOnly.FromDateTime(horloge.GetLocalNow().DateTime);
    return await db.Factures
        .Where(f => !f.Payee && f.Echeance < aujourdhui)
        .OrderBy(f => f.Echeance)
        .ToListAsync();
});

app.Run();

WebApplicationFactory<Program>

Le paquet Microsoft.AspNetCore.Mvc.Testing fournit WebApplicationFactory<TEntryPoint>. Elle exécute le Program.cs de l'API, mais remplace Kestrel par un TestServer en mémoire, et fixe la racine de contenu au dossier du projet de l'API. D'après la documentation, le projet de test utilise le SDK Web et référence ce paquet, qui copie aussi dans son dossier de sortie le fichier .deps.json de l'API.

<!-- Le SDK Web, comme l'API : c'est ce que demande Microsoft.AspNetCore.Mvc.Testing. -->
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="coverlet.collector" Version="6.0.4" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Facturation.Api\Facturation.Api.csproj" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

</Project>

TEntryPoint sert à trouver l'assemblage de l'application ; on y passe Program. Avec des instructions de premier niveau, le compilateur génère cette classe internal, que le projet de test ne voit pas. Jusqu'à .NET 9, on ajoutait donc public partial class Program { } à la fin de Program.cs, ou un InternalsVisibleTo. En .NET 10, un générateur de source ajoute lui-même la déclaration publique, et l'analyseur ASP0027, de sévérité Information, suggère de retirer celle qu'on aurait écrite.

using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;

namespace Facturation.Api.Tests;

// Program est la classe generee pour les instructions de premier niveau de
// l'API ; en .NET 10, un generateur de source la declare publique.
public class PremierTests(WebApplicationFactory<Program> api) : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Une_recherche_sans_client_est_refusee()
    {
        // Un HttpClient branche sur un TestServer en memoire : ni port, ni socket.
        var client = api.CreateClient();

        var reponse = await client.GetAsync("/factures");

        Assert.Equal(HttpStatusCode.BadRequest, reponse.StatusCode);
    }
}

La fabrique est une fixture de classe, au sens du cours xUnit : une seule application démarre pour tous les tests de la classe. CreateClient() rend un HttpClient dont l'adresse de base est http://localhost/ et dont le gestionnaire passe les requêtes au TestServer sans ouvrir de socket. Par défaut, il suit les redirections et conserve les cookies ; un WebApplicationFactoryClientOptions avec AllowAutoRedirect = false permet de vérifier une redirection elle-même. Depuis .NET 10, UseKestrel() fait démarrer à la fabrique un vrai Kestrel, sur un vrai port, pour les tests qui passent par un navigateur ; ce cours reste sur le TestServer.

Ce test passe sans aucune base : la liaison refuse la requête avant d'appeler le gestionnaire, donc avant qu'EF Core ouvre une connexion. La sortie du Terminal Logger montre deux choses. Les journaux de l'application s'y mêlent à ceux d'xUnit, exception comprise, ce qui aide à comprendre une réponse 500. Et l'environnement est Development : là où ASP.NET Core prend Production par défaut, la fabrique fixe Development, donc la page d'exception du développeur est active, et UseEnvironment sur le builder de la fabrique en choisit un autre. L'API est créée par dotnet new web, dont l'appsettings.json limite les journaux de Microsoft.AspNetCore au niveau Warning ; sans lui, chaque requête ajouterait ses lignes. Les chemins sont ceux d'une solution placée dans C:\facturation.

$ dotnet test --filter "FullyQualifiedName~PremierTests"
Restauration terminée (1,9s)
  Facturation.Api net10.0 a réussi (1,3s) → C:\facturation\Facturation.Api\bin\Debug\net10.0\Facturation.Api.dll
  Facturation.Api.Tests net10.0 a réussi (1,6s) → bin\Debug\net10.0\Facturation.Api.Tests.dll
[xUnit.net 00:00:00.01] xUnit.net VSTest Adapter v3.1.4+50e68bbb8b (64-bit .NET 10.0.8)
[xUnit.net 00:00:00.34]   Discovering: Facturation.Api.Tests
[xUnit.net 00:00:00.54]   Discovered:  Facturation.Api.Tests
[xUnit.net 00:00:00.63]   Starting:    Facturation.Api.Tests
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: C:\facturation\Facturation.Api
fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
      An unhandled exception has occurred while executing the request.
      Microsoft.AspNetCore.Http.BadHttpRequestException: Required parameter "string client" was not provided from query string.
         at lambda_method26(Closure, Object, HttpContext)
         at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...
[xUnit.net 00:00:01.57]   Finished:    Facturation.Api.Tests
  Test de Facturation.Api.Tests net10.0 : a réussi (3,8 s)

Récapitulatif du test : total : 1; échec : 0; réussi : 1; ignoré : 0; durée : 3,7s
Générer a réussi dans 11,4s

Remplacer un service

ConfigureTestServices s'exécute après les enregistrements de Program.cs. Un service qu'on y enregistre à nouveau l'emporte donc quand il est résolu seul, puisque le conteneur rend le dernier enregistrement. C'est ainsi qu'on remplace la base. Le piège est dans le détail d'EF Core : la recette longtemps donnée par la documentation d'ASP.NET Core, jusqu'à sa version pour .NET 8, retirait DbContextOptions<FacturationDb> avant de rappeler AddDbContext. Sous EF Core 10, elle échoue :

using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Facturation.Api.Tests;

public sealed class ApiSqliteRecetteEfCore8 : WebApplicationFactory<Program>
{
    private readonly SqliteConnection connexion = new("DataSource=:memory:");

    public ApiSqliteRecetteEfCore8() => connexion.Open();

    protected override void ConfigureWebHost(IWebHostBuilder builder) =>
        builder.ConfigureTestServices(services =>
        {
            // La recette d'avant EF Core 9 : retirer les options, puis reenregistrer.
            services.RemoveAll<DbContextOptions<FacturationDb>>();
            services.AddDbContext<FacturationDb>(options => options.UseSqlite(connexion));
        });
}

public class RemplacementFauxTests(ApiSqliteRecetteEfCore8 api) : IClassFixture<ApiSqliteRecetteEfCore8>
{
    [Fact]
    public async Task Une_facture_inconnue_donne_404()
    {
        var reponse = await api.CreateClient().GetAsync("/factures/F-404");

        Assert.Equal(HttpStatusCode.NotFound, reponse.StatusCode);
    }
}

L'application démarre, et c'est la première requête qui touche la base qui échoue (extrait, sans les journaux de démarrage ni les piles d'appels) :

$ dotnet test --filter "FullyQualifiedName~RemplacementFauxTests"
fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
      An unhandled exception has occurred while executing the request.
      System.InvalidOperationException: Services for database providers 'Npgsql.EntityFrameworkCore.PostgreSQL', 'Microsoft.EntityFrameworkCore.Sqlite' have been registered in the service provider. Only a single database provider can be registered in a service provider. If possible, ensure that Entity Framework is managing its service provider by removing the call to 'UseInternalServiceProvider'. Otherwise, consider conditionally registering the database provider, or maintaining one service provider per database provider.
[xUnit.net 00:00:01.88]     Facturation.Api.Tests.RemplacementFauxTests.Une_facture_inconnue_donne_404 [FAIL]
[xUnit.net 00:00:01.88]       Assert.Equal() Failure: Values differ
[xUnit.net 00:00:01.88]       Expected: NotFound
[xUnit.net 00:00:01.88]       Actual:   InternalServerError

Récapitulatif du test : total : 1; échec : 1; réussi : 0; ignoré : 0; durée : 4,4s

Depuis EF Core 9, chaque appel à AddDbContext ou à ConfigureDbContext enregistre sa configuration comme un service IDbContextOptionsConfiguration<FacturationDb>, et les options sont reconstruites à partir de toutes. Retirer les options laisse le UseNpgsql de l'API en place, et UseSqlite s'y ajoute. La fabrique juste retire la configuration elle-même ; avec elle, le même test rend 404.

using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Facturation.Api.Tests;

public sealed class ApiSqlite : WebApplicationFactory<Program>
{
    // Une base SQLite en memoire vit aussi longtemps que sa connexion reste
    // ouverte : la fabrique ouvre la sienne, et la ferme a sa liberation.
    private readonly SqliteConnection connexion = new("DataSource=:memory:");

    public ApiSqlite() => connexion.Open();

    protected override void ConfigureWebHost(IWebHostBuilder builder) =>
        // Execute apres Program.cs : ces enregistrements s'ajoutent a ceux de l'API.
        builder.ConfigureTestServices(services =>
        {
            // Depuis EF Core 9, la configuration passee a AddDbContext est un
            // service a part : c'est elle qu'on retire, avec son UseNpgsql.
            services.RemoveAll<IDbContextOptionsConfiguration<FacturationDb>>();
            services.AddDbContext<FacturationDb>(options => options.UseSqlite(connexion));
        });

    // Le schema se cree une fois, sur l'application construite.
    protected override IHost CreateHost(IHostBuilder builder)
    {
        var host = base.CreateHost(builder);
        using var portee = host.Services.CreateScope();
        portee.ServiceProvider.GetRequiredService<FacturationDb>().Database.EnsureCreated();
        return host;
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        if (disposing)
        {
            connexion.Dispose();
        }
    }
}

La connexion ouverte n'est pas un détail. Une base SQLite :memory: n'existe que le temps de sa connexion. Avec une simple chaîne de connexion, chaque contexte ouvre et ferme la sienne : EnsureCreated crée la table dans une base aussitôt perdue, et la requête suivante échoue sur SQLite Error 1: 'no such table: Factures'.

Pour remplacer un service le temps d'un seul test, WithWebHostBuilder dérive de la fabrique une nouvelle application, avec sa configuration plus celle qu'on ajoute. La fabrique libère les applications dérivées avec elle. Ici, une horloge fixe rend la règle d'échéance vérifiable à la date près :

using Microsoft.AspNetCore.TestHost;

namespace Facturation.Api.Tests;

// Une horloge arretee, sur le fuseau UTC : GetLocalNow en derive.
public sealed class HorlogeFixe(DateTimeOffset maintenant) : TimeProvider
{
    public override DateTimeOffset GetUtcNow() => maintenant;

    public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}

public class EcheancesTests(ApiSqlite api) : IClassFixture<ApiSqlite>
{
    [Fact]
    public async Task Une_facture_est_echue_le_lendemain_de_son_echeance()
    {
        // WithWebHostBuilder construit, pour ce seul test, une application qui a la
        // configuration d'ApiSqlite plus celle-ci. Le dernier TimeProvider enregistre gagne.
        var client = api
            .WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
                services.AddSingleton<TimeProvider>(new HorlogeFixe(new(2026, 9, 25, 10, 0, 0, TimeSpan.Zero)))))
            .CreateClient();
        await client.PostAsJsonAsync("/factures", new NouvelleFacture("F-010", "Durand", 80m, new(2026, 9, 24)));
        await client.PostAsJsonAsync("/factures", new NouvelleFacture("F-011", "Martin", 95m, new(2026, 9, 25)));

        var echues = await client.GetFromJsonAsync<List<Facture>>("/factures/echues");

        Assert.Equal(["F-010"], echues!.Select(f => f.Numero));
    }
}

Le fournisseur en mémoire d'EF Core

UseInMemoryDatabase semble la base de test idéale : aucun fichier, aucun serveur. La documentation d'EF Core le déconseille fortement pour cet usage, et la description du paquet sur NuGet le dit aussi. Ce fournisseur n'est pas une base relationnelle : il évalue les requêtes en .NET, sur des collections, sans rien traduire en SQL. Sous EF Core 10.0.12, il accepte deux factures de même numéro malgré l'index unique, et un numéro de trente caractères malgré HasMaxLength(20). Il refuse FromSql et ExecuteDelete, et lève une exception au premier BeginTransaction. Le tableau comparatif de la documentation d'EF Core, lu le 26 septembre 2026, dit pourtant les transactions « ignorées » : c'est vrai du fournisseur, mais sous EF Core 10, UseInMemoryDatabase configure l'avertissement TransactionIgnoredWarning pour lever une exception. Un ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) la fait taire, et la transaction est alors réellement ignorée.

Le pire est ce qu'il accepte. Le test suivant vérifie la recherche de l'API sur cette base, et il passe. C'est précisément ce qui cloche : il passe parce que le fournisseur exécute string.Equals en .NET, ce que ni SQLite ni Npgsql ne traduisent.

using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Facturation.Api.Tests;

// Paquet Microsoft.EntityFrameworkCore.InMemory 10.0.12.
public sealed class ApiEnMemoire : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder) =>
        builder.ConfigureTestServices(services =>
        {
            services.RemoveAll<IDbContextOptionsConfiguration<FacturationDb>>();
            services.AddDbContext<FacturationDb>(options => options.UseInMemoryDatabase("facturation"));
        });
}

public class RechercheEnMemoireTests(ApiEnMemoire api) : IClassFixture<ApiEnMemoire>
{
    [Fact]
    public async Task La_recherche_par_client_ignore_la_casse()
    {
        var client = api.CreateClient();
        await client.PostAsJsonAsync("/factures", new NouvelleFacture("F-001", "Durand", 120m, new(2026, 9, 10)));

        var reponse = await client.GetAsync("/factures?client=durand");

        Assert.Equal(HttpStatusCode.OK, reponse.StatusCode);
        var factures = await reponse.Content.ReadFromJsonAsync<List<Facture>>();
        Assert.Equal("F-001", Assert.Single(factures!).Numero);
    }
}

Le même test, sur la fabrique SQLite de la section précédente :

using System.Net;

namespace Facturation.Api.Tests;

// Le meme test, sur la fabrique ApiSqlite.
public class RechercheSqliteTests(ApiSqlite api) : IClassFixture<ApiSqlite>
{
    [Fact]
    public async Task La_recherche_par_client_ignore_la_casse()
    {
        var client = api.CreateClient();
        await client.PostAsJsonAsync("/factures", new NouvelleFacture("F-001", "Durand", 120m, new(2026, 9, 10)));

        var reponse = await client.GetAsync("/factures?client=durand");

        Assert.Equal(HttpStatusCode.OK, reponse.StatusCode);
        var factures = await reponse.Content.ReadFromJsonAsync<List<Facture>>();
        Assert.Equal("F-001", Assert.Single(factures!).Numero);
    }
}

Lui échoue, et il a raison : ni le fournisseur SQLite ni Npgsql ne traduisent en SQL la surcharge de string.Equals qui prend un StringComparison. En production, sur PostgreSQL, la recherche aurait rendu la même erreur 500 à chaque appel (extrait, sans les journaux ni les piles d'appels) :

$ dotnet test --filter "FullyQualifiedName~RechercheEnMemoireTests|FullyQualifiedName~RechercheSqliteTests"
fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
      An unhandled exception has occurred while executing the request.
      System.InvalidOperationException: The LINQ expression 'DbSet<Facture>()
          .Where(f => string.Equals(
              a: f.Client,
              b: @client,
              comparisonType: OrdinalIgnoreCase))' could not be translated. Additional information: Translation of the 'string.Equals' overload with a 'StringComparison' parameter is not supported. See https://go.microsoft.com/fwlink/?linkid=2129535 for more information. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.
[xUnit.net 00:00:05.25]     Facturation.Api.Tests.RechercheSqliteTests.La_recherche_par_client_ignore_la_casse [FAIL]
[xUnit.net 00:00:05.25]       Assert.Equal() Failure: Values differ
[xUnit.net 00:00:05.25]       Expected: OK
[xUnit.net 00:00:05.25]       Actual:   InternalServerError

Récapitulatif du test : total : 2; échec : 1; réussi : 1; ignoré : 0; durée : 8,8s

La correction s'écrit dans une forme que les deux fournisseurs relationnels traduisent. Après elle, les deux tests passent. EF.Functions.ILike, propre à Npgsql, aurait aussi convenu en production, mais SQLite ne le traduit pas : une fonction propre à un fournisseur ne se teste que sur ce fournisseur.

// Dans Program.cs, a la place de la recherche precedente. EF Core traduit
// ToLower() par lower(), chez SQLite comme chez PostgreSQL.
app.MapGet("/factures", async (string client, FacturationDb db) =>
    await db.Factures
        .Where(f => f.Client.ToLower() == client.ToLower())
        .ToListAsync());

Une vraie base avec Testcontainers

SQLite est une vraie base relationnelle, mais ce n'est pas PostgreSQL. Sous EF Core 10.0.12, il accepte lui aussi un numéro de trente caractères : sa colonne TEXT n'a pas de longueur, là où Npgsql crée une colonne character varying(20), que PostgreSQL protège. Sa fonction lower() ne convertit que les lettres ASCII : la recherche corrigée ne trouve pas « Émile » quand on cherche « émile ». La documentation d'EF Core en tire la conclusion : tester contre le système de base de production, et ne prendre SQLite que faute de mieux.

Testcontainers démarre ce système dans un conteneur Docker jetable, le temps d'une fixture. La version courante de Testcontainers.PostgreSql est la 4.15.0, du 6 septembre 2026. Depuis la 4.10, l'image se passe au constructeur du builder ; le constructeur sans paramètre, qui prenait une image par défaut, est obsolète. La fixture démarre le conteneur, crée le schéma, et donne sa chaîne de connexion à l'API par la configuration :

using Microsoft.AspNetCore.Mvc.Testing;
using Testcontainers.PostgreSql;

namespace Facturation.Api.Tests;

// Paquet Testcontainers.PostgreSql 4.15.0. Il faut un demon Docker en marche.
public sealed class ApiPostgres : WebApplicationFactory<Program>, IAsyncLifetime
{
    // Un conteneur jetable, de la meme version majeure qu'en production, publie
    // sur un port libre de la machine.
    private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:18").Build();

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

        using var portee = Services.CreateScope();
        await portee.ServiceProvider.GetRequiredService<FacturationDb>().Database.EnsureCreatedAsync();
    }

    // Program.cs lit cette chaine : aucun service a remplacer, rien que la configuration.
    protected override void ConfigureWebHost(IWebHostBuilder builder) =>
        builder.UseSetting("ConnectionStrings:Facturation", postgres.GetConnectionString());

    // WebApplicationFactory a deja un DisposeAsync qui rend un ValueTask ;
    // celui d'IAsyncLifetime, en xUnit v2, rend un Task : implementation explicite.
    async Task IAsyncLifetime.DisposeAsync()
    {
        await DisposeAsync();
        await postgres.DisposeAsync();
    }
}

public class RecherchePostgresTests(ApiPostgres api) : IClassFixture<ApiPostgres>
{
    [Fact]
    public async Task La_recherche_par_client_ignore_la_casse()
    {
        var client = api.CreateClient();
        await client.PostAsJsonAsync("/factures", new NouvelleFacture("F-030", "Durand", 120m, new(2026, 9, 10)));

        var factures = await client.GetFromJsonAsync<List<Facture>>("/factures?client=durand");

        Assert.Equal("F-030", Assert.Single(factures!).Numero);
    }
}

Cet exemple exige un démon Docker, qui ne tournait pas sur la machine où ce cours a été écrit. Il y compile, avec la version 4.6.0 du paquet, présente dans le cache NuGet local, et la forme new PostgreSqlBuilder().WithImage("postgres:18") qu'elle impose ; il n'a pas été exécuté. Sans démon Docker, la fixture échoue, et avec elle tous les tests de la classe.

Isoler les tests

L'instance neuve par test que décrit le cours xUnit n'isole que les champs de la classe. La fabrique, partagée, porte une seule base pour tous les tests de la classe, et ce qu'un test y écrit, le suivant le lit.

namespace Facturation.Api.Tests;

// Une fabrique pour la classe, donc une base pour ses deux tests : celui qui
// passe en second trouve aussi la facture du premier.
public class ClientsPartagesTests(ApiSqlite api) : IClassFixture<ApiSqlite>
{
    [Fact]
    public async Task Durand_a_une_facture()
    {
        var client = api.CreateClient();
        await client.PostAsJsonAsync("/factures", new NouvelleFacture("F-020", "Durand", 120m, new(2026, 10, 1)));

        var factures = await client.GetFromJsonAsync<List<Facture>>("/factures?client=durand");

        Assert.Single(factures!);
    }

    [Fact]
    public async Task Durand_a_une_facture_de_cinquante_euros()
    {
        var client = api.CreateClient();
        await client.PostAsJsonAsync("/factures", new NouvelleFacture("F-021", "Durand", 50m, new(2026, 10, 1)));

        var factures = await client.GetFromJsonAsync<List<Facture>>("/factures?client=durand");

        Assert.Equal(50m, Assert.Single(factures!).Montant);
    }
}

Chaque test passe seul. Ensemble, celui qui s'exécute en second échoue ; ici, xUnit a lancé d'abord le test à cinquante euros, d'où l'identifiant 1 de F-021 (extrait, sans les journaux ni les piles d'appels) :

$ dotnet test --filter "FullyQualifiedName~ClientsPartagesTests"
[xUnit.net 00:00:05.74]     Facturation.Api.Tests.ClientsPartagesTests.Durand_a_une_facture [FAIL]
[xUnit.net 00:00:05.75]       Assert.Single() Failure: The collection contained 2 items
[xUnit.net 00:00:05.75]       Collection: [Facture { Client = "Durand", Echeance = 01/10/2026, Id = 1, Montant = 50,0, Numero = "F-021", ··· }, Facture { Client = "Durand", Echeance = 01/10/2026, Id = 2, Montant = 120,0, Numero = "F-020", ··· }]

Récapitulatif du test : total : 2; échec : 1; réussi : 1; ignoré : 0; durée : 8,0s

La correction remet la base dans un état connu avant chaque test, dans l'InitializeAsync de la classe, qu'xUnit appelle avant chaque test. ExecuteDeleteAsync vide la table en une instruction DELETE, sans charger les lignes.

using Microsoft.EntityFrameworkCore;

namespace Facturation.Api.Tests;

public class ClientsIsolesTests(ApiSqlite api) : IClassFixture<ApiSqlite>, IAsyncLifetime
{
    // Avant chaque test, la table repart vide. L'application, elle, reste celle
    // de la fabrique : on ne paie pas un nouveau demarrage par test.
    public async Task InitializeAsync()
    {
        using var portee = api.Services.CreateScope();
        await portee.ServiceProvider.GetRequiredService<FacturationDb>().Factures.ExecuteDeleteAsync();
    }

    public Task DisposeAsync() => Task.CompletedTask;

    [Fact]
    public async Task Durand_a_une_facture()
    {
        var client = api.CreateClient();
        await client.PostAsJsonAsync("/factures", new NouvelleFacture("F-020", "Durand", 120m, new(2026, 10, 1)));

        var factures = await client.GetFromJsonAsync<List<Facture>>("/factures?client=durand");

        Assert.Single(factures!);
    }

    [Fact]
    public async Task Durand_a_une_facture_de_cinquante_euros()
    {
        var client = api.CreateClient();
        await client.PostAsJsonAsync("/factures", new NouvelleFacture("F-021", "Durand", 50m, new(2026, 10, 1)));

        var factures = await client.GetFromJsonAsync<List<Facture>>("/factures?client=durand");

        Assert.Equal(50m, Assert.Single(factures!).Montant);
    }
}

Entre classes, le parallélisme d'xUnit ne pose ici aucun problème : chaque classe a sa fixture, donc sa connexion SQLite, donc sa base. Avec un conteneur partagé par une fixture de collection, les classes de la collection passent en série ; pour garder le parallélisme, on donne à chaque classe sa propre base sur le même serveur. Une autre technique consiste à créer des données propres à chaque test — un client au nom unique, par exemple — et à ne vérifier que celles-là.

La documentation d'EF Core propose aussi d'ouvrir une transaction au début du test et de ne jamais la valider. Cela suppose que le test tienne le contexte qui écrit. Derrière un HttpClient, chaque requête résout son propre contexte, dans sa propre portée ; contre une vraie base, ce contexte ouvre sa propre connexion et valide ses propres écritures, hors de la transaction du test. Le nettoyage entre les tests reste donc la voie ordinaire. La bibliothèque Respawn l'automatise : elle lit les clés étrangères du schéma et vide les tables par des DELETE dans l'ordre qu'elles imposent, en ignorant les tables qu'on lui désigne par TablesToIgnore. Sa version 7.0.0, du 30 novembre 2025, sous licence Apache 2.0, prend en charge SQL Server, PostgreSQL, MySQL, Oracle, Informix et, depuis cette version, DB2, Snowflake et SQLite. Ce cours n'en montre pas de code.

Ce cours vous a servi ? Offrir un café Signaler une erreur