Chapitre 1322 minIntermédiaire
Tests unitaires en C# avec MSTest (ou xUnit)
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- créer un projet de test et le relier à la couche métier ;
- écrire un test lisible en trois temps ;
- tester plusieurs cas avec DataRow ;
- tester un service et une règle d'accès sans base de données grâce à un faux dépôt ;
- lancer les tests dans Visual Studio et en ligne de commande.
5 exercices corrigés
Un test unitaire est une petite méthode qui exécute un morceau de code (une méthode, une règle) avec des données choisies et vérifie automatiquement que le résultat est celui attendu. En C#, on les écrit dans un projet de test, avec un framework comme MSTest (celui de Microsoft, intégré à Visual Studio) ou xUnit. Dans une application WinForms, on teste avant tout la couche métier : c'est là que vivent les règles, et c'est elle que l'architecture en couches a rendue testable sans fenêtre ni base de données. La démarche générale (pourquoi tester, types de tests, TDD) est dans le cours Tests.
Pourquoi tester la couche métier, et pas les fenêtres
| Ce qu'on teste | Avec quoi | Intérêt |
|---|---|---|
Les règles du métier (Patient, Ordonnance, PatientService) | Tests unitaires | Rapides (millisecondes), fiables, lancés à chaque modification |
| Les DAO et les requêtes | Tests d'intégration, avec une vraie base de test | Vérifient le SQL et la correspondance des colonnes |
| Les fenêtres | Tests manuels (cahier de recette), outils d'automatisation d'interface | Coûteux à automatiser, fragiles quand le dessin change |
Grâce au chapitre 8, la règle « deux patients ne peuvent pas avoir le même numéro de sécurité sociale » est dans PatientService, qui ne dépend que de l'interface IPatientRepository : on peut la tester avec un faux dépôt en mémoire, en quelques millisecondes.
Créer le projet de test
Dans Visual Studio : clic droit sur la solution > Ajouter > Nouveau projet > Projet de test MSTest, nommé Gsb.Tests, en .NET 10. Ajoute ensuite une référence au projet Gsb.Metier. En ligne de commande :
dotnet new mstest -n Gsb.Tests -o Gsb.Tests
dotnet sln add Gsb.Tests
dotnet add Gsb.Tests reference Gsb.Metier
Le projet référence le paquet MSTest (version 4) et importe d'office l'espace de noms Microsoft.VisualStudio.TestTools.UnitTesting. Il ne référence ni Gsb.Ui ni Gsb.Donnees : les tests unitaires n'ont besoin ni de fenêtre ni de MySQL.
Un premier test : Arrange, Act, Assert
Une classe de test porte l'attribut [TestClass], chaque test l'attribut [TestMethod]. Un test bien écrit se lit en trois temps, le schéma AAA : préparer (Arrange), agir (Act), vérifier (Assert).
using Gsb.Metier;
namespace Gsb.Tests;
[TestClass]
public sealed class PatientTests
{
[TestMethod]
public void Age_VeilleDeLAnniversaire_RenvoieLAgeSansCompterLAnnee()
{
// Arrange
var patient = new Patient { Nom = "Bernard", DateNaissance = new DateTime(2008, 11, 3) };
// Act
int? age = patient.Age(new DateTime(2026, 11, 2));
// Assert
Assert.AreEqual(17, age);
}
}
Le nom du test décrit le cas : la méthode testée, la situation, le résultat attendu. Quand un test échoue parmi cinquante, son nom doit suffire à comprendre ce qui est cassé.
La méthode testée, dans la classe Patient :
public int? Age(DateTime aujourdhui)
{
if (DateNaissance is not DateTime naissance) return null; // date inconnue
int age = aujourdhui.Year - naissance.Year;
if (aujourdhui.Date < naissance.AddYears(age)) age--; // anniversaire pas encore passé cette année
return age;
}
Elle reçoit la date du jour en paramètre au lieu de lire DateTime.Today elle-même : c'est ce qui la rend testable. Un test qui dépend de la date du jour passerait aujourd'hui et échouerait demain.
Les assertions courantes
| Assertion MSTest | Vérifie que… |
|---|---|
Assert.AreEqual(attendu, obtenu) | les deux valeurs sont égales (attention à l'ordre : l'attendu d'abord) |
Assert.AreNotEqual(a, b) | elles sont différentes |
Assert.IsTrue(condition), Assert.IsFalse(condition) | la condition est vraie, fausse |
Assert.IsNull(valeur), Assert.IsNotNull(valeur) | la valeur est (ou n'est pas) null |
Assert.HasCount(2, liste) | la collection contient 2 éléments |
Assert.ThrowsExactly<T>(() => …) | le code lève exactement une exception de type T ; renvoie l'exception |
await Assert.ThrowsExactlyAsync<T>(() => …) | la même chose pour du code asynchrone |
Tester qu'une règle refuse une valeur interdite est aussi important que tester qu'elle accepte les bonnes :
[TestMethod]
public void AjouterLigne_MemeMedicamentDeuxFois_EstRefusee()
{
var ordonnance = new Ordonnance();
ordonnance.AjouterLigne(new LigneOrdonnance("PARA1000", "1 cp 3 fois par jour", 5));
Assert.ThrowsExactly<RegleMetierException>(
() => ordonnance.AjouterLigne(new LigneOrdonnance("PARA1000", "2 cp le soir", 3)));
Assert.HasCount(1, ordonnance.Lignes); // la ligne refusée n'a pas été ajoutée
}
Plusieurs cas avec DataRow
Pour tester la même règle avec plusieurs jeux de données, [DataRow] passe des valeurs en paramètres. Chaque ligne devient un cas de test distinct dans les résultats.
[TestMethod]
[DataRow(2026, 11, 3, 18)] // le jour de l'anniversaire
[DataRow(2026, 12, 31, 18)]
[DataRow(2027, 1, 1, 18)]
[DataRow(2026, 1, 1, 17)]
public void Age_SelonLaDate_RenvoieLAgeAttendu(int annee, int mois, int jour, int attendu)
{
var patient = new Patient { Nom = "Bernard", DateNaissance = new DateTime(2008, 11, 3) };
Assert.AreEqual(attendu, patient.Age(new DateTime(annee, mois, jour)));
}
[TestMethod]
[DataRow(0)]
[DataRow(91)]
public void AjouterLigne_DureeHorsLimites_EstRefusee(int duree)
{
var ordonnance = new Ordonnance();
var ex = Assert.ThrowsExactly<RegleMetierException>(
() => ordonnance.AjouterLigne(new LigneOrdonnance("PARA1000", "1 cp 3 fois par jour", duree)));
Assert.AreEqual("La durée doit être comprise entre 1 et 90 jours.", ex.Message);
}
Choisis les valeurs aux limites : 0 et 91 encadrent l'intervalle autorisé de 1 à 90. Une erreur de < à la place de <= se cache presque toujours à une limite. Ajoute aussi les cas 1 et 90, qui doivent être acceptés.
Tester un service sans base : le faux dépôt
PatientService a besoin d'un IPatientRepository. Dans les tests, on lui donne une implémentation en mémoire, écrite en quelques lignes, qui compte aussi les appels pour pouvoir les vérifier :
internal class FauxDepotPatients : IPatientRepository
{
public List<Patient> Patients { get; } = [];
public int NombreAjouts { get; private set; }
public Task<List<Patient>> ListerAsync() => Task.FromResult(Patients.ToList());
public Task<Patient?> TrouverAsync(int numPatient) =>
Task.FromResult(Patients.FirstOrDefault(p => p.NumPatient == numPatient));
public Task<bool> NumeroSecuExisteAsync(string numeroSecu, int saufNumPatient) =>
Task.FromResult(Patients.Any(p => p.NumeroSecu == numeroSecu && p.NumPatient != saufNumPatient));
public Task<int> AjouterAsync(Patient patient)
{
NombreAjouts++;
patient.NumPatient = Patients.Count + 1;
Patients.Add(patient);
return Task.FromResult(patient.NumPatient);
}
public Task ModifierAsync(Patient patient) => Task.CompletedTask;
public Task SupprimerAsync(int numPatient)
{
Patients.RemoveAll(p => p.NumPatient == numPatient);
return Task.CompletedTask;
}
}
Les tests du service deviennent asynchrones : la méthode renvoie async Task, et MSTest attend sa fin.
[TestClass]
public sealed class PatientServiceTests
{
[TestMethod]
public async Task Enregistrer_NumeroSecuDejaPris_EstRefuseSansRienAjouter()
{
var depot = new FauxDepotPatients();
depot.Patients.Add(new Patient { NumPatient = 1, Nom = "Dupont", NumeroSecu = "2900575123456" });
var service = new PatientService(depot);
await Assert.ThrowsExactlyAsync<RegleMetierException>(
() => service.EnregistrerAsync(new Patient { Nom = "Durand", NumeroSecu = "2900575123456" }));
Assert.AreEqual(0, depot.NombreAjouts);
}
[TestMethod]
public async Task Enregistrer_ModificationDuMemePatient_GardeSonNumeroSecu()
{
var depot = new FauxDepotPatients();
depot.Patients.Add(new Patient { NumPatient = 1, Nom = "Dupont", NumeroSecu = "2900575123456" });
var service = new PatientService(depot);
await service.EnregistrerAsync(new Patient { NumPatient = 1, Nom = "Dupont-Moreau", NumeroSecu = "2900575123456" });
Assert.AreEqual(0, depot.NombreAjouts); // une modification, pas un ajout, et pas de refus
}
}
Le second test protège un bug facile à introduire : si l'on oubliait le paramètre saufNumPatient, modifier le nom de Marie Dupont serait refusé, puisque son propre numéro existe déjà. Des bibliothèques comme Moq ou NSubstitute créent ces faux objets automatiquement ; pour quelques interfaces, une classe écrite à la main est plus lisible.
Tester OrdonnanceService avec un faux dépôt
La règle « seul un médecin crée une ordonnance » (chapitre 12) est une règle de sécurité : elle mérite ses tests, comme une règle de gestion. OrdonnanceService (chapitre 8) dépend de IOrdonnanceRepository et de l'utilisateur connecté : le test fournit les deux. Le faux dépôt garde les ordonnances reçues dans une liste, ce qui permet de vérifier qu'une ordonnance refusée n'est jamais arrivée jusqu'à lui.
internal class FauxDepotOrdonnances : IOrdonnanceRepository
{
public List<Ordonnance> Enregistrees { get; } = [];
public Task<List<OrdonnanceLisible>> ListerLisiblesAsync() => Task.FromResult(new List<OrdonnanceLisible>());
public Task<int> EnregistrerAsync(Ordonnance ordonnance)
{
Enregistrees.Add(ordonnance);
ordonnance.NumOrdonnance = Enregistrees.Count;
return Task.FromResult(ordonnance.NumOrdonnance);
}
}
[TestClass]
public sealed class OrdonnanceServiceTests
{
private static Ordonnance OrdonnanceComplete()
{
var ordonnance = new Ordonnance { NumMedecin = 1, NumPatient = 1 };
ordonnance.AjouterLigne(new LigneOrdonnance("PARA1000", "1 cp 3 fois par jour", 5));
return ordonnance;
}
[TestMethod]
public async Task Enregistrer_ParUneSecretaire_EstRefuseSansRienEnregistrer()
{
var depot = new FauxDepotOrdonnances();
var secretaire = new Utilisateur { Login = "s.leroy", Role = "secretaire" };
var service = new OrdonnanceService(depot, secretaire);
var ex = await Assert.ThrowsExactlyAsync<RegleMetierException>(
() => service.EnregistrerAsync(OrdonnanceComplete()));
Assert.AreEqual("Seul un médecin peut créer une ordonnance.", ex.Message);
Assert.IsEmpty(depot.Enregistrees); // rien n'est parti vers la base
}
[TestMethod]
public async Task Enregistrer_ParUnMedecin_TransmetAuDepot()
{
var depot = new FauxDepotOrdonnances();
var medecin = new Utilisateur { Login = "p.martin", Role = "medecin" };
var service = new OrdonnanceService(depot, medecin);
int numero = await service.EnregistrerAsync(OrdonnanceComplete());
Assert.AreEqual(1, numero);
Assert.HasCount(1, depot.Enregistrees);
}
[TestMethod]
public async Task Enregistrer_SansMedicament_EstRefusee()
{
var depot = new FauxDepotOrdonnances();
var service = new OrdonnanceService(depot, new Utilisateur { Login = "p.martin", Role = "medecin" });
await Assert.ThrowsExactlyAsync<RegleMetierException>(
() => service.EnregistrerAsync(new Ordonnance { NumMedecin = 1, NumPatient = 1 }));
Assert.IsEmpty(depot.Enregistrees);
}
}
Teste toujours les deux côtés d'une règle d'accès : celui qui a le droit passe, celui qui ne l'a pas est refusé. Un test qui ne vérifierait que le refus passerait aussi avec un service qui refuse tout le monde. La méthode OrdonnanceComplete() évite de recopier la préparation dans chaque test : quand l'Arrange se répète, range-le dans une méthode de la classe de test.
Lancer les tests
Dans Visual Studio, ouvre l'Explorateur de tests (menu Test) et clique sur Exécuter tout (Ctrl + R, A). Chaque test apparaît en vert ou en rouge ; un clic sur un test rouge affiche la valeur attendue, la valeur obtenue et la ligne fautive. En ligne de commande, depuis le dossier de la solution :
dotnet test
Réussi! - échec : 0, réussite : 26, ignorée(s) : 0, total : 26, durée : 40 ms - Gsb.Tests.dll (net10.0)
La même commande s'exécute dans une chaîne d'intégration continue à chaque git push : un test rouge bloque la fusion (tests automatisés dans la CI).
Et xUnit ?
xUnit est l'autre framework très répandu, notamment dans les projets libres de l'écosystème .NET. Les idées sont identiques, seuls les noms changent :
| MSTest 4 | xUnit v3 |
|---|---|
[TestClass] sur la classe | rien : toute classe publique avec des tests |
[TestMethod] | [Fact] |
[TestMethod] + [DataRow(…)] | [Theory] + [InlineData(…)] |
Assert.AreEqual(attendu, obtenu) | Assert.Equal(attendu, obtenu) |
Assert.IsTrue(c) | Assert.True(c) |
Assert.IsNull(v) | Assert.Null(v) |
Assert.ThrowsExactly<T>(…) | Assert.Throws<T>(…) |
[TestInitialize] | le constructeur de la classe |
Choisis celui que ton équipe ou ton enseignant utilise ; l'essentiel est que tes règles métier soient couvertes, et que tu saches montrer les tests à la commission.
Exercices
Exercice 1 sur 5 Tester la clé du numéro de sécurité sociale Réussi À revoir
La classe Nir calcule la clé de contrôle d'un numéro de sécurité sociale : 97 − (le nombre formé par les 13 chiffres modulo 97). Écris un test [DataRow] pour Nir.Cle(string) avec les numéros 1891278123456 (clé 5) et 2900575123456, dont tu calcules la clé, puis un test qui vérifie qu'un texte de 12 chiffres lève une ArgumentException.
Voir la réponseMasquer la réponse de l'exercice 1
2 900 575 123 456 modulo 97 donne 28, donc la clé vaut 97 − 28 = 69.
[TestClass]
public sealed class NirTests
{
[TestMethod]
[DataRow("1891278123456", 5)]
[DataRow("2900575123456", 69)]
public void Cle_RenvoieLaCleDeControle(string nir, int cle)
{
Assert.AreEqual(cle, Nir.Cle(nir));
}
[TestMethod]
public void Cle_DouzeChiffres_LeveArgumentException()
{
Assert.ThrowsExactly<ArgumentException>(() => Nir.Cle("189127812345"));
}
}
Le calcul porte sur un nombre de 13 chiffres, qui dépasse la capacité d'un int : la classe le convertit en long. Les numéros des personnes nées en Corse (départements 2A et 2B) suivent une règle particulière, hors du champ de cet exercice.
Tu avais trouvé ?
Exercice 2 sur 5 Le test qui trouve le bug Réussi À revoir
Un développeur a simplifié la méthode Age ainsi : return aujourdhui.Year - naissance.Year;. Lequel des tests du chapitre échoue, et avec quel message ? Que montre cet exemple ?
Voir la réponseMasquer la réponse de l'exercice 2
Le test Age_VeilleDeLAnniversaire_RenvoieLAgeSansCompterLAnnee échoue : 2026 − 2008 = 18, alors que Hugo Bernard n'a que 17 ans le 2 novembre 2026, la veille de son anniversaire. Le message de MSTest indique la valeur attendue (17) et la valeur obtenue (18). Deux cas [DataRow] échouent aussi : (2026, 1, 1, 17), qui obtient 18, et (2027, 1, 1, 18), qui obtient 19. Les deux autres, le jour de l'anniversaire et le 31 décembre, passent.
L'exemple montre l'intérêt des cas aux limites : la version simplifiée n'est juste qu'entre l'anniversaire et la fin de l'année, soit à peine deux mois sur douze pour Hugo. Un test lancé seulement en décembre ne l'aurait pas vu.
Tu avais trouvé ?
Exercice 3 sur 5 La liste noire Réussi À revoir
Ton binôme a réécrit la vérification d'OrdonnanceService.EnregistrerAsync ainsi : if (connecte.Role == "secretaire") throw new RegleMetierException(…);. Les trois tests d'OrdonnanceServiceTests passent-ils toujours ? Quel cas n'est plus protégé ? Écris le test qui le montre.
Voir la réponseMasquer la réponse de l'exercice 3
Les trois tests passent toujours : la secrétaire est refusée, le médecin passe, l'ordonnance vide est refusée par VerifierAvantEnregistrement. Mais tout rôle autre que "secretaire" peut maintenant créer une ordonnance : un compte au rôle vide (mal configuré), un futur rôle "comptable", ou "Secretaire" avec une majuscule.
[TestMethod]
public async Task Enregistrer_RoleVide_EstRefuse()
{
var depot = new FauxDepotOrdonnances();
var service = new OrdonnanceService(depot, new Utilisateur { Login = "stagiaire", Role = "" });
await Assert.ThrowsExactlyAsync<RegleMetierException>(
() => service.EnregistrerAsync(OrdonnanceComplete()));
Assert.IsEmpty(depot.Enregistrees);
}
Avec la version du binôme, ce test échoue : aucune exception n'est levée, et l'ordonnance arrive dans le dépôt. La bonne pratique est la liste blanche : on autorise explicitement ce qui est permis (Role != "medecin" → refus) et tout le reste est refusé par défaut.
Tu avais trouvé ?
Exercice 4 sur 5 Que tester ? Réussi À revoir
Pour la règle « une ordonnance doit contenir au moins un médicament, et avoir un médecin et un patient », liste les cas de test à écrire pour VerifierAvantEnregistrement.
Voir la réponseMasquer la réponse de l'exercice 4
- Ordonnance complète (médecin, patient, une ligne) : aucune exception.
- Sans aucune ligne :
RegleMetierException(« au moins un médicament »). - Sans médecin (
NumMedecin= 0) :RegleMetierException. - Sans patient (
NumPatient= 0) :RegleMetierException. - Avec plusieurs lignes : aucune exception (le cas « plusieurs » est différent du cas « un »).
Chaque cas est un test séparé, au nom explicite, pour savoir immédiatement lequel casse.
Tu avais trouvé ?
Exercice 5 sur 5 Un test fragile Réussi À revoir
Pourquoi ce test passe-t-il aujourd'hui et risque-t-il d'échouer plus tard ? Corrige-le.
[TestMethod]
public void Age_HugoBernard()
{
var patient = new Patient { Nom = "Bernard", DateNaissance = new DateTime(2008, 11, 3) };
Assert.AreEqual(17, patient.Age(DateTime.Today));
}
Voir la réponseMasquer la réponse de l'exercice 5
Le test dépend de la date du jour : il passe jusqu'au 2 novembre 2026 inclus, puis échoue à partir du 3 novembre 2026, quand Hugo a 18 ans, alors que le code n'a pas changé. Un test doit donner le même résultat chaque fois qu'on le lance. On fixe la date dans le test :
[TestMethod]
public void Age_LaVeilleDeSes18Ans_Renvoie17()
{
var patient = new Patient { Nom = "Bernard", DateNaissance = new DateTime(2008, 11, 3) };
Assert.AreEqual(17, patient.Age(new DateTime(2026, 11, 2)));
}
Tu avais trouvé ?