Chapitre 527 minIntermédiaire
Tests unitaires en C# avec xUnit
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- créer une solution .NET avec un projet de test xUnit ;
- écrire des tests avec [Fact], [Theory] et la classe Assert ;
- maîtriser la date d'un test avec TimeProvider ;
- reconnaître les équivalents MSTest et NUnit.
3 exercices corrigés
xUnit est le framework de test unitaire le plus utilisé pour C# et .NET : on écrit les tests dans un projet séparé, on marque chaque méthode de test avec [Fact] ou [Theory], on vérifie les résultats avec la classe Assert, et la commande dotnet test exécute tout. Ce chapitre teste les règles de Prêt'Matos écrites en C# avec .NET 10 et xUnit v3. Si tu découvres les classes, les constructeurs et les interfaces, commence par le cours programmation orientée objet.
xUnit, NUnit ou MSTest ?
Trois frameworks coexistent en .NET. Ils font la même chose avec des attributs différents ; tu dois savoir les reconnaître, car tu les croiseras en stage.
| xUnit | NUnit | MSTest | |
|---|---|---|---|
| Test simple | [Fact] | [Test] | [TestMethod] (dans une classe [TestClass]) |
| Test paramétré | [Theory] + [InlineData(…)] | [TestCase(…)] | [DataRow(…)] |
| Préparation avant chaque test | le constructeur de la classe | [SetUp] | [TestInitialize] |
| Nettoyage après chaque test | Dispose() (interface IDisposable) | [TearDown] | [TestCleanup] |
| Égalité | Assert.Equal(attendu, obtenu) | Assert.That(obtenu, Is.EqualTo(attendu)) | Assert.AreEqual(attendu, obtenu) |
| Version actuelle | xUnit v3 | NUnit 5 | MSTest 4 |
xUnit a une particularité qui explique son succès : il crée une nouvelle instance de la classe de test pour chaque test. Les tests sont donc isolés les uns des autres par construction, sans réglage. C'est le framework utilisé par les équipes de .NET elles-mêmes et celui de ce cours.
Créer la solution et le projet de test
Le code métier et les tests vivent dans deux projets d'une même solution : une bibliothèque de classes PretMatos.Domaine et un projet de test PretMatos.Tests qui y fait référence. Le projet de test n'est jamais livré en production.
# Une solution et la bibliothèque de classes qui contient les règles métier
dotnet new sln -n PretMatos
dotnet new classlib -o PretMatos.Domaine
# Les modèles de projet de xUnit v3 (une seule fois par machine)
dotnet new install xunit.v3.templates
# Le projet de test, sa référence au code testé, et l'ajout des deux projets à la solution
dotnet new xunit3 -o PretMatos.Tests --framework net10.0 --test-runner vstest
dotnet add PretMatos.Tests reference PretMatos.Domaine
dotnet sln add PretMatos.Domaine PretMatos.Tests
Depuis .NET 10, dotnet new sln crée un fichier PretMatos.slnx, le nouveau format de solution en XML, plus lisible que l'ancien .sln. Visual Studio, Rider et VS Code l'ouvrent tous les trois.
L'option --test-runner vstest choisit le moteur d'exécution classique de dotnet test, celui qui fonctionne partout sans réglage supplémentaire. Le projet de test obtenu ressemble à ceci :
<!-- PretMatos.Tests/PretMatos.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1" />
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PretMatos.Domaine\PretMatos.Domaine.csproj" />
</ItemGroup>
</Project>
| Élément | Rôle |
|---|---|
<OutputType>Exe</OutputType> | Avec xUnit v3, un projet de test est un programme exécutable : il contient son propre lanceur |
<Using Include="Xunit" /> | Importe l'espace de noms Xunit dans tous les fichiers : [Fact] et Assert sont disponibles sans using |
Microsoft.NET.Test.Sdk et xunit.runner.visualstudio | Permettent à dotnet test et à l'explorateur de tests de Visual Studio de trouver et de lancer les tests |
xunit.v3.mtp-off | Le paquet de xUnit v3 lui-même, dans sa variante pour le moteur classique |
ProjectReference | Le projet de test voit les classes publiques de PretMatos.Domaine |
Les classes à tester
Les règles de Prêt'Matos, en C#. Une Periode refuse d'exister si sa fin précède son début : c'est l'objet lui-même qui garantit sa cohérence, ce que le cours POO appelle l'encapsulation.
// PretMatos.Domaine/Periode.cs
using System.Globalization;
namespace PretMatos.Domaine;
public class Periode
{
public DateOnly Debut { get; }
public DateOnly Fin { get; }
public Periode(DateOnly debut, DateOnly fin)
{
if (fin < debut)
{
throw new ArgumentException("La date de fin précède la date de début.");
}
Debut = debut;
Fin = fin;
}
// Du 12 au 18 mars : 7 jours, le premier et le dernier compris.
public int DureeEnJours => Fin.DayNumber - Debut.DayNumber + 1;
public bool Chevauche(Periode autre) => Debut <= autre.Fin && autre.Debut <= Fin;
// La culture invariante garde le séparateur « / » quelle que soit la langue de la machine.
public override string ToString() =>
string.Create(CultureInfo.InvariantCulture, $"du {Debut:dd/MM/yyyy} au {Fin:dd/MM/yyyy}");
}
DateOnly, disponible depuis .NET 6, représente une date sans heure : exactement ce qu'il faut pour un prêt à la journée. Sa propriété DayNumber donne le numéro du jour depuis le 1er janvier de l'an 1, ce qui rend le calcul de durée trivial.
Pourquoi CultureInfo.InvariantCulture dans ToString() ? Dans un format de date .NET, le / n'est pas un caractère fixe : il est remplacé par le séparateur de dates de la culture de la machine. Sur un poste réglé en français, on obtient 10/03/2027 ; sur un serveur de CI réglé en allemand, 10.03.2027, et le test qui attend des barres échoue. Un test doit donner le même résultat sur toutes les machines (le R de FIRST, au chapitre 3) : on fixe donc la culture dans le code.
// PretMatos.Domaine/VerificateurReservation.cs
namespace PretMatos.Domaine;
public record Reservation(string Materiel, Periode Periode);
public record Verification(bool Acceptee, string? Motif = null);
public class VerificateurReservation
{
public const int DureeMaxJours = 15;
private readonly TimeProvider _horloge;
public VerificateurReservation(TimeProvider horloge)
{
_horloge = horloge;
}
public Verification Verifier(Reservation demande, IEnumerable<Reservation> existantes)
{
DateOnly aujourdhui = DateOnly.FromDateTime(_horloge.GetLocalNow().DateTime);
if (demande.Periode.Debut < aujourdhui)
{
return new Verification(false, "La date de début est déjà passée.");
}
if (demande.Periode.DureeEnJours > DureeMaxJours)
{
return new Verification(false, $"Un prêt dure au plus {DureeMaxJours} jours.");
}
Reservation? conflit = existantes.FirstOrDefault(
r => r.Materiel == demande.Materiel && r.Periode.Chevauche(demande.Periode));
if (conflit is not null)
{
return new Verification(false, $"{demande.Materiel} est déjà réservé {conflit.Periode}.");
}
return new Verification(true);
}
}
Le vérificateur ne lit pas DateTime.Now : il reçoit un TimeProvider, l'abstraction de l'horloge fournie par .NET depuis la version 8. En production, on lui passe TimeProvider.System, la vraie horloge ; dans les tests, une horloge simulée. C'est l'injection de dépendances du chapitre 3, à la manière de .NET.
[Fact] : un test simple
// PretMatos.Tests/PeriodeTests.cs
using PretMatos.Domaine;
namespace PretMatos.Tests;
public class PeriodeTests
{
[Fact]
public void DureeEnJours_DuDouzeAuDixHuit_VautSept()
{
// Arrange
var periode = new Periode(new DateOnly(2027, 3, 12), new DateOnly(2027, 3, 18));
// Act
int duree = periode.DureeEnJours;
// Assert
Assert.Equal(7, duree);
}
}
Une classe de test est une classe publique ordinaire ; chaque méthode publique marquée [Fact] est un test. Le nom suit la convention Methode_Situation_ResultatAttendu, qui se lit comme une phrase dans le rapport : « DureeEnJours, du douze au dix-huit, vaut sept ».
Les assertions de xUnit
| Assertion | Vérifie que… |
|---|---|
Assert.Equal(attendu, obtenu) | les deux valeurs sont égales (pour des objets, selon leur méthode Equals ; les record comparent leurs propriétés) |
Assert.NotEqual(a, b) | les deux valeurs diffèrent |
Assert.True(condition), Assert.False(condition) | la condition est vraie, ou fausse |
Assert.Null(objet), Assert.NotNull(objet) | la référence est nulle, ou non |
Assert.Throws<TException>(() => …) | l'appel lève exactement une exception de ce type, et la renvoie pour qu'on examine son message |
Assert.Contains(element, collection) | la collection contient l'élément (ou la chaîne, la sous-chaîne) |
Assert.Empty(collection) | la collection est vide |
Assert.Single(collection) | la collection contient un seul élément, et le renvoie |
Assert.InRange(valeur, min, max) | la valeur est comprise entre deux bornes |
Assert.Equal(0.3, total, 2) | deux double sont égaux à 2 décimales près |
Tester une exception
[Fact]
public void Constructeur_FinAvantDebut_LeveArgumentException()
{
var exception = Assert.Throws<ArgumentException>(
() => new Periode(new DateOnly(2027, 3, 20), new DateOnly(2027, 3, 19)));
Assert.Equal("La date de fin précède la date de début.", exception.Message);
}
Comme en JavaScript, on passe une expression lambda (() => …) qui fait l'appel : Assert.Throws l'exécute et attrape l'exception. Assert.Throws<ArgumentException> exige le type exact : une ArgumentNullException, qui hérite pourtant d'ArgumentException, ferait échouer le test. Pour accepter les classes dérivées, utilise Assert.ThrowsAny<ArgumentException>.
[Theory] et [InlineData] : les tests paramétrés
Un [Theory] est un test qui reçoit des paramètres ; chaque attribut [InlineData] fournit un jeu de valeurs et produit un test distinct dans le rapport.
[Theory]
[InlineData(12, 18, true)] // commence pendant
[InlineData(5, 10, true)] // finit le premier jour
[InlineData(15, 20, false)] // commence le lendemain
[InlineData(1, 9, false)] // finit la veille
public void Chevauche_ComparaisonAvecDixQuatorze(int debut, int fin, bool attendu)
{
var pc07 = new Periode(new DateOnly(2027, 3, 10), new DateOnly(2027, 3, 14));
var autre = new Periode(new DateOnly(2027, 3, debut), new DateOnly(2027, 3, fin));
Assert.Equal(attendu, pc07.Chevauche(autre));
}
Les valeurs de [InlineData] doivent être des constantes : nombres, chaînes, booléens, null. On ne peut pas y écrire new DateOnly(…), d'où le choix de passer les jours en entiers. Pour des données plus complexes, xUnit propose [MemberData(nameof(Cas))], qui lit les jeux de valeurs dans une propriété statique de la classe.
Préparer le contexte : le constructeur
xUnit appelle le constructeur de la classe de test avant chaque test, sur une instance neuve. C'est l'équivalent du beforeEach de Vitest :
// PretMatos.Tests/VerificateurReservationTests.cs
using Microsoft.Extensions.Time.Testing;
using PretMatos.Domaine;
namespace PretMatos.Tests;
public class VerificateurReservationTests
{
private readonly VerificateurReservation _verificateur;
private readonly List<Reservation> _existantes;
// xUnit crée une nouvelle instance de la classe pour chaque test :
// le constructeur prépare donc un contexte neuf à chaque fois.
public VerificateurReservationTests()
{
var horloge = new FakeTimeProvider(new DateTimeOffset(2027, 3, 8, 9, 0, 0, TimeSpan.Zero));
_verificateur = new VerificateurReservation(horloge);
_existantes = [Reserver("PC-07", 10, 14)];
}
private static Reservation Reserver(string materiel, int jourDebut, int jourFin) =>
new(materiel, new Periode(new DateOnly(2027, 3, jourDebut), new DateOnly(2027, 3, jourFin)));
[Fact]
public void Verifier_MaterielLibre_Accepte()
{
Verification resultat = _verificateur.Verifier(Reserver("PC-07", 15, 18), _existantes);
Assert.True(resultat.Acceptee);
}
[Fact]
public void Verifier_MaterielDejaReserve_RefuseAvecLesDates()
{
Verification resultat = _verificateur.Verifier(Reserver("PC-07", 12, 18), _existantes);
Assert.False(resultat.Acceptee);
Assert.Equal("PC-07 est déjà réservé du 10/03/2027 au 14/03/2027.", resultat.Motif);
}
[Fact]
public void Verifier_DebutPasse_Refuse()
{
Verification resultat = _verificateur.Verifier(Reserver("VP-02", 7, 9), _existantes);
Assert.Equal("La date de début est déjà passée.", resultat.Motif);
}
[Theory]
[InlineData(15, 29, true)] // 15 jours : la limite, acceptée
[InlineData(15, 30, false)] // 16 jours : refusée
public void Verifier_DureeMaximale(int jourDebut, int jourFin, bool acceptee)
{
Verification resultat = _verificateur.Verifier(Reserver("VP-02", jourDebut, jourFin), _existantes);
Assert.Equal(acceptee, resultat.Acceptee);
}
}
Trois choses à remarquer :
FakeTimeProviderest une horloge simulée fournie par Microsoft dans le paquetMicrosoft.Extensions.TimeProvider.Testing(dotnet add PretMatos.Tests package Microsoft.Extensions.TimeProvider.Testing). Elle est figée au 8 mars 2027 à 9 h ;horloge.Advance(TimeSpan.FromDays(1))la ferait avancer d'un jour. Son fuseau local par défaut est UTC, ce qui rend le test identique sur toutes les machines.- La méthode privée
Reserverest une fabrique : elle évite de répéternew Periode(new DateOnly(…), …)dans chaque test et rend les tests lisibles. _existantes = [Reserver("PC-07", 10, 14)]utilise une expression de collection, la syntaxe des crochets disponible depuis C# 12.
Si une classe de test ouvre une ressource (un fichier, une connexion), elle implémente IDisposable : xUnit appelle Dispose() après chaque test. Pour une ressource coûteuse à partager entre tous les tests d'une classe, comme une base de test, xUnit propose les fixtures de classe (IClassFixture<T>).
Lancer les tests
$ dotnet test
PretMatos.Domaine -> …/PretMatos.Domaine/bin/Debug/net10.0/PretMatos.Domaine.dll
PretMatos.Tests -> …/PretMatos.Tests/bin/Debug/net10.0/PretMatos.Tests.dll
Série de tests pour …/PretMatos.Tests.dll (.NETCoreApp,Version=v10.0)
Réussi! - échec : 0, réussite : 11, ignorée(s) : 0, total : 11, durée : 48 ms - PretMatos.Tests.dll (net10.0)
dotnet test compile la solution, trouve les projets de test et lance tous les tests. Le bilan s'affiche dans la langue du SDK installé : « Passed! » sur une machine en anglais. Les 11 tests comptent chaque ligne [InlineData] comme un test. Pour n'en lancer qu'une partie : dotnet test --filter "FullyQualifiedName~Periode".
Dans Visual Studio, le menu Test, Explorateur de tests affiche l'arbre des tests avec un bouton pour les lancer ou les déboguer un par un. Dans VS Code, l'extension C# Dev Kit ajoute un panneau Testing et des boutons « Run Test » au-dessus de chaque méthode. Déboguer un test (point d'arrêt dans le code métier, puis Debug Test) est souvent le moyen le plus rapide de comprendre un bug.
Lire un échec
Si l'on oublie le + 1 de DureeEnJours, deux tests échouent :
[xUnit.net 00:00:00.48] PretMatos.Tests.PeriodeTests.DureeEnJours_DuDouzeAuDixHuit_VautSept [FAIL]
[xUnit.net 00:00:00.48] PretMatos.Tests.VerificateurReservationTests.Verifier_DureeMaximale(jourDebut: 15, jourFin: 30, acceptee: False) [FAIL]
Échoué PretMatos.Tests.PeriodeTests.DureeEnJours_DuDouzeAuDixHuit_VautSept [9 ms]
Message d'erreur :
Assert.Equal() Failure: Values differ
Expected: 7
Actual: 6
Échoué! - échec : 2, réussite : 9, ignorée(s) : 0, total : 11
Le premier échec désigne le calcul. Le second est plus instructif : sans le + 1, un prêt du 15 au 30 mars compte 15 jours au lieu de 16, et il est accepté à tort. Le test de la valeur limite a vu la conséquence métier du bug. Remarque aussi que le nom d'un [Theory] en échec affiche les valeurs de ses paramètres : tu sais exactement quelle ligne [InlineData] a cassé.
Et une application WinForms ou WPF ?
On ne teste pas une fenêtre avec xUnit. On sort la logique de la fenêtre (le formulaire de réservation) dans des classes ordinaires, comme VerificateurReservation, et le code de la fenêtre se contente d'afficher et de transmettre : c'est l'idée des architectures MVC et MVVM. Les classes métier se testent avec xUnit ; l'interface se vérifie à la main ou par des tests d'interface spécialisés. Plus le code des fenêtres est mince, plus ton application est testable. Le cours C# et WinForms montre cette séparation (architecture en couches en WinForms).
Exercices
Exercice 1 sur 3 Écrire les tests d'une méthode C# Réussi À revoir
L'équipe ajoute une méthode à Periode :
public Periode Prolonger(int jours)
{
if (jours < 1 || jours > 7)
{
throw new ArgumentOutOfRangeException(nameof(jours), "Une prolongation dure de 1 à 7 jours.");
}
return new Periode(Debut, Fin.AddDays(jours));
}
Écris avec xUnit : un [Fact] qui vérifie la nouvelle date de fin d'une prolongation de 3 jours d'un prêt du 10 au 14 mars ; un [Theory] qui vérifie que 0 et 8 jours lèvent l'exception ; un [Theory] qui vérifie que 1 et 7 jours sont acceptés.
Voir la réponseMasquer la réponse de l'exercice 1
public class ProlongationTests
{
private readonly Periode _pret = new(new DateOnly(2027, 3, 10), new DateOnly(2027, 3, 14));
[Fact]
public void Prolonger_DeTroisJours_RepousseLaFinAuDixSept()
{
Periode prolongee = _pret.Prolonger(3);
Assert.Equal(new DateOnly(2027, 3, 17), prolongee.Fin);
Assert.Equal(new DateOnly(2027, 3, 10), prolongee.Debut);
}
[Theory]
[InlineData(0)]
[InlineData(8)]
public void Prolonger_HorsDeUnASept_LeveUneException(int jours)
{
Assert.Throws<ArgumentOutOfRangeException>(() => _pret.Prolonger(jours));
}
[Theory]
[InlineData(1, 15)]
[InlineData(7, 21)]
public void Prolonger_AuxLimites_EstAccepte(int jours, int jourFinAttendu)
{
Periode prolongee = _pret.Prolonger(jours);
Assert.Equal(new DateOnly(2027, 3, jourFinAttendu), prolongee.Fin);
}
}
Les valeurs 0, 1, 7 et 8 sont les quatre valeurs limites des deux frontières. Vérifier aussi que le début ne change pas protège contre une implémentation qui recréerait la période à partir d'aujourd'hui. Le champ _pret peut être partagé sans risque : Prolonger renvoie une nouvelle période sans modifier l'ancienne, et xUnit recrée de toute façon l'instance pour chaque test.
Tu avais trouvé ?
Exercice 2 sur 3 Le message à l'envers Réussi À revoir
Un test échoue avec ce message :
Assert.Equal() Failure: Values differ
Expected: 16
Actual: 15
Le test est Assert.Equal(periode.DureeEnJours, 15); pour un prêt du 15 au 30 mars. Le développeur cherche pourquoi le code calcule 15 au lieu de 16. Que se passe-t-il ?
Voir la réponseMasquer la réponse de l'exercice 2
Les arguments sont inversés : xUnit attend Assert.Equal(attendu, obtenu). Le code calcule 16 (du 15 au 30 mars, premier et dernier jour compris : 16 jours), et c'est le test qui attend 15, à tort. Le message affiche donc « Expected: 16 », la valeur obtenue, et « Actual: 15 », la valeur écrite dans le test. Le développeur cherche un bug dans le code alors que l'erreur est dans le test. Correction : Assert.Equal(16, periode.DureeEnJours);.
Tu avais trouvé ?
Exercice 3 sur 3 De MSTest à xUnit Réussi À revoir
Convertis cette classe MSTest en xUnit.
[TestClass]
public class PeriodeTests
{
private Periode _pc07 = null!;
[TestInitialize]
public void Initialiser()
{
_pc07 = new Periode(new DateOnly(2027, 3, 10), new DateOnly(2027, 3, 14));
}
[TestMethod]
[DataRow(15, 20, false)]
[DataRow(14, 18, true)]
public void Chevauche(int debut, int fin, bool attendu)
{
var autre = new Periode(new DateOnly(2027, 3, debut), new DateOnly(2027, 3, fin));
Assert.AreEqual(attendu, _pc07.Chevauche(autre));
}
}
Voir la réponseMasquer la réponse de l'exercice 3
public class PeriodeTests
{
private readonly Periode _pc07;
public PeriodeTests()
{
_pc07 = new Periode(new DateOnly(2027, 3, 10), new DateOnly(2027, 3, 14));
}
[Theory]
[InlineData(15, 20, false)]
[InlineData(14, 18, true)]
public void Chevauche(int debut, int fin, bool attendu)
{
var autre = new Periode(new DateOnly(2027, 3, debut), new DateOnly(2027, 3, fin));
Assert.Equal(attendu, _pc07.Chevauche(autre));
}
}
[TestClass] disparaît, [TestInitialize] devient le constructeur (le champ peut alors être readonly), [TestMethod] avec [DataRow] devient [Theory] avec [InlineData], et Assert.AreEqual devient Assert.Equal, dans le même ordre (attendu, obtenu). Le second cas, « commence le dernier jour », est la valeur limite dont parle le chapitre 3.
Tu avais trouvé ?