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é
- Les calculs métier : tarifs, remises, TVA, règles d'éligibilité. Peu de dépendances, gros risque d'erreur, valeur immédiate.
- Chaque bug corrigé : écris d'abord le test qui reproduit le bug. Il ne reviendra jamais.
- Les parcours qui rapportent de l'argent : inscription, paiement, commande — en test d'intégration.
- 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);
}
}
| Assertion | Usage |
|---|---|
Assert.Equal(attendu, obtenu) | Le cas général. Attendu en premier |
Assert.True/False | Booléens — préfère une assertion plus parlante quand c'est possible |
Assert.Null / NotNull | Présence |
Assert.Contains / Empty / Single | Collections et chaînes |
Assert.Throws<T> / ThrowsAsync<T> | Une exception attendue |
Assert.Equivalent(a, b) | Comparaison structurelle profonde (xUnit v3) |
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
// 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);
}
}
// 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();
}
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ègle | Pourquoi |
|---|---|
| Un test = un comportement | Quand 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éterministe | Pas de DateTime.Now, pas d'aléatoire, pas d'appel réseau réel |
| Rapide | Une 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émentation | Sinon chaque refactoring casse la suite et tu finiras par la supprimer |
| Un test rouge d'abord pour chaque bug | Prouve la correction et empêche la régression |
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
- xUnit v3 est le modèle par défaut du SDK .NET 10, sur Microsoft.Testing.Platform (plus rapide, meilleure intégration CLI, exécutable autonome). NUnit et MSTest restent parfaitement valides.
- Parallélisme : xUnit exécute les classes de tests en parallèle. Deux classes qui écrivent dans la
même base se marchent dessus →
[Collection("Db")]pour les sérialiser. - Couverture : utile pour repérer le code jamais exécuté, dangereuse comme objectif. 100 % de couverture avec zéro assertion utile est atteignable — et inutile.
- Tests de snapshot (Verify) : très efficaces sur des sorties structurées (JSON, documents générés). Attention à ne pas approuver aveuglément un fichier de référence modifié.
- Tests de mutation (Stryker.NET) : modifie ton code pour vérifier que tes tests échouent. Le vrai indicateur de qualité d'une suite de tests.
- En intégration continue : échec sur avertissement, exécution des tests à chaque push, et
dotnet list package --vulnerabledans le même pipeline.
Vérifie que c'est passé
🎯 Quiz — 4 questions
1. Ton test échoue une fois sur dix, sans modification de code. Que fais-tu ?
2. Pourquoi éviter UseInMemoryDatabase pour tester du code EF Core ?
3. Comment tester du code qui dépend de la date du jour ?
4. Ton équipe vise 100 % de couverture. Bonne idée ?
Fiches de révision
[Fact] vs [Theory] ?WebApplicationFactory<Program> : vraies requêtes HTTP, en mémoire.MigrateAsync(). Pas UseInMemoryDatabase.TimeProvider injecté, FakeTimeProvider en test.- Beaucoup de tests unitaires rapides, un socle solide de tests d'intégration, très peu de bout en bout.
- Le nom du test décrit le comportement attendu : c'est de la documentation.
- Testcontainers pour la base, WebApplicationFactory pour l'API, bUnit pour Blazor.
- Injecte le temps et l'aléatoire : un test doit être déterministe.
- Teste le comportement, pas l'implémentation. Et un test rouge pour chaque bug.