Page chargée : Article

Blog

Code coverage en .NET : pourquoi 100 % ne suffit pas à juger tes tests

Code coverage, assertions, cas limites et doubles de test en .NET : plusieurs exemples C# et xUnit pour comprendre ce que tes tests protègent réellement.

Ton code coverage peut monter sans que tes tests détectent davantage de bugs.

Tu ajoutes des tests, le pourcentage augmente, le pipeline est content. C’est plutôt rassurant. Le problème commence quand ce chiffre devient la réponse à une autre question : « Est-ce que mes tests verraient passer une erreur dans cette règle métier ? »

Pour répondre à ça, il faut regarder les cas testés et ce qu’on vérifie réellement. Je te montre plusieurs situations en C# : une limite oubliée malgré des assertions correctes, un résultat vérifié trop vaguement et un calcul remplacé par un double de test. On verra aussi comment le chiffre global peut monter sans que la partie la plus importante soit mieux testée.

Ce que mesure le code coverage

Le code coverage mesure ce que tes tests ont exécuté. Selon le rapport, tu regardes notamment les lignes, les méthodes ou les branches couvertes. La documentation .NET détaille ces mesures.

Une ligne couverte a été exécutée pendant les tests. Pour une condition avec deux chemins, le branch coverage permet de voir si les tests sont passés par chacun d’eux.

C’est utile pour repérer un bout de code que personne n’exerce. Une branche d’erreur jamais traversée mérite qu’on s’y intéresse.

Mais le rapport ne connaît pas le besoin métier. Il ne sait pas si tu as choisi les bonnes valeurs, si l’assertion vérifie le bon résultat ou si une règle entière a été oubliée avant même d’être codée. Il mesure l’exécution de ce qui existe.

Deux tests corrects, un cas oublié

Imaginons une règle de frais de livraison : 5 euros en dessous de 100 euros de commande, livraison gratuite à partir de 100 euros.

On se concentre ici sur le calcul des frais. Le montant de la commande a déjà été validé avant cet appel.

public static class ShippingCosts
{
    public static decimal Calculate(decimal subtotal)
    {
        return subtotal >= 100m ? 0m : 5m;
    }
}

On écrit deux tests xUnit : une commande à 50 euros et une à 150 euros.

using Xunit;

public class ShippingCostsTests
{
    [Fact]
    public void Below_threshold_shipping_costs_five_euros()
    {
        var shippingCosts = ShippingCosts.Calculate(50m);

        Assert.Equal(5m, shippingCosts);
    }

    [Fact]
    public void Above_threshold_shipping_is_free()
    {
        var shippingCosts = ShippingCosts.Calculate(150m);

        Assert.Equal(0m, shippingCosts);
    }
}

Ces tests sont utiles. Ils vérifient des montants précis, pas seulement que le résultat est positif ou qu’aucune exception n’a été levée. Sur cette méthode isolée, ils couvrent toutes les lignes et les deux branches de la condition.

Maintenant, remplace temporairement >= par > dans le code :

return subtotal > 100m ? 0m : 5m;

Les deux tests passent toujours. À 50 euros, la livraison coûte 5 euros. À 150 euros, elle est gratuite. Le code coverage de cette méthode reste à 100 % pour les lignes et les branches.

Pourtant, une commande à exactement 100 euros paie désormais la livraison. La règle dit « à partir de 100 », le code fait « au-dessus de 100 ». Le rapport n’a aucune raison de protester : ses deux chemins ont bien été exécutés.

Voilà le trou. Il est dans les cas testés, même si aucune ligne ne manque dans le rapport.

Le test qui protège la limite

On ajoute le cas à exactement 100 euros dans la même classe de tests :

[Fact]
public void At_threshold_shipping_is_free()
{
    var shippingCosts = ShippingCosts.Calculate(100m);

    Assert.Equal(0m, shippingCosts);
}

Avec le mauvais opérateur >, ce test échoue : il attend 0 euro, le code renvoie 5 euros. On remet ensuite >=, et les trois tests passent.

Ce troisième test n’a pas augmenté le code coverage de la méthode. Les lignes et les branches étaient déjà couvertes. Il a quand même ajouté une protection : une erreur sur l’inclusion de la limite ne passe plus inaperçue.

C’est pour ça que je préfère discuter de la règle et des cas attendus avant de discuter du pourcentage. « En dessous », « exactement à la limite » et « au-dessus » représentent ici trois situations métier, même si le code n’a que deux branches.

Line coverage et branch coverage ne racontent pas la même chose

Dans cette méthode, un seul test avec une commande à 50 euros suffit à exécuter la ligne du return. Ça ne prouve pas que le chemin de la livraison gratuite a été testé.

Le branch coverage apporte une information supplémentaire : est-ce qu’on a exercé le côté vrai et le côté faux de la condition ? Il permet de repérer ce trou-là. Mais une fois les deux branches parcourues, il ne distingue pas les différentes valeurs qui prennent le même chemin.

Sur notre méthode, voilà ce que ça donne. Ces chiffres ont été vérifiés avec Coverlet et portent uniquement sur ShippingCosts.Calculate :

Montants testés Line coverage Branch coverage Cas exactement au seuil
50 euros 100 % 50 % Absent
50 et 150 euros 100 % 100 % Absent
50, 100 et 150 euros 100 % 100 % Vérifié

Le dernier test apporte une protection sans changer les deux pourcentages. Le rapport a atteint sa limite d’information. Il faut maintenant lire la règle, les entrées choisies et les assertions.

Sur un vrai projet, précise aussi de quel indicateur tu parles. « On a 90 % de coverage » ne veut pas dire grand-chose si une personne regarde les lignes et une autre les branches, ou si leurs rapports ne portent pas sur les mêmes assemblies.

Une assertion trop large laisse passer le mauvais montant

Le cas oublié n’est pas la seule façon de laisser passer une erreur. Tu peux avoir choisi les bonnes entrées et mal vérifier les sorties.

Reprenons les mêmes frais de livraison. Un test pourrait se contenter de vérifier qu’ils ne sont pas négatifs :

[Theory]
[InlineData(50)]
[InlineData(100)]
[InlineData(150)]
public void Shipping_costs_are_not_negative(int subtotal)
{
    var shippingCosts = ShippingCosts.Calculate(subtotal);

    Assert.True(shippingCosts >= 0m);
}

Cette fois, on a même pensé à la commande de 100 euros. Mais si ces assertions sont les seules qu’on utilise, elles acceptent aussi bien 0, 5 que 50 euros de frais.

Change temporairement le calcul pour qu’il facture 50 euros au lieu de 5 sous le seuil :

return subtotal >= 100m ? 0m : 50m;

Ce test passe toujours pour ses trois entrées. Il vérifie bien quelque chose : l’absence de montant négatif. Il ne vérifie pas les frais annoncés à l’utilisateur. Celui qui paie dix fois trop cher aura peut-être une autre définition d’un résultat correct.

Les assertions Assert.Equal(5m, shippingCosts) et Assert.Equal(0m, shippingCosts) des premiers tests détectent cette différence. Le choix de l’assertion dépend donc de ce que tu veux protéger. Si la règle fixe un montant, vérifier seulement son signe ou qu’un objet existe ne suffit pas.

Même logique pour une liste : « elle n’est pas vide » ne vérifie ni les éléments attendus, ni les éléments interdits. Ce n’est pas une raison pour bannir ce type d’assertion. Il faut simplement savoir quelle partie du contrat elle vérifie, et ce qu’elle laisse passer.

Un double de test ne valide pas le calcul qu’il remplace

Les mocks et les stubs ajoutent un autre piège : on peut croire avoir vérifié une règle alors qu’on a soi-même fourni sa réponse.

Imaginons que le calcul du total passe par un service de frais de livraison. Pour cet exemple, le total est simplement le montant de la commande plus les frais :

public interface IShippingCosts
{
    decimal Calculate(decimal subtotal);
}

public sealed class Checkout(IShippingCosts shippingCosts)
{
    public decimal CalculateTotal(decimal subtotal)
    {
        return subtotal + shippingCosts.Calculate(subtotal);
    }
}

On isole Checkout avec un petit stub. Il renvoie toujours 5 euros, quelle que soit la commande. Aucun framework de mocking n’est nécessaire pour le montrer :

using Xunit;

public class CheckoutTests
{
    private sealed class FixedShippingCosts : IShippingCosts
    {
        public decimal Calculate(decimal subtotal) => 5m;
    }

    [Fact]
    public void Total_includes_shipping_costs()
    {
        var checkout = new Checkout(new FixedShippingCosts());

        var total = checkout.CalculateTotal(50m);

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

Ce test vérifie que Checkout ajoute les frais que son service lui fournit. C’est son rôle, et il est utile : si l’addition disparaît, il échoue.

En revanche, la règle « livraison gratuite à partir de 100 euros » n’est pas exécutée ici. Si tu la casses dans l’implémentation réelle de IShippingCosts, ce test reste vert. Le stub continue de renvoyer les 5 euros qu’on lui a demandés.

Le même raisonnement vaut avec un mock configuré pour retourner une valeur. Vérifier l’appel ou additionner cette valeur peut protéger l’orchestration. Ça ne teste pas le code de la dépendance remplacée.

Garde donc les tests du vrai calcul à côté. Et quand le branchement entre les composants fait partie de ce que tu veux vérifier, ajoute un test avec l’implémentation réelle sur ce parcours. L’intérêt est de savoir où chaque règle est exercée, pas de transformer tous les tests unitaires en tests d’intégration.

Le chiffre global peut monter loin du problème

Le pourcentage global mélange aussi des morceaux de code dont les conséquences ne sont pas les mêmes. Une ligne qui choisit si la livraison est gratuite et une ligne qui copie un libellé peuvent chacune compter comme une ligne couverte.

Prenons un exemple arithmétique, volontairement simplifié. Ton rapport porte sur 100 lignes exécutables : 90 sont couvertes, 10 ne le sont pas. Parmi ces 10, il y a des règles importantes.

Tu ajoutes ensuite 100 lignes de mapping, toutes exercées par les tests. Voilà l’évolution du rapport :

État Lignes couvertes Lignes exécutables Code coverage global
Avant 90 100 90 %
Après 190 200 95 %

Le chiffre a pris cinq points. Les dix lignes oubliées sont toujours oubliées.

Ça ne veut pas dire que tester les mappings est inutile. Ça veut dire que la hausse globale ne prouve pas que les règles importantes sont mieux protégées. Le rapport mélange tout ; ta review doit encore regarder la partie concernée par le changement.

Pour choisir le prochain test à écrire, pars plutôt d’une erreur que le logiciel pourrait laisser passer et de sa conséquence. Sur notre exemple, faire payer la livraison à une commande de 100 euros donne une raison précise d’ajouter un cas. « Il manque deux points pour faire passer la CI » ne décrit pas aussi bien le problème.

Vérifier que le test peut détecter l’erreur

Tu peux faire cet exercice sur une règle de ton projet, dans une modification locale que tu annules ensuite.

  1. Décris le comportement attendu. Dans notre exemple, une commande à 100 euros ne paie pas de frais de livraison.
  2. Introduis une faute précise dans le code. Le passage de >= à > change réellement ce comportement.
  3. Lance les tests sans toucher à leurs attentes. Le test doit échouer parce que le résultat est incorrect. Une erreur de compilation ne démontre pas qu’il détecte la faute métier.
  4. Complète le cas manquant si tout reste vert. Vérifie qu’il échoue avec la faute, puis restaure le code correct et relance les tests.

L’intérêt est de poser une question très concrète : « Si quelqu’un faisait cette erreur demain, quel test nous préviendrait ? »

Si tu ne peux pas répondre, le pourcentage ne va pas trouver le cas à ta place.

Le mutation testing automatise cet exercice

C’est le principe du mutation testing : introduire de petites modifications dans le code, puis lancer les tests pour voir s’ils les détectent. Changer un opérateur de comparaison, inverser une condition ou supprimer une instruction, par exemple.

Si un test échoue à cause de la modification, le mutant est détecté. Si les tests restent verts, le mutant a survécu et mérite qu’on regarde pourquoi. Notre remplacement de >= par > est exactement ce genre de modification.

En .NET, Stryker.NET permet de l’automatiser. Tu peux commencer sur une petite partie du domaine où une régression aurait des conséquences importantes, plutôt que de lancer immédiatement l’exercice sur toute la solution.

Il faut aussi lire les résultats : certaines modifications ne changent aucun comportement observable. Un mutant survivant demande une analyse, pas systématiquement un test de plus. Et les tests sont relancés sur plusieurs versions du code, donc cet exercice a un coût d’exécution.

Le but reste de trouver les erreurs que tes tests laissent passer. Transformer le score de mutation en nouveau chiffre à satisfaire nous ramènerait au même problème.

Ce que je regarderais dans une review

Pour éviter de finir la discussion à « le pipeline est vert », je regarderais quatre choses.

  • La règle est-elle explicite ? Dans notre exemple, « à partir de 100 euros » inclut exactement 100. Si personne ne sait trancher ce cas, il manque encore une décision métier.
  • Les entrées exercent-elles les situations qui comptent ? Les deux côtés du seuil et le seuil lui-même, ici. Ajouter plusieurs valeurs loin de la limite ne remplace pas ce cas.
  • Les assertions portent-elles sur le résultat attendu ? Un montant précis quand la règle fixe un montant. Des éléments identifiés quand le contrat fixe le contenu d’une liste.
  • Le vrai comportement est-il testé quelque part ? Si un stub fournit les frais dans le test de Checkout, il faut savoir quels tests exercent le vrai calcul des frais.

Le rapport de code coverage aide à compléter cette lecture. Il peut montrer une branche jamais exécutée ou une nouvelle méthode sans test. Il ne remplace aucune de ces questions.

À quoi sert encore le code coverage ?

À ce qu’il sait faire : montrer les zones que tes tests n’exécutent pas et aider à orienter la lecture du code.

Une règle importante sans aucun test, une branche de refus jamais traversée ou une nouvelle feature dont seul le parcours heureux est exercé : ce sont des points de départ utiles pour une review.

Un seuil dans la CI peut servir de garde-fou. Mais atteindre ce seuil ne termine pas la discussion. Sur le comportement qui vient de changer, il faut encore pouvoir expliquer les cas acceptés, les cas refusés et les limites, puis vérifier que les assertions portent sur le résultat attendu.

Dans notre exemple, le cas à 100 euros aurait dû venir de la lecture de la règle « à partir de 100 ». Le rapport de coverage ne pouvait pas le déduire à notre place.

Le mot de la fin

Je ne te conseille pas de jeter ton rapport de code coverage. Regarde-le pour trouver ce que tes tests ne traversent pas. Puis regarde les tests pour comprendre ce qu’ils vérifient et quelle erreur les ferait échouer.

On peut avoir 100 % sur les lignes et les branches d’une méthode, ajouter un test sans gagner le moindre point, et pourtant mieux protéger le métier. On peut aussi augmenter le chiffre global sans toucher aux règles qui nous préoccupent.

Le pourcentage ne choisira pas les bonnes assertions ni les bons cas à ta place.