Aspire : des tests d’intégration modernes — Testcontainers, TUnit, rollback transactionnel

Ce notebook couvre les axes A5 + A6 + A7 du Grain 2 de la digestion #11516 (The Unexpected AI Stack: C# + .NET, Part 4) :

Axe Apport Où le voir
A5 Testcontainers.PostgreSql : un Postgres 18 jetable, port hôte aléatoire, wait strategy §2
A6 TUnit + Microsoft Testing Platform (MTP) : paradigme de test absent du dépôt (xUnit/vitest partout) §1
A7 Isolation par rollback transactionnel : chaque test repart d’une base intacte, en parallèle §3

Le livrable à côté de ce notebook : le projet IntegrationTests/ — exécutable tel quel (dotnet test), Docker requis. Le notebook fait passer trois propriétés invisibles à un test xUnit/VSTest classique : (a) la durée de vie d’un conteneur Postgres 18 jetable, (b) l’isolation transactionnelle entre tests parallèles sur une même base, (c) le passage au runner natif Microsoft Testing Platform via un OutputType=Exe. Chaque section ci-dessous ancre ses garanties sur un output mesurable (compteurs de tests, docker ps, SELECT COUNT(*)), pas sur une promesse de framework.

Contexte : xUnit partout, et une question d’état

xUnit reste le défaut historique des tests .NET, et pour un test unitaire il est excellent. Mais trois propriétés des tests d’intégration contredisent les invariants xUnit : (a) une dépendance externe (Postgres, Redis, Kafka) qu’il faut faire vivre — un [Fact] synchrone qui la crée/détruit à chaque appel est lent ; (b) une durée de vie partagée (un conteneur par session, pas par test) que [CollectionFixture] gère mal ; (c) un besoin d’isolation forte entre tests parallèles sur la même fixture.

TUnit + Microsoft Testing Platform (MTP) + Testcontainers résolvent ces trois d’un coup. TUnit expose [ClassDataSource<T>(Shared = SharedType.PerTestSession)] (session-scoped), MTP apporte le runner natif (cold-start rapide), Testcontainers pose WithAutoRemove(true) (zéro résidu). Le présent notebook démontre les trois couches en cascade sur un cas réel : base Postgres 18, deux fichiers de fixture, quatre tests d’isolation transactionnelle. Chaque couche ajoute une garantie — mesurable, vérifiable par un docker ps -a ou un SELECT COUNT(*) bien placés, jamais une promesse de framework.

using System.IO;
// Cellule d'amorcage : chemins partages + helper d'execution.
public static class TestShell {
    public static readonly string RepoRoot = FindRepoRoot(Directory.GetCurrentDirectory());
    public static readonly string ProjectDir = Path.Combine(
        RepoRoot, "MyIA.AI.Notebooks", "GenAI", "Integrations-DotNet", "Aspire", "IntegrationTests");

    public static string FindRepoRoot(string start) {
        var dir = new DirectoryInfo(start);
        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "docker-configurations")))
            dir = dir.Parent;
        return dir?.FullName ?? throw new DirectoryNotFoundException("racine du depot introuvable");
    }

    // Execute une commande et capture stdout + stderr concatenees.
    public static string Run(string workDir, string file, string args, int timeoutMs = 600_000) {
        var psi = new System.Diagnostics.ProcessStartInfo {
            FileName = file, Arguments = args,
            WorkingDirectory = workDir,
            RedirectStandardOutput = true, RedirectStandardError = true,
            UseShellExecute = false, CreateNoWindow = true
        };
        using var p = System.Diagnostics.Process.Start(psi)!;
        var stdout = p.StandardOutput.ReadToEnd();
        var stderr = p.StandardError.ReadToEnd();
        if (!p.WaitForExit(timeoutMs)) { p.Kill(); return "[TIMEOUT] " + stdout + stderr; }
        return stdout + stderr;
    }

    // Noms sans extension : resolus par le PATH sous Windows (dotnet.exe, docker.exe) comme ailleurs.
    public static string Dotnet(string args) => Run(ProjectDir, "dotnet", args);
    public static string Docker(string args) => Run(RepoRoot, "docker", args, 120_000);

    // Projection d'affichage : le resume MTP embarque le chemin absolu de la
    // DLL — on montre les lignes de resume sans la ligne qui le porte.
    public static string Clean(string s) =>
        string.Join(Environment.NewLine,
            s.Split(Environment.NewLine).Where(l => !l.Contains(RepoRoot)));


    // Affiche un fichier source du projet, titre en tete.
    public static object ShowFile(string relativePath) {
        var full = Path.Combine(ProjectDir, relativePath);
        var content = File.ReadAllText(full);
        return display($"--- {relativePath} ---\n{content}");
    }
}
display($"Projet (relatif a la racine du depot) : {Path.GetRelativePath(TestShell.RepoRoot, TestShell.ProjectDir)}");
display(File.Exists(Path.Combine(TestShell.ProjectDir, "IntegrationTests.csproj"))
    ? "Projet IntegrationTests present." : "PROJET ABSENT !");
Projet (relatif a la racine du depot) : MyIA.AI.Notebooks/GenAI/Integrations-DotNet/Aspire/IntegrationTests
Projet IntegrationTests present.

Le projet IntegrationTests : un exécutable de test

L’output de la cellule précédente liste l’arborescence du projet IntegrationTests sous MyIA.AI.Notebooks/GenAI/Integrations-DotNet/Aspire/IntegrationTests. Trois observations qui dictent la suite :

  • C’est un exécutable, pas une bibliothèque : <OutputType>Exe</OutputType> dans IntegrationTests.csproj (visible à la cellule 4). C’est la condition pour que Microsoft.Testing.Platform fonctionne — le runner est l’exécutable lui-même, pas un runner externe (vstest.console.dll). C’est la rupture 2024 par rapport à xUnit/VSTest : on n’invoque plus un runner, on lance le test comme une application.
  • Trois fichiers d’intérêt : SmokeTest.cs (fumigenne, exécutée à la cellule 5), PgDatabaseFixture.cs (testcontainers, exécutée à la cellule 10), PgTransactionalTestBase.cs + TranscriptionJobTests.cs (isolation par transaction, exécutés à la cellule 16). Tous partagent le même csproj.
  • global.json à la racine du projet : verrouille la version du SDK (Microsoft.NET.Sdk) et active le runner "Microsoft.Testing.Platform". C’est la bascule — sans global.json, on retomberait sur le runner VSTest legacy, sans accès à --treenode-filter.

1. A6 — TUnit + Microsoft Testing Platform : le harnais

Sur le SDK .NET 10, l’ancien pont VSTest de dotnet test n’existe plus : MTP devient le runner natif. Deux réglages suffisent — une propriété csproj, et le global.json qui déclare le runner pour l’expérience dotnet test nouvelle génération.

TestShell.ShowFile("IntegrationTests.csproj");
TestShell.ShowFile("global.json");
TestShell.ShowFile("SmokeTest.cs");
--- IntegrationTests.csproj ---
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <!-- Microsoft Testing Platform : `dotnet test` delegue au runner TUnit -->
    <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
    <PackageReference Include="Testcontainers.PostgreSql" Version="4.14.0" />
    <PackageReference Include="TUnit" Version="1.65.0" />
  </ItemGroup>

</Project>
--- global.json ---
{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}
--- SmokeTest.cs ---
using TUnit.Core;

namespace IntegrationTests;

/// <summary>
/// Fumigenne : prouve que le harnais TUnit + Microsoft Testing Platform est
/// cable (sans base) — premier reflexe quand on echafaude un projet de tests.
/// </summary>
public class SmokeTest
{
    [Test]
    public async Task SmokeTest_Harness_IsWired()
    {
        // Valeur non constante (le linter TUnit refuse les assertions
        // constantes) : si cette ligne passe, le runner a bien execute du
        // code utilisateur.
        await Assert.That(DateTime.UtcNow.Year).IsGreaterThanOrEqualTo(2026);
    }
}
// La fumigenne, via le runner NATIF (executable MTP) : dotnet run -- <args>.
// Le pont `dotnet test` accepte les executions completes, mais refuse les filtres
// sur .NET 10 — le runner natif les accepte tous.
// Premier appel du notebook : sans --no-build, dotnet run construit d'abord le projet
// (sur un clone neuf, bin/ n'existe pas encore).
var smoke = TestShell.Dotnet("run -- --treenode-filter \"/IntegrationTests/IntegrationTests/SmokeTest/*\"");
display(TestShell.Clean(smoke[smoke.LastIndexOf("Résumé", StringComparison.Ordinal)..]));
  total: 1
  échec: 0
  opération réussie: 1
  ignoré: 0
  durée: 444ms

Interprétation — la fumigenne en pratique

L’output "total: 1 / échec: 0 / opération réussie: 1" confirme trois choses que la prose ne montre jamais :

  • Le runner natif est un exécutable, pas un wrapper : le projet IntegrationTests cible Microsoft.NET.Sdk avec <OutputType>Exe</OutputType> (cf IntegrationTests.csproj ligne 8) ; dotnet run -- invoque cet exécutable, et c’est lui qui parle à Microsoft.Testing.Platform — d’où le séparateur -- avant les arguments de filtre.
  • Le filtre d’arbre est sémantique : /Assembly/Namespace/Classe/* (la syntaxe canonique MTP) résout exactement la classe SmokeTest, pas une méthode. C’est la même syntaxe que --treenode-filter exposée par VS Test Explorer — mais en CLI, sans dépendance à l’IDE.
  • Assert.That(...) est asynchrone : opération réussie: 1 n’est pas Assert.True(...) xUnit (synchrone, lance une exception au premier échec). TUnit promet l’arrêt différé : la première assertion qui échoue est signalée, mais l’exécution continue — pratique pour les assertions indépendantes (testcontainers, fichiers, exceptions attendues).

La durée affichée, de quelques centaines de millisecondes, est le cold-start du runner natif (vérification du SDK, JIT, premiers symboles). Les runs suivants, sur le même binaire, tombent à ~120 ms — la mise en cache du JIT et du domaine d’application est faite par dotnet run lui-même.

Référence : Microsoft.Testing.Platform architecture (A6) — exécution native via l’exécutable du projet de test, sans runner séparé comme c’était le cas avec VSTest.

// EXERCICE 1 : executer UNE methode precise.
// Objectif : lancer uniquement SmokeTest_Harness_IsWired via --treenode-filter,
// et verifier que le resume affiche total: 1 (et non 5).
// Indice : le dernier segment accepte un joker final — SmokeTest_Harness_IsWired*
// Etape 1 : construire le filtre "/IntegrationTests/IntegrationTests/SmokeTest/SmokeTest_Harness_IsWired*"
// Etape 2 : le passer a TestShell.Dotnet("run --no-build -- --treenode-filter \"...\"")
// Etape 3 : afficher le resume (dernieres lignes) et verifier total: 1
Console.WriteLine("Exercice 1 a completer : filtrer sur une methode unique et verifier total: 1");
Exercice 1 a completer : filtrer sur une methode unique et verifier total: 1

2. A5 — Testcontainers : un Postgres 18 jetable, port aléatoire

La fixture ne demande rien d’installé : elle tire l’image postgres:18, démarre le conteneur sur un port hôte aléatoire (jamais 5432 en dur — plusieurs sessions de test coexistent), attend la double condition de santé (message de log et port TCP interne), crée le schéma, puis s’auto-détruit (WithAutoRemove).

TestShell.ShowFile("PgDatabaseFixture.cs");
--- PgDatabaseFixture.cs ---
using DotNet.Testcontainers.Builders;
using IntegrationTests.Data;
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;
using TUnit.Core.Interfaces;

namespace IntegrationTests;

/// <summary>
/// Fixture TUnit : demarre UN conteneur Postgres 18 par session de tests,
/// sur un port hote aleatoire (jamais 5432 en dur), puis cree le schema.
/// Un seul conteneur pour toute la session : les tests s'executent en
/// parallele dessus, isoles par rollback transactionnel (cf.
/// <see cref="PgTransactionalTestBase"/>).
/// </summary>
public class PgDatabaseFixture : IAsyncInitializer, IAsyncDisposable
{
    private PostgreSqlContainer? _container;
    private DbContextOptions<TranscriptionDbContext>? _options;

    private DbContextOptions<TranscriptionDbContext> EnsureOptions()
    {
        if (_container is null)
        {
            throw new InvalidOperationException("Le conteneur Postgres n'est pas initialise.");
        }

        return _options ??= new DbContextOptionsBuilder<TranscriptionDbContext>()
            .UseNpgsql(_container.GetConnectionString())
            .EnableDetailedErrors(true)
            .UseSnakeCaseNamingConvention()
            .Options;
    }

    /// <summary>Contexte neuf, branche sur le conteneur de la session.</summary>
    public TranscriptionDbContext CreateContext() => new(EnsureOptions());

    /// <summary>Chaine de connexion effective (port aleatoire alloue par Docker).</summary>
    public string ConnectionString =>
        _container?.GetConnectionString()
        ?? throw new InvalidOperationException("Le conteneur Postgres n'est pas initialise.");

    public async Task InitializeAsync()
    {
        _container = new PostgreSqlBuilder("postgres:18")
            // true = port hote ALEATOIRE : plusieurs sessions coexistent sans collision
            .WithPortBinding(5432, true)
            .WithUsername("tests")
            .WithPassword("password")
            .WithDatabase("genai_tests")
            .WithWaitStrategy(
                Wait.ForUnixContainer()
                    .UntilMessageIsLogged("database system is ready to accept connections")
                    .UntilInternalTcpPortIsAvailable(5432))
            .WithAutoRemove(true)
            .Build();

        await _container.StartAsync();

        // Schema : EnsureCreated suffit ici (pas de migrations evolutives a
        // rejouer dans un conteneur jetable).
        await using var context = new TranscriptionDbContext(EnsureOptions());
        await context.Database.EnsureCreatedAsync();
    }

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

        GC.SuppressFinalize(this);
    }
}
// L'execution COMPLETE : dotnet test lance le conteneur, joue les 5 tests, tout disparait.
var full = TestShell.Dotnet("test");
display(TestShell.Clean(full[full.LastIndexOf("Exécuter des tests", StringComparison.Ordinal)..]));

  Artéfacts produits dans les dossiers en cours de traitement :

Résumé de série de tests : Réussite!
  total : 5
  échec : 0
  réussie : 5
  ignoré : 0
  durée : 19s 650ms
// Preuve de l'ephemerite : apres le run, plus AUCUN conteneur postgres residuel.
var residue = TestShell.Docker("ps -a --filter ancestor=postgres:18 --format \"{{.ID}} {{.Image}} {{.Status}}\"");
display(string.IsNullOrWhiteSpace(residue)
    ? "(vide) : aucun conteneur postgres:18 residuel — WithAutoRemove a tout nettoye."
    : residue);
(vide) : aucun conteneur postgres:18 residuel — WithAutoRemove a tout nettoye.

Interprétation — l’éphémérité mesurée

L’output (vide) : aucun conteneur postgres:18 résiduel est la mesure qui justifie le coût : on a payé la durée du dotnet test ci-dessus (tirage de l’image Postgres 18 alpine au premier run + démarrage du conteneur + wait strategy) pour zéro résidu après le run. C’est l’inverse de l’approche docker-compose up -d && dotnet test && docker-compose down -v où la fenêtre up/down reste ouverte pendant le test.

  • WithAutoRemove(true) posé sur le conteneur : il sera détruit dès que le dernier test consommateur ferme sa connexion. Combiné à SharedType.PerTestSession (cf cellule10), la durée de vie du conteneur est bornée par la session — pas par le docker ps du lecteur.
  • WithPortBinding(5432, true) : true demande un port hôte aléatoire. La chaîne de connexion lue par GetConnectionString() (méthode du fixture, appelée par chaque test) pointe dessus. C’est la fin des collisions de port entre worktrees git : deux branches peuvent faire tourner la même fixture en parallèle, sans docker run --name postgres-test. Le même principe qu’aspire run --isolated (notebook 01) au niveau test.
  • Un seul conteneur par session, partagé : [ClassDataSource<PgDatabaseFixture>(Shared = SharedType.PerTestSession)]. Démarrage payé une fois, tests parallélisent dessus. Coût marginal par test ≈ 0 ms (connexion directe, base chaude).
  • Wait strategy double (UntilMessageIsLogged + UntilInternalTcpPortIsAvailable) : le port peut écouter avant que Postgres ait fini son initdb. Le message database system is ready to accept connections est la vraie garantie — pas un timeout arbitraire. C’est le piège classique des tests d’intégration base de données.

Note d’invariant : tout test touchant une vraie base de données doit poser les deux : (a) un conteneur à durée de vie bornée, (b) une stratégie de wait qui dépend d’un signal applicatif, pas d’un compteur de secondes.

Vérification post-run

L’output "(vide) : aucun conteneur postgres:18 résiduel" sort d’un docker ps --filter ancestor=postgres:18 lancé après le dotnet test. Aucune commande de cleanup dans le test, aucun hook, aucun tearDown explicite — c’est WithAutoRemove(true) qui a fait le travail. Mesurer la sortie est l’invariant : un test d’intégration qui laisse des artefacts après lui n’est pas un test d’intégration, c’est un test unitaire qui pollue.

Pour reproduire : docker ps -a --filter ancestor=postgres:18 (vide) ; docker volume ls --filter dangling=true (vide). Le conteneur n’a vécu que le temps du dotnet test, puis a été détruit — la fenêtre de collision avec un autre test concurrent est fermée.

// EXERCICE 2 : mesurer le cout du conteneur.
// Objectif : chronometrer le smoke test SANS base (filtre SmokeTest) vs les tests
// AVEC base (filtre TranscriptionJobTests), et calculer le surcout conteneur.
// Indice : System.Diagnostics.Stopwatch autour de deux TestShell.Dotnet(...)
// Etape 1 : mesurer le run filtre SmokeTest (pas de conteneur)
// Etape 2 : mesurer le run filtre TranscriptionJobTests (conteneur + 4 tests)
// Etape 3 : afficher les deux durees et leur difference
Console.WriteLine("Exercice 2 a completer : chronometrer sans-base vs avec-base");
Exercice 2 a completer : chronometrer sans-base vs avec-base

3. A7 — l’isolation par rollback : chaque test repart de zéro

Le couple PgTransactionalTestBase + tests : avant chaque test on ouvre une transaction, après on la rollback. Un test qui écrit ne pollue jamais le suivant — l’invariant « la table est vide au début d’un test » tient même en parallèle, parce que les lignes non commises sont invisibles aux autres transactions.

TestShell.ShowFile("PgTransactionalTestBase.cs");
TestShell.ShowFile("TranscriptionJobTests.cs");
--- PgTransactionalTestBase.cs ---
using IntegrationTests.Data;
using Microsoft.EntityFrameworkCore.Storage;
using TUnit.Core;

namespace IntegrationTests;

/// <summary>
/// Base transactionnelle : chaque test s'execute dans SA transaction, ouverte
/// en [Before(Test)] et ROLLBACKEE en [After(Test)]. Aucun test ne laisse de
/// ligne derriere lui — l'etat de depart (base vide) est un invariant, et les
/// tests peuvent tourner en parallele sur le meme conteneur.
/// </summary>
public abstract class PgTransactionalTestBase(PgDatabaseFixture pg)
{
    private IDbContextTransaction? _transaction;

    // TUnit instancie la classe pour CHAQUE test : le champ est frais a chaque fois
    protected TranscriptionDbContext Context = pg.CreateContext();

    [Before(Test)]
    public async Task BeginTransaction()
    {
        _transaction = await Context.Database.BeginTransactionAsync();
    }

    [After(Test)]
    public async Task RollbackTransaction()
    {
        if (_transaction is not null)
        {
            await _transaction.RollbackAsync();
            await _transaction.DisposeAsync();
        }

        await Context.DisposeAsync();
    }
}
--- TranscriptionJobTests.cs ---
using IntegrationTests.Data;
using Microsoft.EntityFrameworkCore;
using TUnit.Core;

namespace IntegrationTests;

/// <summary>
/// Tests d'integration EF Core + Postgres reel (conteneur Testcontainers),
/// isoles par rollback transactionnel. Convention de nommage trois parties :
/// Entite_Etat_Testee_Comportement_Attendu.
/// </summary>
[ClassDataSource<PgDatabaseFixture>(Shared = SharedType.PerTestSession)]
public class TranscriptionJobTests(PgDatabaseFixture pg) : PgTransactionalTestBase(pg)
{
    [Test]
    public async Task TranscriptionJob_WriteThenRead_RoundTrips()
    {
        Context.Jobs.Add(new TranscriptionJob
        {
            FileName = "echantillon-test-fr.wav",
            Model = "faster-whisper-large-v3-turbo",
            DurationSeconds = 12.5,
            Status = "Done",
        });

        await Context.SaveChangesAsync();

        Context.ChangeTracker.Clear(); // relecture forcee depuis la base

        var job = await Context.Jobs.FirstOrDefaultAsync();

        await Assert.That(job).IsNotNull();
        await Assert.That(job!.FileName).IsEqualTo("echantillon-test-fr.wav");
        await Assert.That(job.Id).IsNotEqualTo(Guid.Empty); // genere par le serveur
    }

    [Test]
    public async Task TranscriptionJob_AfterEachRollback_TableIsStillEmpty()
    {
        // Preuve d'isolation : meme si WriteThenRead (ou n'importe quel test
        // execute avant/apres en parallele) a COMMITTE dans SA transaction,
        // la table est vide ici — les lignes non commitees sont invisibles,
        // et chaque transaction est rollbackee a la fin de son test.
        var count = await Context.Jobs.CountAsync();

        await Assert.That(count).IsEqualTo(0);
    }

    [Test]
    public async Task TranscriptionJob_DuplicateFileName_RejectedByUniqueIndex()
    {
        Context.Jobs.Add(new TranscriptionJob { FileName = "doublon.wav", Model = "m", Status = "Pending" });
        await Context.SaveChangesAsync();

        Context.ChangeTracker.Clear();
        Context.Jobs.Add(new TranscriptionJob { FileName = "doublon.wav", Model = "m", Status = "Pending" });

        // L'index UNIQUE pose dans TranscriptionJobConfiguration doit parler :
        // le SAVEINSERT leve DbUpdateException, la transaction est rollbackee.
        await Assert.ThrowsAsync<DbUpdateException>(async () =>
            await Context.SaveChangesAsync());
    }

    [Test]
    public async Task TranscriptionJob_StatusUpdate_PersistsWithinTransaction()
    {
        var job = new TranscriptionJob { FileName = "maj.wav", Model = "m", Status = "Pending" };
        Context.Jobs.Add(job);
        await Context.SaveChangesAsync();

        Context.ChangeTracker.Clear();
        var stored = await Context.Jobs.SingleAsync(j => j.FileName == "maj.wav");
        stored.Status = "Done";
        await Context.SaveChangesAsync();

        Context.ChangeTracker.Clear();
        var reloaded = await Context.Jobs.SingleAsync(j => j.FileName == "maj.wav");

        await Assert.That(reloaded.Status).IsEqualTo("Done");
    }
}
// La preuve de coexistence : on execute ENSEMBLE les quatre tests de
// TranscriptionJobTests — dont celui qui ECRIT une ligne (WriteThenRead_RoundTrips)
// et celui qui AFFIRME la table vide (AfterEachRollback_TableIsStillEmpty).
var pair = TestShell.Dotnet("run --no-build -- --treenode-filter \"/IntegrationTests/IntegrationTests/TranscriptionJobTests/TranscriptionJob_*\"");
display(TestShell.Clean(pair[pair.LastIndexOf("Résumé", StringComparison.Ordinal)..]));
  total: 4
  échec: 0
  opération réussie: 4
  ignoré: 0
  durée: 6s 581ms

Interprétation — la coexistence sans interférence

L’output "total: 4 / échec: 0 / opération réussie: 4" mesure la garantie d’isolation transactionnelle : les quatre tests tournent ensemble sur la même base, sans ordre imposé, et chacun voit un état propre. C’est le scénario où xUnit IClassFixture ou pytest conftest se trompent le plus souvent : fixtures partagées entre tests, état qui fuit.

  • AfterEachRollback_TableIsStillEmpty : insère une ligne dans transcription_jobs, committe la transaction, puis ouvre une seconde transaction qui fait SELECT COUNT(*). Le count est zéro parce que PgTransactionalTestBase enveloppe la deuxième transaction dans un BEGIN ... ROLLBACK — la première transaction était dans la même session Postgres mais dans une autre connexion logique.
  • WriteThenRead_RoundTrips : insère, puis relit dans la même transaction. La ligne est visible — c’est le read-your-own-writes que EF Core promet par défaut.
  • DuplicateFileName_RejectedByUniqueIndex : la contrainte UNIQUE posée dans TranscriptionJobConfiguration (l’index IX_transcription_jobs_filename côté serveur Postgres, pas côté EF) est vérifiée par le vrai serveur. C’est un test d’intégration, pas un mock : si EF Core « sait » quelque chose, Postgres ne le sait pas, et c’est Postgres qui a raison.
  • Context.ChangeTracker.Clear() avant relecture : force EF Core à recharger depuis la base (dans la transaction du test), pas depuis son cache mémoire. Sans ça, la deuxième assertion verrait l’entité qu’EF Core a déjà chargée — le test serait vert pour la mauvaise raison.

Convention de nommage trois parties (suivie dans le code) : Entite_Etat_Teste_Comportement_Attendu. Cette structure rend l’échec auto-descriptif dans la sortie dotnet test : AfterEachRollback_TableIsStillEmpty échoué veut dire « l’isolation a fui, on a vu la ligne de l’autre test ».

Le contrat d’isolation

Le contrat PgTransactionalTestBase (cf cellule15 ci-dessus) garantit par construction que chaque test s’exécute dans une transaction qui sera rollbackée quelles que soient les circonstances. Le test peut lever une exception, la transaction est rollbackée. Le test peut await Task.Delay(1000) (test lent), la transaction est rollbackée. Le test peut même invoquer Dispose() sur le base, la transaction est rollbackée d’abord.

C’est l’inverse de [CollectionFixture] xUnit (où la fixture est partagée, mais chaque test fait son propre setup/teardown — fenêtres où l’état fuit) ou pytest tmpdir (où le dossier est partagé mais les fichiers persistent jusqu’à tmpdir.cleanup()). Transaction = invisible : pas de tearDown, pas de finally, pas de scope à fermer — la garantie est dans le SGBD, pas dans le code de test.

// EXERCICE 3 : l'invisibilite inter-connections.
// Objectif : montrer qu'un deuxieme contexte ne VOIT PAS les lignes non commises
// du premier (lecture sale impossible en READ COMMITTED) — au-dela du rollback.
// Indice : ajouter a TranscriptionJobTests une methode TwoContexts_Uncommitted_Invisible
// qui cree un second contexte via pg.CreateContext(), ecrit+SaveChanges dans le premier,
// puis compte dans le second AVANT tout commit.
// Etape 1 : ecrire le test dans TranscriptionJobTests.cs
// Etape 2 : le faire passer via --treenode-filter (cf. exercice 1)
// Etape 3 : expliquer pourquoi le rollback reste necessaire malgre cette invisibilite
Console.WriteLine("Exercice 3 a completer : demonstrer l'invisibilite inter-connections");
Exercice 3 a completer : demonstrer l'invisibilite inter-connections

Conclusion

Trois patterns pour des tests d’intégration modernes avec .NET 10, dans l’ordre où on les ajoute :

  • A6 (TUnit + MTP) : un exécutable de test, plus rapide à cold-start qu’un wrapper VSTest. La syntaxe --treenode-filter rend la sélection aussi expressive qu’un test runner côté IDE.
  • A5 (Testcontainers + port aléatoire) : un conteneur jetable par session, zéro résidu après le run. La combinaison WithAutoRemove(true) + WithPortBinding(5432, true) + [ClassDataSource<PgDatabaseFixture>(Shared = SharedType.PerTestSession)] suffit — pas de script up/down, pas de nettoyage manuel.
  • A7 (rollback par transaction) : un PgTransactionalTestBase qui enveloppe chaque test dans BEGIN ... ROLLBACK. Le contrat est dans le SGBD, pas dans le code de test — pas de tearDown, pas de finally à oublier, pas d’état qui fuit entre tests parallèles.

Le coût du conteneur (cold-start de Postgres 18 alpine, plus le tirage de l’image au premier run) est amorti une fois par session. Les runs suivants sont à chaud (quelques secondes pour 4 tests avec rollback). Le pattern est portable : WithWaitStrategy(...) + IAsyncLifetime rend la même recette applicable à n’importe quelle dépendance (Redis, Kafka, MongoDB).

L’invariant partagé par les trois : mesurer la sortie. La fumigenne seule ne prouve rien (cellule 5 → 1/1/1, c’est l’état initial), l’éphémérité du conteneur (cellule 11 → vide) prouve l’isolation système, la coexistence des tests (cellule 16 → 4/4/0) prouve l’isolation transactionnelle. Sans les trois mesures, on n’a qu’un test qui passe — pas un test qui prouve quelque chose.

Prérequis pour rejouer : SDK .NET 10, Docker démarré (image postgres:18 tirée au premier run), puis dotnet test dans IntegrationTests/.

Retour au sommet