Chapitre 1517 minIntermédiaire
Tester une classe métier avec xUnit
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- organiser une solution avec un projet de tests ;
- écrire un test unitaire selon le schéma AAA ;
- tester une exception et plusieurs cas avec
[Theory]; - tester un service avec des doublures ;
- choisir les cas de test d'une règle.
3 exercices corrigés
Un test unitaire est une petite méthode qui exécute une unité de code, le plus souvent une méthode d'une classe métier, dans une situation précise, et vérifie automatiquement que le résultat est celui attendu. En C#, on les écrit avec un framework de test comme xUnit, dans un projet séparé, et on les lance tous d'une commande avec dotnet test. Ce chapitre montre comment tester les classes de GestParc : organiser la solution, écrire un test, vérifier une exception, tester un service grâce à l'injection de dépendances. La démarche générale (types de tests, non-régression, TDD, jeux d'essai) est détaillée dans le cours Tests unitaires et non-régression.
Pourquoi tester une classe métier
Les règles de gestion vivent dans les classes métier : un ticket résolu n'accepte plus d'intervention, une installation est refusée quand les licences sont épuisées, un portable est amorti sur trois ans. Un test par règle te donne trois choses :
- la preuve que la règle fonctionne, sur des cas que tu as choisis, y compris les cas limites ;
- une alerte immédiate si une modification la casse plus tard : c'est la non-régression ;
- une documentation exécutable : le nom du test énonce la règle.
À l'E6, montrer des tests automatisés qui passent avant et après ton évolution est un argument fort pendant la recette (la recette et l'entretien de l'E6 SLAM). C'est aussi grâce à l'encapsulation et à l'injection de dépendances des chapitres précédents que ces classes se testent facilement : elles ne dépendent ni d'une base, ni d'un écran, ni de l'heure réelle.
Organiser la solution
Les classes métier vont dans une bibliothèque de classes, que l'application et les tests référencent tous les deux. Une solution regroupe les projets.
dotnet new sln -n GestParc
dotnet new classlib -n GestParc.Domaine
dotnet new console -n GestParc.Console
dotnet new xunit -n GestParc.Domaine.Tests
dotnet sln add GestParc.Domaine GestParc.Console GestParc.Domaine.Tests
dotnet add GestParc.Console reference GestParc.Domaine
dotnet add GestParc.Domaine.Tests reference GestParc.Domaine
GestParc/
├── GestParc.slnx la solution (format XML de .NET 10)
├── GestParc.Domaine/ classes métier : Ticket, Logiciel, exceptions, interfaces
├── GestParc.Console/ l'application : Program.cs, la racine de composition
└── GestParc.Domaine.Tests/ les tests, qui référencent GestParc.Domaine
Avec le SDK .NET 10, dotnet new sln crée un fichier .slnx, un format XML plus lisible que l'ancien .sln ; Visual Studio 2026 et VS Code ouvrent les deux. Plus tard, le projet WinForms remplacera ou complétera le projet console : il référencera le même domaine, sans le modifier. Un projet WinForms s'ajoute de la même façon à la solution : le cours C# et WinForms crée ainsi une solution en quatre projets, interface, métier, données et tests, et explique leur découpage en couches.
Dans Visual Studio : Ajouter → Nouveau projet → Projet de test xUnit, puis Ajouter → Référence de projet.
Écrire un premier test
Un test xUnit est une méthode publique marquée de l'attribut [Fact], dans une classe publique. Il suit le schéma AAA : Arrange (préparer les objets), Act (exécuter l'action testée), Assert (vérifier le résultat).
using GestParc.Domaine;
namespace GestParc.Domaine.Tests;
public class LogicielTests
{
[Fact]
public void Installer_decompte_une_licence()
{
// Arrange
var office = new Logiciel("Microsoft 365", 10);
// Act
office.Installer("PC-0001");
// Assert
Assert.Equal(9, office.LicencesDisponibles);
}
}
Le nom du test décrit le comportement attendu, en une phrase : Installer_decompte_une_licence. Quand il échouera dans six mois, son nom dira quelle règle est cassée. Une convention répandue : Methode_Situation_ResultatAttendu.
Les assertions courantes
| Assertion xUnit | Vérifie que… |
|---|---|
Assert.Equal(attendu, obtenu) | deux valeurs sont égales (attention à l'ordre : l'attendu d'abord) |
Assert.NotEqual(a, b) | deux valeurs diffèrent |
Assert.True(condition), Assert.False(condition) | une condition est vraie, fausse |
Assert.Null(x), Assert.NotNull(x) | une référence est nulle, non nulle |
Assert.Empty(liste), Assert.Single(liste) | une collection est vide, contient un seul élément |
Assert.Contains(element, liste) | une collection contient un élément (ou une chaîne, une sous-chaîne) |
Assert.Throws<TException>(() => …) | l'action lève exactement ce type d'exception, et la renvoie |
Assert.Same(a, b) | deux variables désignent le même objet |
Tester une exception
Une règle qui interdit quelque chose se teste en vérifiant que l'exception attendue est levée, et que l'objet est resté cohérent.
[Fact]
public void Installer_au_dela_des_licences_leve_LicencesEpuiseesException()
{
var acrobat = new Logiciel("Adobe Acrobat Pro", 2);
acrobat.Installer("PC-0001");
acrobat.Installer("PC-0002");
var e = Assert.Throws<LicencesEpuiseesException>(() => acrobat.Installer("PC-0003"));
Assert.Equal(2, e.NbLicences);
Assert.Equal(2, acrobat.Postes.Count); // la troisième installation n'a pas eu lieu
}
L'action est passée sous forme de lambda (() => acrobat.Installer("PC-0003")) : c'est Assert.Throws qui l'exécute et attrape l'exception. Assert.Throws exige le type exact ; une classe fille ne suffit pas (Assert.ThrowsAny l'accepte).
Tester plusieurs cas avec [Theory]
Quand la même règle doit être vérifiée sur plusieurs valeurs, [Theory] et [InlineData] évitent de recopier le test :
[Theory]
[InlineData(0)]
[InlineData(-10)]
public void AjouterIntervention_refuse_une_duree_nulle_ou_negative(int duree)
{
Ticket ticket = NouveauTicket();
Assert.Throws<ArgumentOutOfRangeException>(
() => ticket.AjouterIntervention(Chloe, Ouverture.AddHours(1), duree, "?"));
Assert.Equal(StatutTicket.Ouvert, ticket.Statut);
}
Chaque [InlineData] produit un cas de test distinct, avec son propre résultat. NouveauTicket(), Chloe et Ouverture sont une méthode et des champs static readonly de la classe de test, qui préparent les objets communs à plusieurs tests.
Lancer les tests
dotnet test
Réussi! - échec : 0, réussite : 20, ignorée(s) : 0, total : 20, durée : 28 ms - GestParc.Domaine.Tests.dll (net10.0)
Dans Visual Studio, l'Explorateur de tests (menu Test) affiche chaque test avec une pastille verte ou rouge ; dans VS Code, l'extension C# Dev Kit ajoute un onglet Testing. Lance les tests après chaque modification : c'est quelques secondes, et c'est ton filet de sécurité. Le cours Intégration et déploiement continus montre comment GitHub Actions les lance à chaque git push.
Lire un test qui échoue
Supposons qu'une erreur dans DureeTotaleMin fasse un test rouge. Le rapport donne tout ce qu'il faut pour trouver :
Échoué GestParc.Domaine.Tests.TicketTests.DureeTotale_cumule_les_interventions [7 ms]
Message d'erreur :
Assert.Equal() Failure: Values differ
Expected: 40
Actual: 45
Arborescence des appels de procédure :
at GestParc.Domaine.Tests.TicketTests.DureeTotale_cumule_les_interventions() in …/TicketTests.cs:line 14
Le nom du test (la règle), la valeur attendue, la valeur obtenue et la ligne. Avant de corriger le code, demande-toi qui a raison : le code, ou le test ? Ici, deux interventions de 30 et 15 minutes font bien 45 : c'est le test qui attendait une valeur fausse.
Tester un service grâce aux doublures
TicketService dépend d'un repository, d'une stratégie d'échéance, d'un notificateur et d'une horloge. Grâce à l'injection de dépendances, un test lui fournit des doublures (test doubles) : de fausses implémentations, simples et contrôlées.
// Une horloge arrêtée à l'heure choisie par le test
public class HorlogeFixe(DateTimeOffset maintenant) : TimeProvider
{
public DateTimeOffset Maintenant { get; set; } = maintenant;
public override DateTimeOffset GetUtcNow() => Maintenant;
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
// Un notificateur qui n'envoie rien, mais mémorise les messages
public class NotificateurEspion : INotificateur
{
public List<string> Messages { get; } = [];
public void Notifier(string destinataire, string message) => Messages.Add(message);
}
[Fact]
public void Ouvrir_un_ticket_critique_previent_l_astreinte()
{
var espion = new NotificateurEspion();
var horloge = new HorlogeFixe(new DateTimeOffset(2026, 11, 2, 9, 0, 0, TimeSpan.Zero));
var service = new TicketService(new TicketRepositoryMemoire(), new EcheanceStandard(), espion, horloge);
service.Ouvrir("Serveur inaccessible", Priorite.Critique, Julie);
service.Ouvrir("Souris HS", Priorite.Basse, Julie);
Assert.Single(espion.Messages);
Assert.Contains("Serveur inaccessible", espion.Messages[0]);
}
[Fact]
public void EnRetard_renvoie_les_tickets_dont_l_echeance_est_depassee()
{
var horloge = new HorlogeFixe(new DateTimeOffset(2026, 11, 2, 9, 0, 0, TimeSpan.Zero));
var service = new TicketService(new TicketRepositoryMemoire(), new EcheanceStandard(),
new NotificateurEspion(), horloge);
Ticket critique = service.Ouvrir("Serveur inaccessible", Priorite.Critique, Julie);
service.Ouvrir("Imprimante bloquée", Priorite.Normale, Julie);
horloge.Maintenant = horloge.Maintenant.AddHours(5); // on avance l'horloge de 5 heures
Assert.Equal([critique], service.EnRetard());
}
Le second test vérifie en quelques millisecondes qu'un ticket critique (échéance à 4 heures) est en retard au bout de 5 heures, alors qu'un ticket normal (24 heures) ne l'est pas. Sans TimeProvider injecté, il faudrait attendre cinq heures. Le TicketRepositoryMemoire du chapitre 13 remplace la base.
| Doublure | Rôle | Exemple |
|---|---|---|
| Bouchon (stub) | renvoie des réponses prévues | HorlogeFixe |
| Espion (spy) | enregistre les appels pour les vérifier ensuite | NotificateurEspion |
| Faux objet (fake) | une vraie implémentation simplifiée | TicketRepositoryMemoire |
| Mock | doublure générée par une bibliothèque (Moq, NSubstitute), qui vérifie les appels attendus | hors programme ici |
Que tester ?
Pour chaque méthode qui porte une règle, cherche trois familles de cas :
- Le cas nominal : l'usage normal (une installation quand il reste des licences).
- Les cas limites : les bornes des intervalles et les cas « vides » (la dernière licence disponible, une durée de 1 minute et de 480 minutes, une liste vide, un logiciel libre sans nombre de licences).
- Les cas d'erreur : ce qui doit être refusé (plus de licence, durée négative, transition interdite), en vérifiant que l'objet reste cohérent.
Ne teste pas le langage ou le framework (qu'une propriété automatique renvoie ce qu'on y a mis) : teste tes règles.
xUnit, NUnit ou MSTest
Le SDK .NET 10 fournit trois modèles de projet de test : dotnet new xunit, dotnet new nunit et dotnet new mstest. Les trois se valent ; seuls les noms des attributs changent ([Fact] en xUnit, [Test] en NUnit, [TestMethod] en MSTest). Ce cours utilise xUnit, le plus répandu dans les projets .NET récents. Le modèle xunit fourni avec le SDK installe la version 2 de xUnit ; la version 3, plus récente, s'installe avec ses propres modèles (dotnet new install xunit.v3.templates), et la façon d'écrire les tests reste la même.
Exercices
Exercice 1 sur 3 Écrire des tests pour Logiciel Réussi À revoir
La classe Logiciel a un constructeur Logiciel(string nom, int? nbLicences), une méthode Installer(string numPoste) et les propriétés Postes (liste en lecture seule) et LicencesDisponibles (int?, nul pour un logiciel libre). Écris trois tests :
- installer deux fois sur le même poste ne consomme qu'une licence ;
- un logiciel libre accepte 50 installations ;
- un nom vide ou fait d'espaces est refusé (
ArgumentException), avec un[Theory].
Voir la réponseMasquer la réponse de l'exercice 1
[Fact]
public void Installer_deux_fois_sur_le_meme_poste_ne_consomme_qu_une_licence()
{
var office = new Logiciel("Microsoft 365", 10);
office.Installer("PC-0001");
office.Installer("PC-0001");
Assert.Equal(9, office.LicencesDisponibles);
Assert.Single(office.Postes);
}
[Fact]
public void Un_logiciel_libre_s_installe_sans_limite()
{
var vlc = new Logiciel("VLC", null);
for (int i = 1; i <= 50; i++)
{
vlc.Installer($"PC-{i:0000}");
}
Assert.Equal(50, vlc.Postes.Count);
Assert.Null(vlc.LicencesDisponibles);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Un_nom_vide_est_refuse(string nom)
{
Assert.Throws<ArgumentException>(() => new Logiciel(nom, 1));
}
Le premier test protège une règle facile à oublier : sans le contrôle if (postes.Contains(numPoste)) return; dans Installer, il échoue (8 licences disponibles au lieu de 9). Le deuxième vérifie le cas limite du logiciel libre, dont le nombre de licences est nul. Dans le troisième, ArgumentException.ThrowIfNullOrWhiteSpace lève bien une ArgumentException pour une chaîne vide ou d'espaces.
Tu avais trouvé ?
Exercice 2 sur 3 Choisir les cas de test Réussi À revoir
La propriété DureeMin d'une intervention accepte de 1 à 480 minutes. Propose les valeurs à tester et le résultat attendu pour chacune.
Voir la réponseMasquer la réponse de l'exercice 2
| Valeur | Attendu | Pourquoi |
|---|---|---|
| 45 | acceptée | cas nominal |
| 1 | acceptée | borne basse, juste dedans |
| 0 | ArgumentOutOfRangeException | borne basse, juste dehors |
| 480 | acceptée | borne haute, juste dedans |
| 481 | ArgumentOutOfRangeException | borne haute, juste dehors |
| −10 | ArgumentOutOfRangeException | valeur clairement invalide |
Les erreurs se cachent aux bornes (< écrit à la place de <=) : on teste chaque borne des deux côtés. Un [Theory] avec un [InlineData] par valeur acceptée, et un autre pour les valeurs refusées, couvre tout en deux méthodes.
Tu avais trouvé ?
Exercice 3 sur 3 Un test paramétré pour la stratégie Réussi À revoir
Écris un [Theory] qui vérifie l'échéance donnée par EcheanceStandard pour les quatre priorités, sur un ticket ouvert le 2 novembre 2026 à 9 h. Les délais sont de 4, 8, 24 et 72 heures.
Voir la réponseMasquer la réponse de l'exercice 3
[Theory]
[InlineData(Priorite.Critique, 13)]
[InlineData(Priorite.Haute, 17)]
[InlineData(Priorite.Normale, 33)]
[InlineData(Priorite.Basse, 81)]
public void EcheanceStandard_ajoute_le_delai_de_la_priorite(Priorite priorite, int heureAttendue)
{
Ticket ticket = new(1, "Test", priorite, Julie, new DateTime(2026, 11, 2, 9, 0, 0));
DateTime echeance = new EcheanceStandard().Echeance(ticket);
Assert.Equal(new DateTime(2026, 11, 2, 0, 0, 0).AddHours(heureAttendue), echeance);
}
heureAttendue compte les heures depuis le 2 novembre à minuit : 9 + 4 = 13 h ; 9 + 8 = 17 h ; 9 + 24 = 33, soit le 3 novembre à 9 h ; 9 + 72 = 81, soit le 5 novembre à 9 h. Un [InlineData] ne peut contenir que des constantes : on ne peut pas y écrire new DateTime(…), d'où ce nombre d'heures.
Tu avais trouvé ?