Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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.

bash
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
text
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).

csharp
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 xUnitVé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.

csharp
[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 :

csharp
[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

bash
dotnet test
text
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 :

text
  É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.

csharp
// 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);
}
csharp
[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.

DoublureRôleExemple
Bouchon (stub)renvoie des réponses prévuesHorlogeFixe
Espion (spy)enregistre les appels pour les vérifier ensuiteNotificateurEspion
Faux objet (fake)une vraie implémentation simplifiéeTicketRepositoryMemoire
Mockdoublure générée par une bibliothèque (Moq, NSubstitute), qui vérifie les appels attendushors programme ici

Que tester ?

Pour chaque méthode qui porte une règle, cherche trois familles de cas :

  1. Le cas nominal : l'usage normal (une installation quand il reste des licences).
  2. 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).
  3. 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 :

  1. installer deux fois sur le même poste ne consomme qu'une licence ;
  2. un logiciel libre accepte 50 installations ;
  3. un nom vide ou fait d'espaces est refusé (ArgumentException), avec un [Theory].
Voir la réponseMasquer la réponse de l'exercice 1
csharp
[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
ValeurAttenduPourquoi
45acceptéecas nominal
1acceptéeborne basse, juste dedans
0ArgumentOutOfRangeExceptionborne basse, juste dehors
480acceptéeborne haute, juste dedans
481ArgumentOutOfRangeExceptionborne haute, juste dehors
−10ArgumentOutOfRangeExceptionvaleur 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
csharp
[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é ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : POO à l'examen : méthode, exercices corrigés et aide-mémoireRevoir le sommaire du cours