Tester des applications .NET avec xUnit et NUnit

Tester des applications .NET avec xUnit et NUnit

Tester une application .NET n’est pas seulement une bonne pratique technique, c’est une manière de travailler qui change profondément la relation que l’on entretient avec le code. Au début, on écrit souvent des tests parce qu’on “doit” en écrire. Puis, avec l’expérience, on comprend qu’un bon test est bien plus qu’une simple vérification automatique : c’est une documentation vivante, une alerte précoce en cas de régression, un garde-fou pour refactorer sans trembler et, parfois même, une façon élégante de mieux concevoir son architecture. Avec .NET, deux frameworks se distinguent depuis des années dans l’écosystème des tests unitaires : xUnit et NUnit. Tous deux sont puissants, matures et largement utilisés, mais ils n’encouragent pas exactement les mêmes habitudes, ni la même façon d’organiser ses tests.

Dans cet article, nous allons explorer en profondeur la manière de tester des applications .NET avec xUnit et NUnit. Nous verrons comment créer un projet de test, écrire des tests lisibles, gérer les dépendances externes avec des mocks, tester du code asynchrone, organiser les suites de tests, utiliser les attributs de paramétrage, et intégrer le tout dans un flux de travail professionnel. L’objectif n’est pas seulement de montrer des extraits de code, mais de donner une vision concrète, pragmatique et humaine du test logiciel dans un projet .NET réel. Parce qu’au fond, les tests ne servent pas uniquement à “faire plaisir au build” : ils servent surtout à dormir plus sereinement quand la base de code grandit, quand les délais sont serrés, et quand plusieurs développeurs interviennent sur le même système.

Pourquoi tester une application .NET

Une application non testée peut fonctionner parfaitement pendant des semaines, puis casser au pire moment, souvent après une modification apparemment anodine. Ce genre de scénario arrive plus souvent qu’on ne l’admet. Un petit changement dans une méthode de calcul, une condition modifiée dans une logique métier, une dépendance mise à jour, et tout un pan fonctionnel peut se dégrader sans qu’on s’en rende compte immédiatement. Les tests automatisés réduisent ce risque. Ils transforment l’incertitude en signal. Ils disent : “voici ce que le code devait faire hier, voici ce qu’il fait aujourd’hui, et voici la preuve qu’il n’a pas changé de comportement sans intention”.

Dans une application .NET, les tests unitaires sont particulièrement utiles pour vérifier les règles métier, les calculs, les validations, les transformations de données et la logique conditionnelle. Ils ne remplacent pas les tests d’intégration ou les tests end-to-end, mais ils constituent souvent la première ligne de défense. Un bon ensemble de tests unitaires est rapide, précis, facile à exécuter localement et très utile en CI/CD. Quand un projet grossit, c’est souvent ce filet de sécurité qui évite que l’on avance dans le brouillard.

Il faut aussi voir les tests comme un outil de conception. Un code difficile à tester est souvent un code difficile à maintenir. Si vous vous retrouvez à lutter pour écrire un test simple, c’est parfois le signe que votre classe a trop de responsabilités, que vos dépendances sont trop couplées ou que votre architecture manque de séparation. Autrement dit, les tests ne servent pas seulement à vérifier le code : ils aident à mieux le penser.

xUnit et NUnit : deux approches, une même ambition

xUnit et NUnit poursuivent le même objectif : faciliter l’écriture et l’exécution de tests en .NET. Ils offrent tous les deux des annotations pour identifier les tests, des assertions pour vérifier les résultats, et des mécanismes pour organiser et exécuter les scénarios. Pourtant, leur philosophie diffère légèrement.

NUnit est souvent apprécié pour sa richesse historique, ses nombreux attributs et sa grande flexibilité. Il a longtemps été une référence dans l’écosystème .NET et reste très utilisé dans de nombreux projets. xUnit, de son côté, a été conçu avec une approche plus moderne et plus légère, en mettant l’accent sur l’isolation, la simplicité et la compatibilité avec les nouvelles pratiques de développement .NET. Il encourage par exemple une utilisation plus nette du cycle de vie des tests, et favorise souvent un style plus sobre.

Dans la pratique, le choix entre xUnit et NUnit dépend parfois de la base existante, des conventions d’équipe ou des habitudes du projet. Il n’existe pas de vainqueur absolu. Les deux permettent de construire des suites de tests fiables. Ce qui compte, c’est surtout la cohérence, la lisibilité et la discipline avec laquelle ils sont utilisés.

Créer un projet de test .NET

Commençons par la base : créer un projet de test dans une solution .NET. Supposons que vous avez une solution avec une application principale et que vous souhaitez ajouter des tests unitaires.

Avec le CLI .NET, vous pouvez créer un projet xUnit comme ceci :

dotnet new xunit -n MyApp.Tests
dotnet add MyApp.Tests reference MyApp/MyApp.csproj

Pour NUnit :

dotnet new nunit -n MyApp.Tests
dotnet add MyApp.Tests reference MyApp/MyApp.csproj

Ensuite, vous pouvez lancer l’ensemble des tests avec :

dotnet test

C’est simple, mais c’est déjà une étape importante. Un projet de test bien séparé du projet principal permet de garder un découpage clair. L’application contient le comportement métier ; le projet de test contient la vérification de ce comportement. Cette séparation aide à éviter la confusion entre code productif et code de validation.

Dans un cadre professionnel, il est aussi courant d’ajouter des packages pour les mocks, les assertions avancées ou les bibliothèques d’aide aux tests. Par exemple, Moq est largement utilisé pour simuler des dépendances. FluentAssertions est souvent apprécié pour rendre les assertions plus lisibles. On peut parfaitement écrire des tests avec les assertions natives de xUnit ou NUnit, mais dans beaucoup de projets, les assertions enrichies améliorent nettement la clarté.

Anatomie d’un bon test

Un test simple et lisible suit souvent un schéma mental très pratique : Arrange, Act, Assert. On prépare les données, on exécute l’action, puis on vérifie le résultat. Cette structure n’est pas une règle sacrée, mais elle aide énormément à garder les tests compréhensibles, surtout quand le volume augmente.

Prenons une classe très simple :

public class Calculator
{
    public int Add(int a, int b) => a + b;
}

Un test xUnit pourrait ressembler à ceci :

using Xunit;

public class CalculatorTests
{
    [Fact]
    public void Add_ReturnsSumOfTwoNumbers()
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        var result = calculator.Add(2, 3);

        // Assert
        Assert.Equal(5, result);
    }
}

La version NUnit est très proche :

using NUnit.Framework;

[TestFixture]
public class CalculatorTests
{
    [Test]
    public void Add_ReturnsSumOfTwoNumbers()
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        var result = calculator.Add(2, 3);

        // Assert
        Assert.AreEqual(5, result);
    }
}

À ce stade, on pourrait croire que la différence entre les frameworks est minime, et dans une certaine mesure c’est vrai. Mais au-delà de la syntaxe, chacun propose ses propres outils pour organiser les scénarios, paramétrer les données et gérer le cycle de vie des tests.

Les assertions : dire clairement ce que l’on vérifie

Les assertions sont le cœur du test. Elles expriment ce que vous attendez du code. Une assertion floue produit un test flou. Une assertion précise produit un test utile.

Avec xUnit, on utilise souvent Assert.Equal, Assert.True, Assert.False, Assert.Null, Assert.Throws, etc.

[Fact]
public void Divide_ByZero_ThrowsException()
{
    var calculator = new AdvancedCalculator();

    Assert.Throws<DivideByZeroException>(() => calculator.Divide(10, 0));
}

Avec NUnit :

[Test]
public void Divide_ByZero_ThrowsException()
{
    var calculator = new AdvancedCalculator();

    Assert.Throws<DivideByZeroException>(() => calculator.Divide(10, 0));
}

Le point essentiel n’est pas seulement d’attraper une exception, mais de s’assurer que le comportement est celui attendu. Si votre méthode doit refuser une valeur invalide, le test doit le dire explicitement. Si votre méthode doit renvoyer un objet précis, le test doit vérifier les propriétés importantes, pas seulement qu’“un objet” a été retourné.

Dans la vraie vie, on gagne souvent beaucoup en lisibilité avec des assertions plus expressives. Prenons un exemple avec une classe OrderService qui retourne une commande enrichie :

public class Order
{
    public int Id { get; set; }
    public decimal Total { get; set; }
    public string Status { get; set; } = string.Empty;
}

Un test peut alors vérifier plusieurs aspects :

[Fact]
public void CreateOrder_ReturnsPendingOrderWithExpectedTotal()
{
    var service = new OrderService();

    var order = service.CreateOrder(100m);

    Assert.NotNull(order);
    Assert.Equal(100m, order.Total);
    Assert.Equal("Pending", order.Status);
    Assert.True(order.Id > 0);
}

Ce type de test est déjà plus proche de la réalité métier. On ne valide pas seulement une valeur brute, on valide une intention.

Paramétrer les tests pour éviter la répétition

Dès qu’un scénario se répète avec plusieurs entrées, il devient pertinent d’utiliser des tests paramétrés. Cela évite la duplication et rend la suite plus lisible.

Avec xUnit, on utilise souvent [Theory] et [InlineData] :

using Xunit;

public class DiscountCalculatorTests
{
    [Theory]
    [InlineData(100, 10, 90)]
    [InlineData(50, 5, 45)]
    [InlineData(200, 25, 175)]
    public void ApplyDiscount_ReturnsExpectedValue(decimal price, decimal discount, decimal expected)
    {
        var calculator = new DiscountCalculator();

        var result = calculator.ApplyDiscount(price, discount);

        Assert.Equal(expected, result);
    }
}

Avec NUnit, on peut utiliser [TestCase] :

using NUnit.Framework;

[TestFixture]
public class DiscountCalculatorTests
{
    [TestCase(100, 10, 90)]
    [TestCase(50, 5, 45)]
    [TestCase(200, 25, 175)]
    public void ApplyDiscount_ReturnsExpectedValue(decimal price, decimal discount, decimal expected)
    {
        var calculator = new DiscountCalculator();

        var result = calculator.ApplyDiscount(price, discount);

        Assert.AreEqual(expected, result);
    }
}

Les tests paramétrés sont précieux parce qu’ils encouragent à penser en termes de comportements répétables plutôt qu’en scénarios isolés. Ils permettent aussi de garder un fichier de test compact sans sacrifier la couverture. Cela dit, il faut faire attention à ne pas transformer un test paramétré en tableau incompréhensible. Si les données deviennent trop nombreuses ou trop complexes, mieux vaut parfois séparer les cas pour préserver la lisibilité.

Tester du code asynchrone

Dans les applications .NET modernes, l’asynchronisme est partout : accès base de données, appels HTTP, messages de file, traitements I/O, opérations métier dépendantes de services distants. Tester du code asynchrone est donc indispensable.

Avec xUnit, les tests async sont très naturels :

using Xunit;

public class UserServiceTests
{
    [Fact]
    public async Task GetUserAsync_ReturnsUser()
    {
        var service = new UserService();

        var user = await service.GetUserAsync(1);

        Assert.NotNull(user);
        Assert.Equal(1, user.Id);
    }
}

Avec NUnit :

using NUnit.Framework;

[TestFixture]
public class UserServiceTests
{
    [Test]
    public async Task GetUserAsync_ReturnsUser()
    {
        var service = new UserService();

        var user = await service.GetUserAsync(1);

        Assert.IsNotNull(user);
        Assert.AreEqual(1, user.Id);
    }
}

L’erreur classique consiste à oublier await et à tester un Task au lieu du résultat réel. C’est une source fréquente de faux positifs ou de tests qui “passent” sans réellement vérifier le comportement final. Pour les méthodes async, le test doit être async lui-même. Cela semble évident, mais dans un grand projet, ce détail est parfois négligé.

Il faut aussi penser à tester les exceptions dans les méthodes async. En xUnit :

[Fact]
public async Task GetUserAsync_WhenUserMissing_ThrowsException()
{
    var service = new UserService();

    await Assert.ThrowsAsync<KeyNotFoundException>(() => service.GetUserAsync(999));
}

En NUnit :

[Test]
public async Task GetUserAsync_WhenUserMissing_ThrowsException()
{
    var service = new UserService();

    Assert.ThrowsAsync<KeyNotFoundException>(async () => await service.GetUserAsync(999));
}

Quand on travaille avec l’asynchronisme, la discipline est essentielle. Un test asynchrone mal écrit peut donner un faux sentiment de sécurité. Un test bien écrit, au contraire, capture le comportement réel de votre application.

Isoler les dépendances avec des mocks

Un test unitaire doit rester focalisé sur une unité de comportement. Dès qu’une méthode dépend d’un service externe, d’une base de données, d’un client HTTP ou d’un système de fichiers, on risque de sortir du cadre du test unitaire pur. C’est là qu’interviennent les mocks, stubs et fakes. L’idée est de simuler les dépendances pour que le test reste rapide, déterministe et isolé.

Prenons un exemple simple avec un service d’envoi d’e-mails.

public interface IEmailSender
{
    void Send(string to, string subject, string body);
}

public class NotificationService
{
    private readonly IEmailSender _emailSender;

    public NotificationService(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }

    public void NotifyUser(string email)
    {
        _emailSender.Send(email, "Bienvenue", "Merci pour votre inscription.");
    }
}

Avec Moq et xUnit :

using Moq;
using Xunit;

public class NotificationServiceTests
{
    [Fact]
    public void NotifyUser_SendsWelcomeEmail()
    {
        var mockEmailSender = new Mock<IEmailSender>();
        var service = new NotificationService(mockEmailSender.Object);

        service.NotifyUser("user@example.com");

        mockEmailSender.Verify(
            x => x.Send("user@example.com", "Bienvenue", "Merci pour votre inscription."),
            Times.Once);
    }
}

Avec NUnit, le test est presque identique :

using Moq;
using NUnit.Framework;

[TestFixture]
public class NotificationServiceTests
{
    [Test]
    public void NotifyUser_SendsWelcomeEmail()
    {
        var mockEmailSender = new Mock<IEmailSender>();
        var service = new NotificationService(mockEmailSender.Object);

        service.NotifyUser("user@example.com");

        mockEmailSender.Verify(
            x => x.Send("user@example.com", "Bienvenue", "Merci pour votre inscription."),
            Times.Once);
    }
}

Les mocks apportent une chose très importante : la possibilité de vérifier non seulement le résultat, mais aussi l’interaction. Cela permet de confirmer qu’une méthode appelle correctement une dépendance, avec les bons paramètres. Dans certains contextes, cette vérification de l’interaction est précieuse. Dans d’autres, elle peut devenir excessive si on teste chaque détail interne de l’implémentation au lieu du comportement public. Comme souvent en test, le bon dosage compte.

Tester une logique métier plus réaliste

Les exemples simples sont utiles, mais le vrai intérêt des tests apparaît quand la logique devient un peu plus riche. Imaginons un service de commande qui applique une réduction selon le montant total.

public class PricingService
{
    public decimal CalculateFinalPrice(decimal subtotal, bool isPremiumCustomer)
    {
        decimal discount = 0m;

        if (subtotal >= 200m)
            discount += 0.10m;

        if (isPremiumCustomer)
            discount += 0.05m;

        return subtotal - (subtotal * discount);
    }
}

On peut écrire plusieurs tests pour couvrir les cas importants :

using Xunit;

public class PricingServiceTests
{
    [Fact]
    public void CalculateFinalPrice_WhenRegularCustomerAndLowSubtotal_DoesNotApplyDiscount()
    {
        var service = new PricingService();

        var result = service.CalculateFinalPrice(100m, false);

        Assert.Equal(100m, result);
    }

    [Fact]
    public void CalculateFinalPrice_WhenSubtotalIsHigh_AppliesTenPercentDiscount()
    {
        var service = new PricingService();

        var result = service.CalculateFinalPrice(200m, false);

        Assert.Equal(180m, result);
    }

    [Fact]
    public void CalculateFinalPrice_WhenPremiumCustomer_AppliesAdditionalDiscount()
    {
        var service = new PricingService();

        var result = service.CalculateFinalPrice(100m, true);

        Assert.Equal(95m, result);
    }
}

On pourrait aussi paramétrer ces cas, mais parfois la séparation explicite reste plus lisible. Chaque test raconte alors une histoire simple : dans telle condition, telle remise s’applique. Cette dimension narrative compte beaucoup. Un bon test se lit presque comme une phrase métier.

Les fixtures, le cycle de vie et l’organisation des tests

Quand le volume de tests grandit, l’organisation devient cruciale. Il faut éviter les fichiers trop longs, les classes de test monolithiques et les préparations dupliquées dans chaque méthode. NUnit et xUnit offrent tous deux des mécanismes pour gérer l’initialisation.

En NUnit, on utilise souvent [SetUp] et [TearDown] :

using NUnit.Framework;

[TestFixture]
public class AccountTests
{
    private AccountService _service = null!;

    [SetUp]
    public void SetUp()
    {
        _service = new AccountService();
    }

    [Test]
    public void CreateAccount_ReturnsValidAccount()
    {
        var account = _service.CreateAccount("Hassan");

        Assert.IsNotNull(account);
        Assert.AreEqual("Hassan", account.OwnerName);
    }
}

Avec xUnit, l’approche est différente. On utilise souvent le constructeur de la classe de test pour l’initialisation, et IDisposable pour le nettoyage si nécessaire.

using Xunit;

public class AccountTests
{
    private readonly AccountService _service;

    public AccountTests()
    {
        _service = new AccountService();
    }

    [Fact]
    public void CreateAccount_ReturnsValidAccount()
    {
        var account = _service.CreateAccount("Hassan");

        Assert.NotNull(account);
        Assert.Equal("Hassan", account.OwnerName);
    }
}

Cette différence est importante sur le plan conceptuel. NUnit sépare plus visiblement la préparation du test avec ses attributs dédiés. xUnit tend à s’appuyer sur les mécanismes de construction classiques du langage. Les deux options sont valables. Le plus important est de garder une suite de tests prévisible et bien structurée.

Quand un projet devient plus complexe, il est utile de classer les tests par domaine métier ou par composant technique. Par exemple, un dossier Services, un dossier Controllers, un dossier Repositories, ou encore une séparation entre tests unitaires et tests d’intégration. Ce découpage facilite la navigation, la maintenance et l’extension future de la suite.

Tester les contrôleurs ASP.NET Core

Dans une application ASP.NET Core, il est fréquent de vouloir tester les contrôleurs en isolant leur logique. Le contrôleur ne devrait pas contenir trop de règles métier ; son rôle est souvent de coordonner les dépendances, valider les entrées et renvoyer une réponse HTTP cohérente. Tester un contrôleur permet de vérifier qu’il réagit correctement aux scénarios attendus.

Prenons un contrôleur simplifié :

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpGet("{id}")]
    public IActionResult GetProduct(int id)
    {
        var product = _productService.GetById(id);

        if (product == null)
            return NotFound();

        return Ok(product);
    }
}

Le test avec xUnit et Moq :

using Moq;
using Xunit;
using Microsoft.AspNetCore.Mvc;

public class ProductsControllerTests
{
    [Fact]
    public void GetProduct_WhenProductExists_ReturnsOk()
    {
        var mockService = new Mock<IProductService>();
        mockService.Setup(x => x.GetById(1)).Returns(new Product { Id = 1, Name = "Laptop" });

        var controller = new ProductsController(mockService.Object);

        var result = controller.GetProduct(1);

        var okResult = Assert.IsType<OkObjectResult>(result);
        var product = Assert.IsType<Product>(okResult.Value);
        Assert.Equal("Laptop", product.Name);
    }

    [Fact]
    public void GetProduct_WhenProductDoesNotExist_ReturnsNotFound()
    {
        var mockService = new Mock<IProductService>();
        mockService.Setup(x => x.GetById(1)).Returns((Product?)null);

        var controller = new ProductsController(mockService.Object);

        var result = controller.GetProduct(1);

        Assert.IsType<NotFoundResult>(result);
    }
}

Avec NUnit, la logique reste identique, seule la syntaxe d’assertion change légèrement.

L’intérêt de ce type de test est clair : vous vérifiez que le contrat HTTP est respecté. Un produit existant renvoie 200 OK, un produit absent renvoie 404 Not Found. Ce sont des comportements concrets, stables et utiles.

Les pièges courants à éviter

Quand on commence à écrire des tests, certains pièges reviennent sans cesse. Le premier consiste à écrire des tests trop proches de l’implémentation interne. Un test qui casse à chaque refactorisation mineure devient vite une nuisance. L’objectif n’est pas de figer le code, mais de figer le comportement utile. Si vous testez le détail d’un algorithme alors que seul le résultat compte, vos tests seront fragiles.

Le deuxième piège est de créer des tests trop gros. Un seul test qui vérifie dix choses à la fois devient difficile à diagnostiquer lorsqu’il échoue. Il vaut souvent mieux plusieurs tests ciblés qu’un énorme test multifenêtre. Chaque test doit idéalement répondre à une question claire.

Le troisième piège est de négliger les noms de tests. Un test appelé Test1 ou ShouldWork n’aide personne. Un bon nom raconte le scénario. Par exemple : CalculateFinalPrice_WhenPremiumCustomer_AppliesAdditionalDiscount. À la lecture, on comprend déjà l’intention. Le nom du test est presque aussi important que le code du test lui-même.

Le quatrième piège est de tout mocker. Oui, les mocks sont utiles. Non, il ne faut pas simuler tout et n’importe quoi. Parfois, un faux excès de sophistication rend la suite moins utile qu’un test plus direct avec un objet simple ou une implémentation réelle en mémoire. La règle pratique est simple : mockez ce qui est externe, lent, instable ou coûteux à exécuter ; laissez réel ce qui est simple, déterministe et rapide.

Le cinquième piège est d’ignorer la maintenance. Une suite de tests demande elle aussi du soin. Un test obsolète, un helper trop générique ou une duplication excessive finit par coûter cher. Les tests sont du code. Ils méritent la même attention que le reste.

TDD : écrire les tests avant le code

Le développement piloté par les tests, ou TDD, consiste à écrire d’abord le test, puis le code minimal pour le faire passer, puis à refactorer. Cette approche est parfois mal comprise. Elle n’a pas pour but de transformer le développeur en machine à tests. Elle vise surtout à aider à clarifier l’intention, réduire le code inutile et garder un feedback rapide.

Le cycle classique est simple : rouge, vert, refactor. D’abord, le test échoue parce que le comportement n’existe pas encore. Ensuite, on écrit juste assez de code pour le faire réussir. Enfin, on nettoie l’ensemble sans casser le comportement vérifié. Ce rythme peut paraître un peu rigide au début, mais il est très formateur.

Dans .NET, xUnit et NUnit se prêtent très bien au TDD. Par exemple, si vous développez un service de validation d’adresse e-mail, vous pouvez d’abord écrire le test :

[Fact]
public void IsValid_WhenEmailIsMalformed_ReturnsFalse()
{
    var validator = new EmailValidator();

    var result = validator.IsValid("not-an-email");

    Assert.False(result);
}

Ensuite seulement, vous implémentez la logique minimale :

public class EmailValidator
{
    public bool IsValid(string email)
    {
        return email.Contains("@");
    }
}

Bien sûr, cette implémentation est simpliste. Mais elle illustre l’idée. Le test guide la conception. Plus tard, vous pourrez enrichir la validation, ajouter des cas limites, gérer les espaces, les domaines invalides, ou les formats internationaux. Le TDD donne un cadre de progression très rassurant.

Couvrir les cas limites et les comportements d’erreur

Une suite de tests utile ne se contente pas de vérifier les cas heureux. Elle vérifie aussi ce qui se passe quand l’utilisateur se trompe, quand une donnée est absente, quand une valeur est hors limites, ou quand une dépendance échoue. Les cas limites révèlent souvent la qualité d’une conception.

Prenons une méthode de calcul de moyenne :

public class StatisticsService
{
    public double Average(IEnumerable<int> values)
    {
        if (values == null)
            throw new ArgumentNullException(nameof(values));

        var list = values.ToList();

        if (list.Count == 0)
            throw new InvalidOperationException("La collection ne peut pas être vide.");

        return list.Average();
    }
}

Les tests doivent couvrir les scénarios d’erreur :

using Xunit;
using System.Collections.Generic;
using System;

public class StatisticsServiceTests
{
    [Fact]
    public void Average_WhenValuesIsNull_ThrowsArgumentNullException()
    {
        var service = new StatisticsService();

        Assert.Throws<ArgumentNullException>(() => service.Average(null!));
    }

    [Fact]
    public void Average_WhenCollectionIsEmpty_ThrowsInvalidOperationException()
    {
        var service = new StatisticsService();

        Assert.Throws<InvalidOperationException>(() => service.Average(new List<int>()));
    }

    [Fact]
    public void Average_WhenCollectionHasValues_ReturnsExpectedAverage()
    {
        var service = new StatisticsService();

        var result = service.Average(new[] { 2, 4, 6 });

        Assert.Equal(4, result);
    }
}

Ces tests sont précieux parce qu’ils décrivent explicitement les limites du contrat. Ils empêchent aussi les régressions “silencieuses” qui apparaissent lorsqu’on modifie une logique sans penser aux entrées inattendues.

Intégrer les tests dans un pipeline CI/CD

Dans un projet professionnel, les tests ne doivent pas vivre uniquement sur la machine du développeur. Ils doivent s’exécuter automatiquement dans le pipeline d’intégration continue. Cela permet de détecter rapidement les régressions à chaque commit, à chaque pull request, ou à chaque merge.

Avec GitHub Actions, Azure DevOps, GitLab CI ou Jenkins, le principe reste similaire : restaurer les dépendances, compiler la solution, puis lancer dotnet test. L’important n’est pas le choix de l’outil, mais la régularité de l’exécution.

Un pipeline simple peut être quelque chose comme :

name: .NET CI

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Test
        run: dotnet test --no-build --configuration Release --verbosity normal

Dans une équipe, cette automatisation change tout. Elle évite les oublis, réduit le risque humain et donne un retour immédiat. Un test qui échoue dans le pipeline est souvent bien plus facile à corriger que le même bug détecté en production une semaine plus tard.

Mesurer la couverture sans en devenir prisonnier

La couverture de code est un indicateur utile, mais ce n’est pas une fin en soi. Un taux de couverture élevé ne garantit pas automatiquement des tests pertinents. On peut couvrir 90 % d’un projet et pourtant avoir manqué les scénarios les plus critiques. À l’inverse, une couverture modérée peut très bien protéger les zones à risque si les tests sont ciblés intelligemment.

L’objectif n’est pas d’atteindre un chiffre pour le chiffre. L’objectif est de couvrir le comportement important. Les couches métier, les règles sensibles, les branches conditionnelles complexes et les zones historiquement instables méritent une attention particulière. Les écrans purement techniques, les adaptateurs minimes et le code généré ne doivent pas forcer artificiellement la couverture.

Il vaut mieux un petit ensemble de tests très utiles qu’une grande forêt de tests superficiels. La qualité de la couverture compte davantage que sa quantité brute.

Quelques bonnes pratiques qui changent vraiment la vie

Avec l’expérience, certaines habitudes font une énorme différence. La première est de garder les tests simples. Un test doit être lisible en quelques secondes. Si l’on met trop de logique dans le test lui-même, on perd une partie du bénéfice. Le test doit vérifier une logique, pas devenir une logique.

La deuxième est de nommer clairement les tests et les variables. sut pour “system under test” est acceptable dans certains contextes, mais il ne faut pas que le code devienne cryptique. Des noms explicites facilitent la relecture.

La troisième est d’éviter les dépendances cachées. Un test qui dépend d’un ordre d’exécution ou d’un état global devient fragile. Chaque test doit pouvoir s’exécuter indépendamment.

La quatrième est d’avoir une discipline sur les données de test. Utilisez des objets factory, des helpers ou des builders si cela améliore la lisibilité, mais sans créer de surcouche inutile. L’idée est de réduire le bruit, pas d’ajouter une abstraction pour l’abstraction.

La cinquième est de revoir les tests lors des refactorings. Un test peut devenir obsolète, trop couplé à une ancienne structure, ou simplement moins clair qu’au départ. Le refactoring du code de test doit faire partie du travail normal, pas être considéré comme secondaire.

xUnit ou NUnit : lequel choisir ?

La question revient souvent, et la réponse honnête est souvent la suivante : les deux sont très bons. Si vous démarrez un projet neuf et que vous voulez une approche moderne, sobre et très répandue dans l’écosystème .NET actuel, xUnit est un excellent choix. Si vous travaillez sur une base de code existante qui utilise déjà NUnit, ou si vous appréciez sa flexibilité et ses attributs plus explicites, NUnit reste parfaitement valable.

Le plus important est la cohérence de l’équipe. Mélanger les styles sans raison peut créer de la confusion. Un projet bien tenu choisit un framework, fixe des conventions simples, et les applique avec régularité. Les tests gagnent alors en lisibilité, en maintenabilité et en valeur collective.

Dans de nombreux cas, le vrai facteur de succès n’est pas le framework lui-même, mais la qualité de l’intention. Un bon test écrit avec xUnit est meilleur qu’un mauvais test écrit avec NUnit, et inversement. Le framework aide, mais la discipline humaine reste décisive.

Conclusion

Tester des applications .NET avec xUnit et NUnit, ce n’est pas seulement apprendre une syntaxe de plus. C’est adopter une manière de construire du logiciel plus sereine, plus robuste et plus prévisible. Les tests unitaires donnent de la confiance. Ils protègent contre les régressions. Ils aident à refactorer. Ils documentent le comportement réel du système. Et, surtout, ils permettent de travailler avec plus de calme dans un environnement où tout change vite.

xUnit et NUnit offrent tous deux une base solide pour bâtir des suites de tests efficaces. xUnit séduit par sa simplicité et son style moderne. NUnit séduit par sa maturité et sa richesse. Dans les deux cas, l’essentiel est de garder des tests lisibles, isolés, bien nommés et réellement utiles au projet. Les mocks, les tests paramétrés, les scénarios asynchrones, les cas limites et l’intégration CI/CD complètent l’ensemble pour former une stratégie de qualité cohérente.

#tester applications .NET #xUnit #NUnit #tests unitaires .NET #tests automatisés C# #TDD .NET #mock .NET #Moq #tests async .NET #CI/CD .NET #qualité logicielle #stratégie de test

Abonnez-vous à notre newsletter

12k+

Abonnés

Hebdomadaire

Fréquence

Gratuit

Toujours