Accueil › Qualité · perf · production › Chapitre 18

Tester son code

Un test n'est pas une corvée administrative : c'est le seul moyen de modifier ton code dans six mois sans avoir peur. L'objectif n'est pas un pourcentage de couverture, c'est la confiance — et pour l'obtenir, tous les tests ne se valent pas.

Quels tests, en quelle quantité

E2E Playwright · le parcours complet dans un navigateur Lents, fragiles → quelques scénarios critiques seulement Intégration API + vraie base Le meilleur rapport confiance / effort Unitaires Rapides, nombreux, ciblés
La forme exacte importe peu. Ce qui compte : beaucoup de tests rapides sur la logique, un socle solide de tests d'intégration sur les parcours réels, et très peu de tests de bout en bout.
Que tester en priorité quand on part de zéro
  1. Les calculs métier : tarifs, remises, TVA, règles d'éligibilité. Peu de dépendances, gros risque d'erreur, valeur immédiate.
  2. Chaque bug corrigé : écris d'abord le test qui reproduit le bug. Il ne reviendra jamais.
  3. Les parcours qui rapportent de l'argent : inscription, paiement, commande — en test d'intégration.
  4. Pas les getters/setters, ni le code sans logique, ni le framework.

Un premier test unitaire

dotnet new xunit -o Boutique.Tests          # xUnit v3 depuis .NET 10
cd Boutique.Tests
dotnet add reference ../Boutique.Domaine/Boutique.Domaine.csproj
dotnet test
public class CalculTarifTests
{
    [Fact]                                     // un cas unique
    public void Enfant_de_moins_de_4_ans_gratuit()
    {
        // Arrange — préparer
        var calcul = new CalculTarif();
        var passager = new Passager(Age: 3);

        // Act — agir (UNE seule action)
        var tarif = calcul.Calculer(passager);

        // Assert — vérifier
        Assert.Equal(0m, tarif);
    }

    [Theory]                                   // plusieurs cas, un seul code
    [InlineData(3, 0)]
    [InlineData(12, 5)]
    [InlineData(30, 12)]
    [InlineData(70, 8)]
    public void Tarif_selon_age(int age, decimal attendu)
        => Assert.Equal(attendu, new CalculTarif().Calculer(new Passager(age)));

    [Fact]
    public void Age_negatif_rejete()
    {
        var ex = Assert.Throws<ArgumentOutOfRangeException>(
            () => new CalculTarif().Calculer(new Passager(-1)));
        Assert.Contains("Age", ex.Message);
    }
}
AssertionUsage
Assert.Equal(attendu, obtenu)Le cas général. Attendu en premier
Assert.True/FalseBooléens — préfère une assertion plus parlante quand c'est possible
Assert.Null / NotNullPrésence
Assert.Contains / Empty / SingleCollections et chaînes
Assert.Throws<T> / ThrowsAsync<T>Une exception attendue
Assert.Equivalent(a, b)Comparaison structurelle profonde (xUnit v3)
Le nom du test est de la documentation

Test1, TestCalculer : inutiles. Enfant_de_moins_de_4_ans_gratuit : quand il échoue en intégration continue, tu sais déjà ce qui est cassé sans ouvrir le code. Formule recommandée : situation_action_résultat attendu.

Remplacer les dépendances

C'est ici que l'interface du chapitre 5 paie sa dette.

// Souvent la solution la plus lisible, et sans dépendance
internal sealed class EnvoyeurEspion : IEnvoyeurEmail
{
    public List<(string Destinataire, string Sujet)> Envoyes { get; } = [];
    public bool EstDisponible => true;

    public Task EnvoyerAsync(string d, string sujet, string corps)
    {
        Envoyes.Add((d, sujet));
        return Task.CompletedTask;
    }
}

[Fact]
public async Task Inscription_envoie_un_email_de_bienvenue()
{
    var espion = new EnvoyeurEspion();
    var service = new ServiceInscription(espion, new DepotEnMemoire());

    await service.InscrireAsync("ada@exemple.fr");

    var envoi = Assert.Single(espion.Envoyes);
    Assert.Equal("ada@exemple.fr", envoi.Destinataire);
    Assert.Contains("Bienvenue", envoi.Sujet);
}
// dotnet add package NSubstitute
var envoyeur = Substitute.For<IEnvoyeurEmail>();
envoyeur.EstDisponible.Returns(true);

var service = new ServiceInscription(envoyeur, depot);
await service.InscrireAsync("ada@exemple.fr");

// Vérifier l'interaction
await envoyeur.Received(1).EnvoyerAsync("ada@exemple.fr", Arg.Any<string>(), Arg.Any<string>());

// Simuler une panne
envoyeur.EnvoyerAsync(default!, default!, default!)
        .ThrowsAsyncForAnyArgs(new SmtpException());
await Assert.ThrowsAsync<SmtpException>(() => service.InscrireAsync("a@b.fr"));

Utile, mais attention : un test qui vérifie comment le code s'y prend (les appels exacts) casse à chaque refactoring. Préfère vérifier le résultat observable quand c'est possible.

// ❌ Un test qui utilise DateTime.Now échouera un jour (le 31 décembre, à 23 h 59, une année bissextile…)
public decimal Remise() => DateTime.Now.DayOfWeek == DayOfWeek.Sunday ? 0.1m : 0m;

// ✅ Injecte le temps : TimeProvider est intégré depuis .NET 8
public class ServiceRemise(TimeProvider horloge)
{
    public decimal Remise() =>
        horloge.GetUtcNow().DayOfWeek == DayOfWeek.Sunday ? 0.1m : 0m;
}

[Fact]
public void Dimanche_remise_de_10_pourcent()
{
    var faux = new FakeTimeProvider(new DateTimeOffset(2026, 8, 2, 12, 0, 0, TimeSpan.Zero)); // dimanche
    Assert.Equal(0.1m, new ServiceRemise(faux).Remise());
}
// Paquet : Microsoft.Extensions.TimeProvider.Testing
// Idem pour l'aléatoire (injecte une graine) et les GUID (injecte une fabrique).

Tests d'intégration : le meilleur rapport confiance / effort

L'application entière en mémoire, avec de vraies requêtes HTTP
// dotnet add package Microsoft.AspNetCore.Mvc.Testing
public class ProduitsApiTests(WebApplicationFactory<Program> usine)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Get_produits_renvoie_200_et_du_json()
    {
        var client = usine.CreateClient();

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

        reponse.EnsureSuccessStatusCode();
        Assert.Equal("application/json", reponse.Content.Headers.ContentType?.MediaType);
        var produits = await reponse.Content.ReadFromJsonAsync<List<ProduitDto>>();
        Assert.NotNull(produits);
    }

    [Fact]
    public async Task Post_produit_invalide_renvoie_400_avec_details()
    {
        var client = usine.CreateClient();

        var reponse = await client.PostAsJsonAsync("/produits", new { Nom = "", Prix = -5 });

        Assert.Equal(HttpStatusCode.BadRequest, reponse.StatusCode);
        var probleme = await reponse.Content.ReadFromJsonAsync<ValidationProblemDetails>();
        Assert.Contains("Nom", probleme!.Errors.Keys);
    }

    [Fact]
    public async Task Endpoint_admin_sans_droit_renvoie_403()
    {
        var client = usine.CreateClient();
        // … s'authentifier avec un utilisateur non admin …
        Assert.Equal(HttpStatusCode.Forbidden,
            (await client.DeleteAsync("/produits/1")).StatusCode);
    }
}
Une vraie base, jetable, dans un conteneur — la référence aujourd'hui
// dotnet add package Testcontainers.MsSql  (ou .PostgreSql)
public class UsineTest : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly MsSqlContainer _sql = new MsSqlBuilder().Build();

    public async Task InitializeAsync()
    {
        await _sql.StartAsync();                     // démarre SQL Server en conteneur
        using var portee = Services.CreateScope();
        var db = portee.ServiceProvider.GetRequiredService<AppDb>();
        await db.Database.MigrateAsync();            // applique les VRAIES migrations
        await Semer(db);
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder) =>
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<DbContextOptions<AppDb>>();
            services.AddDbContext<AppDb>(o => o.UseSqlServer(_sql.GetConnectionString()));
            services.RemoveAll<IEnvoyeurEmail>();
            services.AddScoped<IEnvoyeurEmail, EnvoyeurEspion>();   // pas de vrai email
        });

    public new async Task DisposeAsync() => await _sql.DisposeAsync();
}
Pourquoi c'est supérieur au fournisseur « en mémoire »

UseInMemoryDatabase n'applique ni contraintes d'unicité, ni clés étrangères, ni transactions, et ne traduit rien en SQL : tes tests passent alors que la production échoue (et inversement, une requête refusée par EF en mémoire fonctionne parfaitement en SQL). Testcontainers exécute ta vraie base, avec tes vraies migrations. Coût : quelques secondes de démarrage, mutualisées entre tous les tests.

Tester des composants Blazor

// dotnet add package bunit
public class CompteurTests : TestContext
{
    [Fact]
    public void Le_clic_incremente_et_previent_le_parent()
    {
        var recu = 0;
        var composant = RenderComponent<Compteur>(p => p
            .Add(c => c.Titre, "Essai")
            .Add(c => c.OnChangement, v => recu = v));

        composant.Find("button").Click();

        Assert.Contains("Valeur actuelle : <b>1</b>", composant.Markup);
        Assert.Equal(1, recu);
    }

    [Fact]
    public void Le_bouton_est_desactive_au_maximum()
    {
        var c = RenderComponent<Compteur>(p => p.Add(x => x.Maximum, 1));
        c.Find("button").Click();
        Assert.True(c.Find("button").HasAttribute("disabled"));
    }
}

Hygiène de tests

RèglePourquoi
Un test = un comportementQuand il échoue, tu sais quoi réparer. Cinq assertions sans lien = cinq tests
Aucun ordre, aucun état partagéUn test qui ne passe que s'il est exécuté en deuxième est un test cassé
DéterministePas de DateTime.Now, pas d'aléatoire, pas d'appel réseau réel
RapideUne suite unitaire de plus de 30 secondes ne sera plus exécutée par personne
Lisible sans le code testéUn test est aussi une spécification exécutable
Tester le comportement, pas l'implémentationSinon chaque refactoring casse la suite et tu finiras par la supprimer
Un test rouge d'abord pour chaque bugProuve la correction et empêche la régression
Commandes utiles
dotnet test                                     # tout
dotnet test --filter "FullyQualifiedName~Tarif" # par nom
dotnet test --filter "Category=Integration"     # par trait
dotnet test --no-dependencies                   # .NET 11 : sans reconstruire les dépendances
dotnet test --collect:"XPlat Code Coverage"     # couverture (format Cobertura)
dotnet watch test                               # relance à chaque modification
Précisions d'outillage

Vérifie que c'est passé

🎯 Quiz — 4 questions

1. Ton test échoue une fois sur dix, sans modification de code. Que fais-tu ?

Un test instable détruit la confiance dans toute la suite : très vite, plus personne ne regarde les échecs. C'est un bug à corriger en priorité — souvent dans le code, pas dans le test.

2. Pourquoi éviter UseInMemoryDatabase pour tester du code EF Core ?

Testcontainers exécute ta vraie base avec tes vraies migrations, pour quelques secondes de démarrage.

3. Comment tester du code qui dépend de la date du jour ?

Calculer la date attendue dans le test reproduit le bug qu'on veut détecter. Le temps est une dépendance : elle s'injecte.

4. Ton équipe vise 100 % de couverture. Bonne idée ?

On peut couvrir 100 % du code sans une seule assertion pertinente. Utilise la couverture pour repérer les zones oubliées, et les tests de mutation pour juger de la qualité réelle.

Fiches de révision

Structure d'un test ?
Arrange (préparer), Act (une action), Assert (vérifier).
[Fact] vs [Theory] ?
Fact = un cas. Theory + InlineData = plusieurs jeux de données, un seul code.
Tester une API entière ?
WebApplicationFactory<Program> : vraies requêtes HTTP, en mémoire.
Tester avec une base ?
Testcontainers + MigrateAsync(). Pas UseInMemoryDatabase.
Le temps dans les tests ?
TimeProvider injecté, FakeTimeProvider en test.
Après un bug corrigé ?
Écrire le test qui reproduit le bug — d'abord rouge, puis vert.
À retenir