Tests & TDD

xUnit

Fact et Theory, fixtures, cycle de vie, assertions.

Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 18 min

xUnit est le framework de test qu'installe le modèle dotnet new xunit du SDK .NET. Il se distingue de NUnit et de MSTest par quelques choix délibérés : aucun attribut de préparation ni de nettoyage, une instance neuve de la classe de test pour chaque test, un contexte partagé qui se déclare explicitement, et des classes qui tournent en parallèle par défaut. Ces choix expliquent la plupart des tests qui passent seuls et échouent dans la suite. Le cours TDD se sert d'xUnit sans s'y attarder ; celui-ci en fait son objet. Il porte sur la version qu'installe le modèle du SDK .NET 10, xUnit 2.9.3, et sa dernière section dit ce que change v3. Tous les exemples compilent ensemble dans un même projet de test, avec les using implicites du modèle.

Fact et Theory

Un [Fact] est un test sans paramètre : un constat qui vaut toujours. Une [Theory] est une méthode paramétrée, et chaque jeu de données qu'on lui fournit devient un test à part entière. Il a son nom — Facturation.Tests.TvaTests.Le_ttc_applique_le_taux(ht: 100, taux: 0,055, attendu: 105,5) dans la liste de dotnet test --list-tests, avec la virgule décimale de la culture française de la machine —, son résultat, et un cas qui échoue n'empêche pas les autres de tourner. [InlineData] écrit les valeurs dans l'attribut ; un attribut n'acceptant pas de decimal, on y écrit des double que xUnit convertit, ce que le cours TDD montre déjà.

namespace Facturation.Tests;

public static class Tva
{
    public static decimal Ttc(decimal ht, decimal taux) => Math.Round(ht * (1 + taux), 2);
}

public class TvaTests
{
    // Un Fact : aucun parametre, un constat qui vaut toujours.
    [Fact]
    public void Un_montant_nul_reste_nul()
    {
        Assert.Equal(0m, Tva.Ttc(0m, 0.20m));
    }

    // Une Theory : chaque InlineData devient un test a part entiere, avec son
    // nom, son resultat et son echec propres.
    [Theory]
    [InlineData(100, 0.20, 120)]
    [InlineData(100, 0.055, 105.5)]
    [InlineData(19.99, 0.20, 23.99)]
    public void Le_ttc_applique_le_taux(decimal ht, decimal taux, decimal attendu)
    {
        Assert.Equal(attendu, Tva.Ttc(ht, taux));
    }
}

Quand les données ne tiennent pas dans un attribut — des objets, des dates, des cas calculés —, [MemberData] les lit dans une propriété ou une méthode statique. La forme historique rend un IEnumerable<object[]>, et elle a un défaut : chaque ligne est un tableau d'objets, que le compilateur ne confronte jamais à la signature du test. Une ligne à laquelle il manque une valeur compile, sans aucun avertissement.

namespace Facturation.Tests;

public class RemiseTests
{
    // Chaque ligne est un object[] : rien ne relie ses cases aux parametres
    // du test. La seconde ligne a perdu sa remise attendue, et compile.
    public static IEnumerable<object[]> Cas =>
    [
        [100m, 10m],
        [250m],
    ];

    [Theory]
    [MemberData(nameof(Cas))]
    public void La_remise_est_de_dix_pour_cent(decimal brut, decimal remise)
    {
        Assert.Equal(remise, brut * 0.10m);
    }
}

L'erreur n'apparaît qu'à l'exécution, sur le seul cas fautif, dont le nom affiche ??? à la place de la valeur manquante :

$ dotnet test --filter "FullyQualifiedName~Facturation.Tests.RemiseTests"
Restauration terminée (1,7s)
  Facturation.Tests net10.0 a réussi (1,0s) → bin\Debug\net10.0\Facturation.Tests.dll
[xUnit.net 00:00:00.01] xUnit.net VSTest Adapter v3.1.4+50e68bbb8b (64-bit .NET 10.0.8)
[xUnit.net 00:00:00.33]   Discovering: Facturation.Tests
[xUnit.net 00:00:00.53]   Discovered:  Facturation.Tests
[xUnit.net 00:00:00.68]   Starting:    Facturation.Tests
[xUnit.net 00:00:00.81]     Facturation.Tests.RemiseTests.La_remise_est_de_dix_pour_cent(brut: 250, remise: ???) [FAIL]
[xUnit.net 00:00:00.81]       System.InvalidOperationException : The test method expected 2 parameter values, but 1 parameter value was provided.
[xUnit.net 00:00:00.86]   Finished:    Facturation.Tests
  Test de Facturation.Tests net10.0 : a échoué avec 1 erreur(s) (4,6 s)
    C:\Program Files\dotnet\sdk\10.0.300\Microsoft.TestPlatform.targets(48,5): error TESTERROR: Facturation.Tests.RemiseTests.La_remise_est_de_dix_pour_cent(brut: 250, remise: ???) (4ms): Message d'erreur : System.InvalidOperationException : The test method expected 2 parameter values, but 1 parameter value was provided.

Récapitulatif du test : total : 2; échec : 1; réussi : 1; ignoré : 0; durée : 4,5s
Générer a échoué avec 1 erreur(s) dans 9,6s

TheoryData typé déplace la vérification à la compilation. Chaque ligne de l'initialiseur est un appel à sa méthode Add, dont les paramètres portent les types de la théorie. La même ligne incomplète, { 250m }, y devient l'erreur CS7036 : aucun argument ne correspond au paramètre obligatoire p2 de TheoryData<decimal, decimal>.Add(decimal, decimal).

namespace Facturation.Tests;

public class RemiseTypeeTests
{
    // Chaque ligne passe par TheoryData<decimal, decimal>.Add(decimal, decimal) :
    // le compilateur verifie le nombre et le type des cases.
    public static TheoryData<decimal, decimal> Cas => new()
    {
        { 100m, 10m },
        { 250m, 25m },
    };

    [Theory]
    [MemberData(nameof(Cas))]
    public void La_remise_est_de_dix_pour_cent(decimal brut, decimal remise)
    {
        Assert.Equal(remise, brut * 0.10m);
    }
}

Une instance de classe par test

xUnit n'a ni [SetUp] ni [TearDown]. Pour chaque test, et pour chaque cas d'une théorie, il construit une instance neuve de la classe, appelle la méthode, puis abandonne l'instance, en appelant Dispose si la classe implémente IDisposable. Le constructeur tient lieu de préparation, Dispose de nettoyage, et un champ d'instance ne peut rien transmettre d'un test au suivant. C'est le but : un test qui dépend de ce qu'un autre a laissé passe ou échoue selon l'ordre d'exécution, et cet ordre n'est pas celui du fichier.

Le piège consiste à défaire cette isolation sans s'en apercevoir, par un champ static. Il appartient au type, pas à l'instance : il survit à chaque instance jetée et accumule d'un test à l'autre.

namespace Facturation.Tests;

public class PanierPartageTests
{
    // static : une seule liste pour le type, donc pour tous ses tests.
    // Le premier test qui tourne la remplit, le second la trouve occupee.
    private static readonly List<string> Lignes = [];

    [Fact]
    public void Ajouter_un_clavier()
    {
        Lignes.Add("clavier");

        Assert.Single(Lignes);
    }

    [Fact]
    public void Ajouter_un_ecran()
    {
        Lignes.Add("ecran");

        Assert.Single(Lignes);
    }
}

Ici, le test qui échoue est Ajouter_un_clavier, déclaré le premier : xUnit a exécuté Ajouter_un_ecran avant lui, et la collection contient les deux références dans cet ordre-là. Chaque test, lancé seul, passe. xUnit v2 trie les tests d'une classe par un identifiant haché qui inclut le nom de l'assemblage : l'ordre est stable d'une exécution à l'autre du même projet, et change d'un projet à l'autre. Les mêmes tests dans un assemblage Boutique.Tests font échouer Ajouter_un_ecran.

$ dotnet test --filter "FullyQualifiedName~PanierPartageTests"
Restauration terminée (2,6s)
  Facturation.Tests net10.0 a réussi (1,0s) → bin\Debug\net10.0\Facturation.Tests.dll
[xUnit.net 00:00:00.01] xUnit.net VSTest Adapter v3.1.4+50e68bbb8b (64-bit .NET 10.0.8)
[xUnit.net 00:00:00.42]   Discovering: Facturation.Tests
[xUnit.net 00:00:00.71]   Discovered:  Facturation.Tests
[xUnit.net 00:00:00.91]   Starting:    Facturation.Tests
[xUnit.net 00:00:01.13]     Facturation.Tests.PanierPartageTests.Ajouter_un_clavier [FAIL]
[xUnit.net 00:00:01.13]       Assert.Single() Failure: The collection contained 2 items
[xUnit.net 00:00:01.13]       Collection: ["ecran", "clavier"]
[xUnit.net 00:00:01.13]       Stack Trace:
[xUnit.net 00:00:01.13]         C:\facturation\Facturation.Tests\PanierPartageTests.cs(14,0): at Facturation.Tests.PanierPartageTests.Ajouter_un_clavier()
[xUnit.net 00:00:01.13]            at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
[xUnit.net 00:00:01.13]            at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
[xUnit.net 00:00:01.14]   Finished:    Facturation.Tests
  Test de Facturation.Tests net10.0 : a échoué avec 1 erreur(s) (5,5 s)
    C:\facturation\Facturation.Tests\PanierPartageTests.cs(14): error TESTERROR:
      Facturation.Tests.PanierPartageTests.Ajouter_un_clavier (13ms): Message d'erreur : Assert.Single() Failure: The collection contained 2 items
      Collection: ["ecran", "clavier"]
      Arborescence des appels de procédure :
         at Facturation.Tests.PanierPartageTests.Ajouter_un_clavier() in C:\facturation\Facturation.Tests\PanierPartageTests.cs:line 14
         at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
         at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)

Récapitulatif du test : total : 2; échec : 1; réussi : 1; ignoré : 0; durée : 5,4s
Générer a échoué avec 1 erreur(s) dans 11,9s

Le même champ, sans static, donne une liste par test, et les deux passent :

namespace Facturation.Tests;

public class PanierTests
{
    // Champ d'instance : xUnit construit un PanierTests neuf pour chaque test,
    // donc chaque test recoit une liste neuve, vide.
    private readonly List<string> lignes = [];

    [Fact]
    public void Ajouter_un_clavier()
    {
        lignes.Add("clavier");

        Assert.Single(lignes);
    }

    [Fact]
    public void Ajouter_un_ecran()
    {
        lignes.Add("ecran");

        Assert.Single(lignes);
    }
}

Une préparation qui acquiert une ressource se libère dans Dispose, que xUnit appelle après chaque test, y compris quand l'assertion a échoué.

namespace Facturation.Tests;

public sealed class ExportTests : IDisposable
{
    private readonly string dossier;

    // Le constructeur tient lieu de preparation : il s'execute avant chaque
    // test, et chaque test a donc son propre dossier temporaire.
    public ExportTests() =>
        dossier = Directory.CreateTempSubdirectory("export-").FullName;

    [Fact]
    public void L_export_ecrit_un_fichier_par_facture()
    {
        File.WriteAllText(Path.Combine(dossier, "F-001.csv"), "clavier;45");
        File.WriteAllText(Path.Combine(dossier, "F-002.csv"), "ecran;180");

        Assert.Equal(2, Directory.GetFiles(dossier).Length);
    }

    // Dispose tient lieu de nettoyage : il s'execute apres chaque test,
    // qu'il ait reussi ou echoue.
    public void Dispose() => Directory.Delete(dossier, recursive: true);
}

Partager un contexte : les fixtures

L'instance par test rend l'isolation gratuite, mais chaque test paie ce que son constructeur construit. Quand la préparation coûte cher — lire un gros fichier de référence, démarrer un conteneur, créer une base — et que les tests ne font que lire son résultat, xUnit la partage par une fixture : une classe qu'il construit lui-même et qu'il passe au constructeur des classes de test qui la demandent. Avec IClassFixture<T>, la fixture est créée avant le premier test de la classe et nettoyée, par Dispose si elle l'implémente, après le dernier.

namespace Facturation.Tests;

// La fixture porte ce qui coute cher a construire et que les tests ne font
// que lire. Un constructeur public sans parametre suffit a xUnit.
public sealed class CatalogueFixture
{
    public IReadOnlyDictionary<string, decimal> Prix { get; }

    public CatalogueFixture()
    {
        // Ici viendrait le chargement couteux : un fichier de reference, un service.
        Prix = new Dictionary<string, decimal>
        {
            ["clavier"] = 45m,
            ["ecran"] = 180m,
        };
    }
}

// IClassFixture<T> : un seul CatalogueFixture pour toute la classe, cree avant
// son premier test et passe au constructeur de chaque instance. La classe de
// test, elle, reste neuve a chaque test.
public class CatalogueTests(CatalogueFixture catalogue) : IClassFixture<CatalogueFixture>
{
    [Fact]
    public void Le_clavier_coute_45_euros() => Assert.Equal(45m, catalogue.Prix["clavier"]);

    [Fact]
    public void L_ecran_coute_180_euros() => Assert.Equal(180m, catalogue.Prix["ecran"]);
}

Le partage suppose que les tests ne modifient pas la fixture. Un test qui y écrit rétablit exactement la dépendance à l'ordre que l'instance par test supprimait, et c'est pourquoi l'exemple l'expose en IReadOnlyDictionary.

ICollectionFixture et IAsyncLifetime

Pour partager une fixture entre plusieurs classes, on nomme une collection. Une classe vide marquée [CollectionDefinition] déclare la fixture par ICollectionFixture<T>, et toute classe qui porte [Collection] avec le même nom reçoit la même instance. La définition doit se trouver dans le même assemblage que les tests qui l'utilisent.

Un constructeur ne peut pas attendre une tâche, et Dispose non plus. Une fixture qui doit initialiser ou nettoyer de façon asynchrone implémente IAsyncLifetime : InitializeAsync est attendue après le constructeur et avant le premier test, DisposeAsync après le dernier.

namespace Facturation.Tests;

// IAsyncLifetime : une preparation et un nettoyage asynchrones, ce qu'un
// constructeur et un Dispose ne peuvent pas etre. En v2, les deux rendent un Task.
public sealed class EntrepotFixture : IAsyncLifetime
{
    public string Dossier { get; } =
        Path.Combine(Path.GetTempPath(), "entrepot-" + Guid.NewGuid().ToString("N"));

    public async Task InitializeAsync()
    {
        Directory.CreateDirectory(Dossier);
        await File.WriteAllTextAsync(Path.Combine(Dossier, "stock.csv"), "clavier;12\necran;3");
    }

    public Task DisposeAsync()
    {
        Directory.Delete(Dossier, recursive: true);
        return Task.CompletedTask;
    }
}

// La definition ne contient aucun code : elle nomme la collection et declare
// la fixture que ses classes partagent.
[CollectionDefinition("Entrepot")]
public class EntrepotCollection : ICollectionFixture<EntrepotFixture>;

[Collection("Entrepot")]
public class InventaireTests(EntrepotFixture entrepot)
{
    [Fact]
    public void Le_stock_liste_deux_references() =>
        Assert.Equal(2, File.ReadAllLines(Path.Combine(entrepot.Dossier, "stock.csv")).Length);
}

[Collection("Entrepot")]
public class ReassortTests(EntrepotFixture entrepot)
{
    [Fact]
    public void L_ecran_est_sous_le_seuil_de_cinq()
    {
        var ligne = File.ReadAllLines(Path.Combine(entrepot.Dossier, "stock.csv"))
            .Single(l => l.StartsWith("ecran;"));

        Assert.True(int.Parse(ligne.Split(';')[1]) < 5);
    }
}

Une classe de test peut aussi implémenter IAsyncLifetime elle-même ; ses deux méthodes encadrent alors chaque test. En v2, l'ordre complet est le suivant, identique d'une exécution à l'autre :

  1. fixture de collection : constructeur, puis InitializeAsync ;
  2. fixture de classe : constructeur, avant le premier test de sa classe ;
  3. pour chaque test : constructeur de la classe, InitializeAsync, le test, DisposeAsync, Dispose ;
  4. fixture de classe : Dispose, après le dernier test de sa classe ;
  5. fixture de collection : DisposeAsync, puis Dispose si elle implémente les deux.

Le parallélisme par collection

Par défaut, chaque classe de test forme sa propre collection. xUnit exécute les collections en parallèle les unes des autres, sur autant de threads que la machine a de processeurs logiques, et les tests d'une même collection l'un après l'autre. Deux classes de deux tests d'une seconde chacun le montrent :

namespace Facturation.Tests;

// Deux classes, donc deux collections : xUnit les execute en meme temps.
// A l'interieur d'une classe, les deux tests passent l'un apres l'autre.
// Poser [Collection("Echeancier")] sur les deux classes les reunirait en une
// seule collection, et les quatre tests passeraient en serie.
public class RelancesTests
{
    [Fact]
    public void Relance_a_trente_jours() => Thread.Sleep(1000);

    [Fact]
    public void Relance_a_soixante_jours() => Thread.Sleep(1000);
}

public class AvoirsTests
{
    [Fact]
    public void Avoir_partiel() => Thread.Sleep(1000);

    [Fact]
    public void Avoir_total() => Thread.Sleep(1000);
}

Entre Starting et Finished, un peu plus de deux secondes, pour quatre secondes de tests : les deux classes ont tourné en même temps. La durée du récapitulatif, elle, compte aussi le lancement de l'hôte de test et la découverte.

$ dotnet test --filter "FullyQualifiedName~RelancesTests|FullyQualifiedName~AvoirsTests"
Restauration terminée (1,8s)
  Facturation.Tests net10.0 a réussi (1,1s) → bin\Debug\net10.0\Facturation.Tests.dll
[xUnit.net 00:00:00.01] xUnit.net VSTest Adapter v3.1.4+50e68bbb8b (64-bit .NET 10.0.8)
[xUnit.net 00:00:00.40]   Discovering: Facturation.Tests
[xUnit.net 00:00:00.60]   Discovered:  Facturation.Tests
[xUnit.net 00:00:00.72]   Starting:    Facturation.Tests
[xUnit.net 00:00:02.89]   Finished:    Facturation.Tests
  Test de Facturation.Tests net10.0 : a réussi (6,7 s)

Récapitulatif du test : total : 4; échec : 0; réussi : 4; ignoré : 0; durée : 6,6s
Générer a réussi dans 12,0s

Les deux mêmes classes marquées [Collection("Echeancier")], comme le suggère le commentaire, prennent un peu plus de quatre secondes au même relevé : elles forment une seule collection, dont les quatre tests passent en série. C'est la conséquence d'une fixture de collection qu'il faut connaître : partager une fixture, c'est aussi renoncer au parallélisme entre les classes qui la partagent, et c'est la protection voulue quand la ressource partagée ne supporte pas deux utilisateurs à la fois.

L'instance par test isole les champs d'instance, pas l'état global : en parallèle, deux classes qui modifient la même variable statique, le même fichier ou la même base se marchent dessus. Le remède est de les placer dans une même collection, ou, en dernier recours, de couper le parallélisme pour tout l'assemblage :

// xUnit v2. Au niveau de l'assemblage, une seule fois dans le projet de test.
// Toutes les collections passent alors l'une apres l'autre.
[assembly: CollectionBehavior(DisableTestParallelization = true)]

// Variante : garder le parallelisme, mais sur quatre threads au plus.
// [assembly: CollectionBehavior(MaxParallelThreads = 4)]

Les assertions

Assert lève une exception dès qu'une vérification échoue : le reste du test ne s'exécute pas, et le message de l'exception est ce que la sortie affiche. Ces messages sont ceux de la bibliothèque, en anglais, même quand le SDK affiche le reste en français.

Égalité et précision décimale

Assert.Equal(attendu, réel) prend l'attendu en premier ; les inverser ne change pas le verdict, mais le message annonce alors l'un pour l'autre. Sur des double, l'égalité exacte est presque toujours fausse. Le paramètre precision passe souvent pour une marge, et il n'en est pas une : il arrondit les deux valeurs au nombre de décimales demandé, puis les compare. Deux valeurs très proches de part et d'autre d'une frontière d'arrondi diffèrent, deux valeurs plus éloignées du même côté sont égales — 1,006 et 1,014 passent à deux décimales.

namespace Facturation.Tests;

public class ArrondiTests
{
    // 0.1 et 0.2 n'ont pas d'ecriture binaire exacte : l'egalite stricte echoue.
    //   Assert.Equal() Failure: Values differ
    //   Expected: 0,29999999999999999
    //   Actual:   0,30000000000000004
    [Fact]
    public void Dix_et_vingt_centimes_font_trente_centimes() =>
        Assert.Equal(0.3, 0.1 + 0.2);

    // precision: 2 n'est pas une marge : les deux valeurs sont arrondies a deux
    // decimales, puis comparees. A 0,002 d'ecart, 1,004 et 1,006 different.
    //   Assert.Equal() Failure: Values are not within 2 decimal places
    //   Expected: 1 (rounded from 1,004)
    //   Actual:   1,01 (rounded from 1,006)
    [Fact]
    public void Deux_mesures_proches_sont_egales() =>
        Assert.Equal(1.004, 1.006, precision: 2);
}

La surcharge tolerance exprime ce qu'on voulait dire : un écart absolu maximal. Pour des montants, le bon remède est en amont, dans le type.

namespace Facturation.Tests;

public class ArrondiToleranceTests
{
    // tolerance : un ecart maximal, qui ne depend d'aucune frontiere d'arrondi.
    [Fact]
    public void Dix_et_vingt_centimes_font_trente_centimes() =>
        Assert.Equal(0.3, 0.1 + 0.2, tolerance: 1e-9);

    [Fact]
    public void Deux_mesures_proches_sont_egales() =>
        Assert.Equal(1.004, 1.006, tolerance: 0.005);

    // Pour de l'argent, le remede est ailleurs : decimal compte en base dix,
    // et l'egalite exacte redevient la bonne assertion.
    [Fact]
    public void En_decimal_le_compte_est_exact() =>
        Assert.Equal(0.3m, 0.1m + 0.2m);
}

Throws contre ThrowsAny

Assert.Throws<T> exige une exception de type T exactement, et une classe dérivée ne suffit pas. Ce piège mord souvent avec les exceptions d'argument : ArgumentNullException et ArgumentOutOfRangeException dérivent toutes deux d'ArgumentException, et un test qui attend la classe de base échoue.

namespace Facturation.Tests;

public static class Stock
{
    public static int Retirer(string reference, int quantite)
    {
        // ThrowIfNull leve ArgumentNullException, qui derive d'ArgumentException.
        ArgumentNullException.ThrowIfNull(reference);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantite);
        return quantite;
    }
}

public class StockTests
{
    // Throws<T> exige le type exact : une classe derivee ne suffit pas.
    //   Assert.Throws() Failure: Exception type was not an exact match
    //   Expected: typeof(System.ArgumentException)
    //   Actual:   typeof(System.ArgumentNullException)
    [Fact]
    public void Une_reference_nulle_est_refusee() =>
        Assert.Throws<ArgumentException>(() => Stock.Retirer(null!, 1));
}

Le test juste choisit selon ce que le contrat promet. S'il promet un type précis, Throws sur ce type ; s'il ne promet qu'une famille, ThrowsAny, qui accepte le type et ses dérivés. Les deux rendent l'exception, ce qui permet de vérifier son contenu. Pour du code asynchrone, Assert.ThrowsAsync et Assert.ThrowsAnyAsync suivent la même règle et s'attendent avec await.

namespace Facturation.Tests;

// Stock est la classe de l'exemple precedent.
public class StockExceptionsTests
{
    // Le type exact, puis ce que l'exception dit : Throws la rend pour cela.
    [Fact]
    public void Une_reference_nulle_est_refusee()
    {
        var ex = Assert.Throws<ArgumentNullException>(() => Stock.Retirer(null!, 1));

        Assert.Equal("reference", ex.ParamName);
    }

    // ThrowsAny<T> accepte T et ses derivees : ici ArgumentOutOfRangeException.
    // C'est le bon choix quand le contrat ne promet qu'une famille d'erreurs.
    [Fact]
    public void Une_quantite_nulle_est_une_erreur_d_argument()
    {
        var ex = Assert.ThrowsAny<ArgumentException>(() => Stock.Retirer("clavier", 0));

        Assert.Equal("quantite", ex.ParamName);
    }
}

Collections

Assert.Equal compare deux séquences élément par élément et, en cas d'écart, pointe la première position qui diffère. Assert.Equivalent ignore l'ordre, mais son mode par défaut tolère des éléments en trop dans la valeur réelle ; seul strict: true les signale, comme Extra values found. Assert.Collection applique un inspecteur par élément et échoue si leur nombre diffère ; Contains, Single et DoesNotContain acceptent un prédicat.

namespace Facturation.Tests;

public record Ligne(string Reference, int Quantite);

public class LignesTests
{
    private readonly List<Ligne> lignes = [new("clavier", 1), new("cable", 2), new("ecran", 1)];

    // Equal sur deux sequences : memes elements, dans le meme ordre. Le type
    // des conteneurs ne compte pas : une expression de collection contre un Select.
    [Fact]
    public void Les_references_dans_l_ordre_de_saisie() =>
        Assert.Equal(["clavier", "cable", "ecran"], lignes.Select(l => l.Reference));

    // Equivalent ignore l'ordre. Sans strict: true, il ignore aussi les elements
    // en trop dans la valeur reelle : attendre ["clavier", "ecran"] passerait.
    [Fact]
    public void Les_references_dans_n_importe_quel_ordre() =>
        Assert.Equivalent(new[] { "ecran", "clavier", "cable" }, lignes.Select(l => l.Reference), strict: true);

    // Collection : un inspecteur par element, dans l'ordre, et autant
    // d'inspecteurs que d'elements.
    [Fact]
    public void Chaque_ligne_a_sa_verification() =>
        Assert.Collection(
            lignes,
            l => Assert.Equal("clavier", l.Reference),
            l => Assert.Equal(2, l.Quantite),
            l => Assert.Equal("ecran", l.Reference));

    [Fact]
    public void Recherches_dans_les_lignes()
    {
        Assert.Contains(new Ligne("cable", 2), lignes); // egalite de valeur du record
        Assert.Single(lignes, l => l.Quantite > 1);
        Assert.DoesNotContain(lignes, l => l.Quantite == 0);
    }
}

Écrire dans la sortie du test

xUnit v2 ne rattache pas Console.WriteLine au test qui l'appelle. Pour une trace qui accompagne un test, il fournit ITestOutputHelper, reçu par le constructeur. L'exemple échoue exprès, pour montrer où va chaque ligne. Il arrondit un total de 25,085 en attendant 25,09, mais Math.Round arrondit par défaut au pair le plus proche et rend 25,08 ; le correctif est Math.Round(total, 2, MidpointRounding.AwayFromZero), qui rend 25,09.

using Xunit.Abstractions;

namespace Facturation.Tests;

// En v2, ITestOutputHelper vient de Xunit.Abstractions, et xUnit le fournit a
// tout constructeur de classe de test qui le demande.
public class FactureTests(ITestOutputHelper sortie)
{
    [Fact]
    public void Le_total_est_arrondi_au_centime_superieur_a_mi_chemin()
    {
        var lignes = new[] { 19.99m, 4.995m, 0.10m };
        var total = Math.Round(lignes.Sum(), 2);

        Console.WriteLine("console : total = " + total);
        sortie.WriteLine($"lignes = {string.Join(" + ", lignes)}, total = {total}");

        Assert.Equal(25.09m, total);
    }
}

Dans le Terminal Logger, la ligne de la console sort en vrac, avant l'échec, sans nom de test : avec plusieurs classes en parallèle, rien ne dirait laquelle l'a écrite. La ligne de ITestOutputHelper apparaît sous l'échec, dans le bloc Output puis dans les messages de sortie standard du test. Quand le test passe, la ligne de la console s'affiche toujours et celle d'ITestOutputHelper ne s'affiche pas : la sortie d'un test sert à comprendre un échec. Avec le logger classique, en revanche — celui de --tl:off, et celui que dotnet test choisit quand sa sortie est redirigée, comme en CI —, la ligne de la console n'apparaît nulle part.

$ dotnet test --filter "FullyQualifiedName~FactureTests"
Restauration terminée (2,9s)
  Facturation.Tests net10.0 a réussi (1,4s) → bin\Debug\net10.0\Facturation.Tests.dll
[xUnit.net 00:00:00.01] xUnit.net VSTest Adapter v3.1.4+50e68bbb8b (64-bit .NET 10.0.8)
[xUnit.net 00:00:00.54]   Discovering: Facturation.Tests
[xUnit.net 00:00:00.80]   Discovered:  Facturation.Tests
[xUnit.net 00:00:00.92]   Starting:    Facturation.Tests
console : total = 25,08
[xUnit.net 00:00:01.20]     Facturation.Tests.FactureTests.Le_total_est_arrondi_au_centime_superieur_a_mi_chemin [FAIL]
[xUnit.net 00:00:01.20]       Assert.Equal() Failure: Values differ
[xUnit.net 00:00:01.20]       Expected: 25,09
[xUnit.net 00:00:01.20]       Actual:   25,08
[xUnit.net 00:00:01.20]       Stack Trace:
[xUnit.net 00:00:01.21]         C:\facturation\Facturation.Tests\FactureTests.cs(18,0): at Facturation.Tests.FactureTests.Le_total_est_arrondi_au_centime_superieur_a_mi_chemin()
[xUnit.net 00:00:01.21]            at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
[xUnit.net 00:00:01.21]            at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
[xUnit.net 00:00:01.21]       Output:
[xUnit.net 00:00:01.21]         lignes = 19,99 + 4,995 + 0,10, total = 25,08
[xUnit.net 00:00:01.36]   Finished:    Facturation.Tests
  Test de Facturation.Tests net10.0 : a échoué avec 1 erreur(s) (8,4 s)
    C:\facturation\Facturation.Tests\FactureTests.cs(18): error TESTERROR:
      Facturation.Tests.FactureTests.Le_total_est_arrondi_au_centime_superieur_a_mi_chemin (61ms): Message d'erreur : Assert.Equal() Failure: Values differ
      Expected: 25,09
      Actual:   25,08
      Arborescence des appels de procédure :
         at Facturation.Tests.FactureTests.Le_total_est_arrondi_au_centime_superieur_a_mi_chemin() in C:\facturation\Facturation.Tests\FactureTests.cs:line 18
         at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
         at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
        Messages de sortie standard :
       lignes = 19,99 + 4,995 + 0,10, total = 25,08

Récapitulatif du test : total : 1; échec : 1; réussi : 0; ignoré : 0; durée : 8,2s
Générer a échoué avec 1 erreur(s) dans 16,3s

xUnit v2, et ce que change v3

Le modèle dotnet new xunit du SDK 10.0.300 produit ce projet. Le paquet xunit 2.9.3 est le framework v2 ; le 3.1.4 de xunit.runner.visualstudio est la version de l'adaptateur VSTest, que la sortie affiche en tête — il ne s'agit pas d'xUnit v3. La documentation officielle dit v2 en maintenance : seules des corrections critiques y sont publiées, toutes les nouveautés vont à v3, dont la ligne courante est 4.x depuis la 4.0.0 du 14 août 2026, et elle encourage à migrer.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="coverlet.collector" Version="6.0.4" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

</Project>

xUnit v3 est un framework distinct, avec ses propres paquets et son propre modèle, qu'on installe par dotnet new install xunit.v3.templates avant de créer le projet par dotnet new xunit3. Fact et Theory, l'instance par test, le partage par fixtures et collections, et les assertions de ce cours s'y retrouvent. Deux comportements changent pourtant : la libération des objets qui ont les deux formes de nettoyage, et la façon de régler le parallélisme. Ce qui change à la migration, d'après le guide officiel et les notes de version :

Pointv2 (ce cours)v3
Paquetxunitxunit.v3
Projet de testbibliothèque chargée par le runnerexécutable, OutputType à Exe
IAsyncLifetimeméthodes qui rendent Task méthodes qui rendent ValueTask ; hérite d'IAsyncDisposable
ITestOutputHelperespace Xunit.Abstractionsespace Xunit, Xunit.Abstractions disparaît
LibérationDisposeAsync puis Dispose, si l'objet a les deuxDisposeAsync seulement, même si l'objet implémente aussi IDisposable
Couper le parallélisme[assembly: CollectionBehavior(DisableTestParallelization = true)] en 4.x, [assembly: Parallelization(Mode = ParallelMode.None)] ; les propriétés de CollectionBehavior sont obsolètes et inutilisables
Consolejamais rattachée au testcapturée sur demande, par [assembly: CaptureConsole]

Plusieurs de ces lignes touchent ce cours. Sous v3, la fixture d'entrepôt ne compile plus telle quelle, puisque ses deux méthodes doivent rendre un ValueTask. L'exemple de sortie doit perdre sa directive using Xunit.Abstractions, dont l'espace de noms n'existe plus. En 4.x, l'attribut qui coupe le parallélisme se réécrit avec Parallelization. Et l'ordre du cycle de vie donné plus haut perd ses Dispose qui suivent un DisposeAsync : un nettoyage réparti entre les deux méthodes n'en exécuterait plus que la moitié.

Ce cours vous a servi ? Offrir un café Signaler une erreur