Meilleures pratiques pour les tests unitaires avec .NET Core et .NET Standard

Il existe de nombreux avantages à écrire des tests unitaires. Ils facilitent la régression, fournissent de la documentation et simplifient la conception. Toutefois, les tests unitaires difficiles à lire et fragiles peuvent faire des ravages dans votre code base. Cet article décrit certaines meilleures pratiques concernant la conception de tests unitaires pour vos projets .NET Core et .NET Standard.

Dans ce guide, vous allez découvrir certaines bonnes pratiques à suivre pour écrire des tests unitaires résilients et faciles à comprendre.

Par John Reese avec des remerciements particuliers à Roy Osherove

Pourquoi un test unitaire ?

Il existe plusieurs raisons d’utiliser des tests unitaires.

Moins de temps pour effectuer des tests fonctionnels

Les tests fonctionnels sont coûteux. Ils impliquent généralement d’ouvrir l’application et d’effectuer une série d’étapes que vous (ou quelqu’un d’autre) devez suivre pour valider le comportement attendu. Ces étapes peuvent ne pas toujours être connues du testeur. Celui-ci devra contacter quelqu’un de plus compétent dans le domaine pour effectuer le test. Le test lui-même peut prendre quelques secondes pour des changements mineurs, ou quelques minutes pour des changements plus importants. Enfin, ce processus doit être répété pour chaque changement apporté au système.

Les tests unitaires, eux, prennent quelques millisecondes, peuvent être exécutés en appuyant sur un simple bouton, et ne nécessitent pas forcément de connaissances générale du système. La réussite ou non du test dépend du programme d’exécution de tests et non de la personne qui effectue le test.

Protection contre la régression

Les défauts de régression sont des défauts introduits quand un changement est apporté à l’application. Il est courant pour les testeurs de tester non seulement les nouvelles fonctionnalités, mais également les fonctionnalités antérieures afin de vérifier que ces dernières fonctionnent toujours comme prévu.

Avec les tests unitaires, vous pouvez réexécuter l’intégralité de votre suite de tests après chaque build ou même après avoir changé une ligne de code. Ainsi, vous avez l’assurance que votre nouveau code ne perturbe pas les fonctionnalités existantes.

Documentation exécutable

Il n’est pas toujours évident de déterminer ce que fait une méthode particulière, ou comment elle se comporte en fonction d’une entrée spécifique. Vous vous posez peut-être la question suivante : comment se comporte cette méthode si je lui donne une chaîne vide ? Null ?

Quand vous avez une suite de tests unitaires correctement nommés, chaque test doit pouvoir expliquer clairement la sortie attendue pour une entrée donnée. De plus, il doit pouvoir vérifier son bon fonctionnement.

Code moins couplé

Quand le code est fortement couplé, il peut être difficile d’effectuer des tests unitaires. Si vous ne créez pas de tests unitaires pour le code que vous écrivez, le couplage peut être moins apparent.

L’écriture de tests pour votre code permet de le découpler de manière naturelle. Sinon, il est plus difficile à tester.

Caractéristiques d’un bon test unitaire

  • Rapide : il n’est pas rare pour des projets matures de comporter des milliers de tests unitaires. Les tests unitaires doivent durer très peu de temps. Millisecondes.
  • Isolés : les tests unitaires sont autonomes et peuvent être exécutés de manière isolée. Ils ne dépendent d’aucun facteur externe, par exemple un système de fichiers ou une base de données.
  • Renouvelable : l’exécution d’un test unitaire doit être cohérente avec ses résultats. En d’autres termes, il retourne toujours le même résultat si vous ne changez rien entre les exécutions.
  • Auto-vérification : le test doit pouvoir détecter automatiquement son état de réussite ou d’échec sans aucune interaction humaine.
  • Délai raisonnable : écrire un test unitaire ne doit pas prendre un temps disproportionné par rapport au test du code. Si vous trouvez que le test du code prend beaucoup de temps par rapport à son écriture, optez pour une conception plus facile à tester.

Couverture du code

Un pourcentage élevé de couverture du code est souvent associé à une qualité de code plus élevée. Toutefois, la mesure elle-même ne peut pas déterminer la qualité du code. La définition d’un objectif de pourcentage de couverture de code trop ambitieux peut être contre-productif. Imaginez un projet complexe avec des milliers de branches conditionnelles et imaginez que vous définissez un objectif de couverture de code de 95 %. Actuellement, le projet conserve une couverture de code de 90 %. Le temps nécessaire pour tenir compte de tous les cas de périphérie dans les 5 % restants pourrait être interminable, et la proposition de valeur diminue rapidement.

Un pourcentage de couverture de code élevé n’est pas un indicateur de réussite, et n’implique pas une qualité de code élevée. Il représente simplement la quantité de code couverte par les tests unitaires. Pour plus d’informations, consultez la couverture du code de test unitaire.

Parlons la même langue

Le terme fictif est malheureusement utilisé à mauvais escient quand il s’agit de tests. Voici une définition des types les plus courants de faux lors de l’écriture de tests unitaires :

Faux : l s’agit d’un terme générique qui permet de décrire un stub ou un objet fictif. Qu’il s’agisse d’un stub ou d’un objet fictif dépend du contexte dans lequel il est utilisé. En d’autres termes, un fake (élément fictif) peut être un stub ou un mob (objet fictif).

Mock - Il s’agit d’un objet fictif du système qui détermine la réussite ou l’échec d’un test unitaire. Un objet fictif est d’abord un faux jusqu’à ce qu’il ne le soit plus.

Stub - Un stub permet de remplacer de manière contrôlée une dépendance existante (ou collaborateur) dans le système. À l’aide d’un stub, vous pouvez tester votre code sans avoir à gérer directement la dépendance. Par défaut, un stub commence est d’abord un faux.

Prenez l'exemple de l'extrait de code suivant :

var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

L’exemple précédent démontre un stub décrit comme objet fictif. Dans ce cas, il s’agit bien d’un stub. Vous passez simplement Order pour instancier Purchase (le système testé). Le nom MockOrder est également trompeur, car encore une fois, l’ordre n’est pas un objet fictif.

Il existe une meilleure approche à cela :

var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

En renommant la classe en FakeOrder, vous rendez la classe beaucoup plus générique. La classe peut être utilisée comme un objet fictif ou un stub, selon la meilleure option pour le cas de test. Dans l’exemple précédent, FakeOrder est utilisé comme stub. N’utilisez pas FakeOrder sous quelque forme que ce soit durant l’assertion. FakeOrder est simplement passé à la classe Purchase pour répondre aux exigences du constructeur.

Pour l’utiliser en tant que fictif, vous pouvez faire quelque chose comme ceci :

var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

Dans ce cas, vous vérifiez une propriété sur le faux (en le différenciant). Ainsi, dans l’extrait de code ci-dessus, le mockOrder est un fictif.

Important

Il est important que cette terminologie soit correcte. Si vous appelez vos stubs « fictifs », les autres développeurs se tromperont sur votre intention.

Le point principal à retenir à propos des fictifs et des stubs est que les fictifs sont comme des stubs, mais vous différenciez le fictif par assertion, contrairement au stub.

Meilleures pratiques

Voici quelques-unes des meilleures pratiques les plus importantes pour l’écriture de tests unitaires.

Éviter des dépendances d’infrastructure

Essayez de ne pas introduire de dépendances à l’infrastructure quand vous écrivez des tests unitaires. Les dépendances rendent les tests lents et fragiles et doivent être réservés aux tests d’intégration. Vous pouvez éviter ces dépendances dans votre application en suivant le principe des dépendances explicites et en utilisant l’injection de dépendances. Vous pouvez également conserver vos tests unitaires dans un projet distinct de vos tests d’intégration. Cette approche garantit que votre projet de test unitaire n’a pas de références ni de dépendances sur les packages d’infrastructure.

Nommage de vos tests

Le nom de votre test doit être composé de trois parties :

  • Nom de la méthode testée
  • Scénario de test utilisé
  • Comportement attendu quand le scénario est appelé

Pourquoi ?

Les standards de nommage sont importants, car ils expriment explicitement la finalité du test. Les tests ne se limitent pas à la vérification du bon fonctionnement de votre code, ils fournissent également de la documentation. En examinant simplement la suite de tests unitaires, vous devez pouvoir en déduire le comportement de votre code. De plus, en cas d’échec des tests, vous pouvez voir exactement quels sont les scénarios qui ne répondent pas à vos attentes.

Mauvais :

[Fact]
public void Test_Single()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Mieux :

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Organisation de vos tests

Organisation, Action, Assertion est un modèle courant pour les tests unitaires. Comme son nom l’indique, il comporte trois actions principales :

  • Organisez vos objets, créez et configurez-les si nécessaire.
  • Action sur un objet
  • Assertion de ce qui est prévu

Pourquoi ?

  • Permet de séparer clairement ce qui est testé des étapes organisation et assertion.
  • Moins de risques de mélanger les assertions avec le code de l’étape « Action ».

La lisibilité est l’un des aspects les plus importants durant l’écriture d’un test. La séparation de chacune de ces actions dans le test met clairement en évidence les dépendances nécessaires à l’appel du code, le mode d’appel du code, ainsi que le contenu de l’assertion. Bien qu’il soit possible de combiner certaines étapes et de réduire la taille du test, l’objectif principal est de rendre le test aussi lisible que possible.

Mauvais :

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Assert
    Assert.Equal(0, stringCalculator.Add(""));
}

Mieux :

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add("");

    // Assert
    Assert.Equal(0, actual);
}

Écrire des tests concluants minimaux

L’entrée à utiliser dans un test unitaire doit être la plus simple possible pour vérifier le comportement testé.

Pourquoi ?

  • Les tests deviennent plus résilients face aux futurs changements du code base.
  • Plus proche du comportement de test que de l’implémentation.

Les tests qui contiennent plus d’informations que nécessaire pour être réussis ont plus de chances d’introduire des erreurs et peuvent rendre l’intention moins claire. Quand vous écrivez des tests, vous devez vous concentrer sur le comportement. La définition de propriétés supplémentaires pour les modèles ou l’utilisation de valeurs différentes de zéro quand cela n’est pas nécessaire, ne fait que nuire à ce que vous essayez de prouver.

Mauvais :

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("42");

    Assert.Equal(42, actual);
}

Mieux :

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Éviter les chaînes magiques

Le nommage des variables dans les tests unitaires est aussi important, sinon plus, que le nommage des variables dans le code de production. Les tests unitaires ne doivent pas contenir de chaînes magiques.

Pourquoi ?

  • Évite au lecteur du test d’inspecter le code de production pour déterminer ce qui rend la valeur spéciale.
  • Montre explicitement ce que vous essayez de prouver et non ce que vous essayez d’accomplir.

Les chaînes magiques peuvent être sources de confusion pour le lecteur de vos tests. Si une chaîne semble inhabituelle, on peut se demander pourquoi une certaine valeur a été choisie pour un paramètre ou une valeur de retour. Ce type de valeur de chaîne peut amener à examiner de plus près les détails de l’implémentation, plutôt que de se concentrer sur le test.

Conseil

Quand vous écrivez des tests, concentrez-vous au maximum sur l’expression de l’intention. Dans le cas des chaînes magiques, une bonne approche consiste à assigner ces valeurs à des constantes.

Mauvais :

[Fact]
public void Add_BigNumber_ThrowsException()
{
    var stringCalculator = new StringCalculator();

    Action actual = () => stringCalculator.Add("1001");

    Assert.Throws<OverflowException>(actual);
}

Mieux :

[Fact]
void Add_MaximumSumResult_ThrowsOverflowException()
{
    var stringCalculator = new StringCalculator();
    const string MAXIMUM_RESULT = "1001";

    Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);

    Assert.Throws<OverflowException>(actual);
}

Éviter la logique dans les tests

Durant l’écriture des tests unitaires, évitez la concaténation manuelle des chaînes et les conditions logiques telles que if, while, for, switch, etc.

Pourquoi ?

  • Moins de risques d’introduire un bogue dans vos tests.
  • Concentrez-vous sur le résultat final plutôt que sur les détails de l’implémentation.

Quand vous introduisez une logique dans votre suite de tests, le risque d’introduction d’un bogue augmente considérablement. Votre suite de tests est bien le dernier endroit où vous devez trouver un bogue. Le fonctionnement de vos tests doit présenter un niveau de fiabilité élevé, ou vous n’aurez pas confiance en ces derniers. Les tests que vous n’approuvez pas n’ont aucune valeur. L’échec d’un test vous donne l’impression que quelque chose ne va pas dans votre code, et que vous ne pouvez pas l’ignorer.

Conseil

Si le recours à la logique dans votre test semble inévitable, scindez-le en deux ou plusieurs tests distincts.

Mauvais :

[Fact]
public void Add_MultipleNumbers_ReturnsCorrectResults()
{
    var stringCalculator = new StringCalculator();
    var expected = 0;
    var testCases = new[]
    {
        "0,0,0",
        "0,1,2",
        "1,2,3"
    };

    foreach (var test in testCases)
    {
        Assert.Equal(expected, stringCalculator.Add(test));
        expected += 3;
    }
}

Mieux :

[Theory]
[InlineData("0,0,0", 0)]
[InlineData("0,1,2", 3)]
[InlineData("1,2,3", 6)]
public void Add_MultipleNumbers_ReturnsSumOfNumbers(string input, int expected)
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add(input);

    Assert.Equal(expected, actual);
}

Préférez les méthodes d’assistance à setup et teardown

Si vous avez besoin d’un objet ou d’un état similaire pour vos tests, préférez une méthode d’assistance à l’utilisation des attributs Setup et Teardown s’ils existent.

Pourquoi ?

  • Moins de confusion durant la lecture des tests, car l’ensemble du code est visible à partir de chaque test.
  • Moins de risques d’effectuer une configuration excessive ou insuffisante pour le test donné.
  • Moins de risques de partager l’état entre les tests, ce qui entraîne la création de dépendances indésirables entre eux.

Dans les frameworks de tests unitaires, Setup est appelé avant chaque test unitaire de votre suite de tests. Bien que certains puissent le voir comme un outil utile, cela aboutit généralement à des tests compliqués et difficiles à lire. Chaque test a généralement des exigences différentes pour être opérationnel. Malheureusement, Setup vous oblige à utiliser exactement les mêmes exigences pour chaque test.

Notes

xUnit a supprimé SetUp et TearDown depuis la version 2.x

Mauvais :

private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
    stringCalculator = new StringCalculator();
}
// more tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var result = stringCalculator.Add("0,1");

    Assert.Equal(1, result);
}

Mieux :

[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var stringCalculator = CreateDefaultStringCalculator();

    var actual = stringCalculator.Add("0,1");

    Assert.Equal(1, actual);
}
// more tests...
private StringCalculator CreateDefaultStringCalculator()
{
    return new StringCalculator();
}

Éviter les actes multiples

Lorsque vous écrivez vos tests, essayez d’inclure un seul acte par test. Les approches courantes de l’utilisation d’un seul acte sont les suivantes :

  • Créez un test distinct pour chaque acte.
  • Utilisez des tests paramétrables.

Pourquoi ?

  • Lorsque le test échoue, vous savez de quel acte il s’agit.
  • Garantit que le test se concentre sur un seul cas.
  • Vous donne une idée complète des causes de l’échec des tests.

Plusieurs actes doivent être individuellement déclarés et il n’est pas garanti que toutes les assertions seront exécutées. Dans la plupart des frameworks de test unitaire, une fois qu’une assertion échoue dans un test unitaire, les tests de procédure sont automatiquement considérés comme ayant échoué. Cela peut être déroutant, car les fonctionnalités qui fonctionnent réellement indiquent un état d’échec.

Mauvais :

[Fact]
public void Add_EmptyEntries_ShouldBeTreatedAsZero()
{
    // Act
    var actual1 = stringCalculator.Add("");
    var actual2 = stringCalculator.Add(",");

    // Assert
    Assert.Equal(0, actual1);
    Assert.Equal(0, actual2);
}

Mieux :

[Theory]
[InlineData("", 0)]
[InlineData(",", 0)]
public void Add_EmptyEntries_ShouldBeTreatedAsZero(string input, int expected)
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add(input);

    // Assert
    Assert.Equal(expected, actual);
}

Valider les méthodes privées en effectuant un test unitaire des méthodes publiques

Dans la plupart des cas, il n’est pas nécessaire de tester une méthode privée. Les méthodes privées sont un détail d’implémentation et n’existent jamais de manière isolée. À un moment donné, une méthode publique appelle la méthode privée dans le cadre de son implémentation. Vous devez prendre en compte le résultat final de la méthode publique qui appelle la méthode privée.

Prenons le cas suivant :

public string ParseLogLine(string input)
{
    var sanitizedInput = TrimInput(input);
    return sanitizedInput;
}

private string TrimInput(string input)
{
    return input.Trim();
}

Votre première réaction peut être de commencer à écrire un test pour TrimInput, car vous souhaitez vérifier que la méthode fonctionne comme prévu. Toutefois, il est tout à fait possible que ParseLogLine manipule sanitizedInput de manière inattendue, ce qui rend tout test de TrimInput inutile.

Le véritable test doit être effectué sur la méthode publique ParseLogLine, car c’est ce qui vous intéresse en fin de compte.

public void ParseLogLine_StartsAndEndsWithSpace_ReturnsTrimmedResult()
{
    var parser = new Parser();

    var result = parser.ParseLogLine(" a ");

    Assert.Equals("a", result);
}

Ainsi, si vous voyez une méthode privée, recherchez la méthode publique et écrivez vos tests par rapport à cette méthode. Le fait qu’une méthode privée retourne le résultat attendu ne signifie pas que le système qui appelle la méthode privée utilise ce résultat correctement.

Références statiques de stub

L’un des principes d’un test unitaire est qu’il doit avoir le contrôle total du système testé. Ce principe peut être problématique quand le code de production inclut des appels à des références statiques (par exemple DateTime.Now). Prenez le code suivant :

public int GetDiscountedPrice(int price)
{
    if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

Comment ce code peut-il faire l’objet d’un test unitaire ? Vous pouvez essayer cette approche :

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(2, actual)
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(1, actual);
}

Malheureusement, vous allez vite réaliser que vos tests posent quelques problèmes.

  • Si la suite de tests est exécutée un mardi, le second test est une réussite, mais le premier test est un échec.
  • Si la suite de tests est exécutée un autre jour, le premier test est une réussite, mais le second test est un échec.

Pour résoudre ces problèmes, vous allez devoir introduire un seam dans votre code de production. Il existe une approche qui consiste à inclure dans un wrapper le code que vous devez contrôler dans une interface, et à faire en sorte que le code de production dépende de cette interface.

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

public int GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider)
{
    if (dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

Votre suite de tests devient désormais la suivante :

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Monday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(2, actual);
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(1, actual);
}

Désormais, la suite de tests a un contrôle total sur DateTime.Now et peut créer un stub de n’importe quelle valeur au moment d’appeler la méthode.