Quatre types courants de couverture de code

Apprenez en quoi consiste la couverture de code et découvrez quatre façons courantes de la mesurer.

Avez-vous déjà entendu l'expression "couverture de code" ? Dans cet article, nous allons découvrir ce qu'est la couverture de code dans les tests et quatre façons courantes de la mesurer.

Qu'est-ce que la couverture de code ?

La couverture de code est une métrique qui mesure le pourcentage de code source exécuté par vos tests. Cela vous aide à identifier les domaines qui pourraient ne pas être testés de manière appropriée.

L'enregistrement de ces métriques se présente souvent comme suit:

Fichier Relevés du pourcentage Pourcentage de branche Fonctions% Pourcentage de lignes Lignes non couvertes
file.js 90 % 100 % 90 % 80 % 89 256
coffee.js 55,55% 80 % 50 % 62,5% 10-11, 18

À mesure que vous ajoutez de nouvelles fonctionnalités et de nouveaux tests, l'augmentation des pourcentages de couverture du code vous garantit que votre application a été testée en profondeur. Toutefois, il reste encore beaucoup à découvrir.

Quatre types courants de couverture de code

Il existe quatre façons courantes de collecter et de calculer la couverture de code: la couverture des fonctions, des lignes, des branches et des instructions.

Quatre types de couverture de texte.

Pour voir comment chaque type de couverture de code calcule son pourcentage, prenons l'exemple de code suivant pour calculer les ingrédients du café:

/* coffee.js */

export function calcCoffeeIngredient(coffeeName, cup = 1) {
  let espresso, water;

  if (coffeeName === 'espresso') {
    espresso = 30 * cup;
    return { espresso };
  }

  if (coffeeName === 'americano') {
    espresso = 30 * cup; water = 70 * cup;
    return { espresso, water };
  }

  return {};
}

export function isValidCoffee(name) {
  return ['espresso', 'americano', 'mocha'].includes(name);
}

Les tests qui vérifient la fonction calcCoffeeIngredient sont les suivants:

/* coffee.test.js */

import { describe, expect, assert, it } from 'vitest';
import { calcCoffeeIngredient } from '../src/coffee-incomplete';

describe('Coffee', () => {
  it('should have espresso', () => {
    const result = calcCoffeeIngredient('espresso', 2);
    expect(result).to.deep.equal({ espresso: 60 });
  });

  it('should have nothing', () => {
    const result = calcCoffeeIngredient('unknown');
    expect(result).to.deep.equal({});
  });
});

Vous pouvez exécuter le code et les tests dans cette démonstration en ligne ou consulter le dépôt.

Couverture des fonctions

Couverture de code: 50%

/* coffee.js */

export function calcCoffeeIngredient(coffeeName, cup = 1) {
  // ...
}

function isValidCoffee(name) {
  // ...
}

La couverture d'une fonction est une métrique simple. Il capture le pourcentage de fonctions appelées dans votre code par vos tests.

Dans l'exemple de code, il y a deux fonctions: calcCoffeeIngredient et isValidCoffee. Les tests n'appellent que la fonction calcCoffeeIngredient. La couverture de la fonction est donc de 50%.

Couverture des lignes

Couverture du code: 62,5%

/* coffee.js */

export function calcCoffeeIngredient(coffeeName, cup = 1) {
  let espresso, water;

  if (coffeeName === 'espresso') {
    espresso = 30 * cup;
    return { espresso };
  }

  if (coffeeName === 'americano') {
    espresso = 30 * cup; water = 70 * cup;
    return { espresso, water };
  }

  return {};
}

export function isValidCoffee(name) {
  return ['espresso', 'americano', 'mocha'].includes(name);
}

La couverture des lignes mesure le pourcentage de lignes de code exécutables que votre suite de tests a exécutées. Si une ligne de code n'est toujours pas exécutée, cela signifie qu'une partie du code n'a pas été testée.

L'exemple de code comporte huit lignes de code exécutable (en rouge et en vert), mais les tests n'exécutent pas la condition americano (deux lignes) ni la fonction isValidCoffee (une ligne). Cela se traduit par une couverture des lignes de 62,5%.

Notez que la couverture des lignes ne tient pas compte des instructions de déclaration, telles que function isValidCoffee(name) et let espresso, water;, car elles ne sont pas exécutables.

Couverture de la succursale

Couverture de code: 80%

/* coffee.js */

export function calcCoffeeIngredient(coffeeName, cup = 1) {
  // ...

  if (coffeeName === 'espresso') {
    // ...
    return { espresso };
  }

  if (coffeeName === 'americano') {
    // ...
    return { espresso, water };
  }

  return {};
}
…

La couverture des branches mesure le pourcentage de branches ou de points de décision exécutés dans le code (instructions ou boucles, par exemple). Elle détermine si les tests examinent à la fois les branches vraies et fausses des instructions conditionnelles.

L'exemple de code comporte cinq branches:

  1. Appel de calcCoffeeIngredient avec seulement coffeeName Marque.
  2. Appel du numéro calcCoffeeIngredient avec coffeeName et cup Marque....
  3. Le café est un expresso Marque.
  4. Le café est Americano Signe X.
  5. Autre café Marque.

Les tests couvrent toutes les branches, à l'exception de la condition Coffee is Americano. La couverture des succursales est donc de 80%.

Couverture du relevé

Couverture du code: 55,55%

/* coffee.js */

export function calcCoffeeIngredient(coffeeName, cup = 1) {
  let espresso, water;

  if (coffeeName === 'espresso') {
    espresso = 30 * cup;
    return { espresso };
  }

  if (coffeeName === 'americano') {
    espresso = 30 * cup; water = 70 * cup;
    return { espresso, water };
  }

  return {};
}

export function isValidCoffee(name) {
  return ['espresso', 'americano', 'mocha'].includes(name);
}

La couverture des déclarations mesure le pourcentage d'instructions dans votre code exécutées par vos tests. À première vue, vous vous demandez peut-être : "n'est-ce pas la même chose que la couverture des lignes ?". En effet, la couverture des déclarations est semblable à la couverture des lignes, mais elle tient compte des lignes de code individuelles qui contiennent plusieurs instructions.

Dans l'exemple de code, le code exécutable comporte huit lignes, mais neuf instructions. Pouvez-vous repérer la ligne contenant deux instructions ?

Vérifiez votre réponse

Il s'agit de la ligne suivante: espresso = 30 * cup; water = 70 * cup;

Les tests ne couvrent que cinq des neuf déclarations.La couverture est donc de 55, 55%.

Si vous écrivez toujours une déclaration par ligne, la couverture de votre ligne sera semblable à celle de votre relevé.

Quel type de couverture de code devez-vous choisir ?

La plupart des outils de couverture de code incluent ces quatre types courants de couverture de code. Le choix de la métrique de couverture de code à prioriser dépend des exigences spécifiques du projet, des pratiques de développement et des objectifs de test.

En général, la couverture des déclarations est un bon point de départ, car il s’agit d’une mesure simple et facile à comprendre. Contrairement à la couverture des instructions, la couverture des branches et celle des fonctions mesurent si les tests appellent une condition (branche) ou une fonction. Par conséquent, il s'agit d'une progression naturelle après la couverture des déclarations.

Une fois que vous avez obtenu une couverture élevée des relevés, vous pouvez passer à la couverture des succursales et des fonctions.

La couverture de test est-elle identique à la couverture de code ?

Non. La couverture de test et la couverture de code sont souvent confondues, mais ce sont des différences:

  • Couverture des tests: métrique qualitative permettant d'évaluer dans quelle mesure la suite de tests couvre les fonctionnalités du logiciel. Elle aide à déterminer le niveau de risque encouru.
  • Couverture de code: métrique quantitative qui mesure la proportion de code exécuté pendant les tests. Il s'agit de la quantité de code couverte par les tests.

Voici une analogie simplifiée: imaginez une application Web comme une maison.

  • La couverture de test mesure dans quelle mesure les tests couvrent les pièces de la maison.
  • La couverture de code mesure la portion de la maison par laquelle les tests ont été effectués.

Une couverture de code de 100% ne signifie pas qu'il n'y a pas de bug

Bien qu'il soit certainement souhaitable d'obtenir une couverture de code élevée lors des tests, une couverture de code complète ne garantit pas l'absence de bugs ou de failles dans votre code.

Un moyen dénué de sens d'atteindre 100% de couverture de code

Prenons le test suivant:

/* coffee.test.js */

// ...
describe('Warning: Do not do this', () => {
  it('is meaningless', () => { 
    calcCoffeeIngredient('espresso', 2);
    calcCoffeeIngredient('americano');
    calcCoffeeIngredient('unknown');
    isValidCoffee('mocha');
    expect(true).toBe(true); // not meaningful assertion
  });
});

Ce test couvre 100% des fonctions, des lignes, des branches et des instructions, mais il n'a aucun sens, car il ne teste pas le code. L'assertion expect(true).toBe(true) sera toujours transmise, que le code fonctionne ou non.

Une métrique médiocre est pire qu'aucune métrique

Une mauvaise métrique peut donner une fausse impression de sécurité, ce qui est pire que de n'en avoir aucune. Par exemple, si vous disposez d'une suite de tests qui atteint une couverture de code de 100 %, mais que les tests sont tous dénués de sens, vous pourriez avoir l'impression que votre code est bien testé. Si vous supprimez ou cassez accidentellement une partie du code de l'application, les tests réussissent, même si l'application ne fonctionne plus correctement.

Pour éviter ce scénario:

  • Examen test. Rédigez et examinez des tests pour vous assurer qu'ils sont pertinents, et testez le code dans différents scénarios.
  • Utilisez la couverture de code comme référence, et non comme seul indicateur de l'efficacité des tests ou de la qualité du code.

Utiliser la couverture de code dans différents types de tests

Examinons de plus près comment vous pouvez utiliser la couverture de code avec les trois types de test courants:

  • Tests unitaires. Il s'agit du meilleur type de test pour recueillir la couverture de code, car ils sont conçus pour couvrir plusieurs petits scénarios et chemins de test.
  • Tests d'intégration. Ils peuvent vous aider à collecter la couverture de code pour les tests d'intégration, mais utilisez-les avec prudence. Dans ce cas, vous calculez la couverture d'une plus grande partie du code source, et il peut être difficile de déterminer quels tests couvrent réellement quelles parties du code. Toutefois, le calcul de la couverture du code des tests d'intégration peut être utile pour les anciens systèmes qui ne comportent pas d'unités bien isolées.
  • Tests de bout en bout : Mesurer la couverture du code pour les tests de bout en bout n'est pas une mince affaire, en raison de la nature complexe de ces tests. Au lieu d'utiliser la couverture de code, la couverture des exigences peut être la meilleure solution. En effet, l'objectif des tests de bout en bout est de couvrir les exigences de votre test, et non de se concentrer sur le code source.

Conclusion

La couverture de code peut s'avérer une métrique utile pour mesurer l'efficacité de vos tests. Il peut vous aider à améliorer la qualité de votre application en vous assurant que la logique essentielle de votre code est bien testée.

Cependant, n'oubliez pas que la couverture de code n'est qu'une métrique. Veillez également à prendre en compte d'autres facteurs, tels que la qualité de vos tests et les exigences de votre application.

L'objectif n'est pas d'atteindre une couverture de code de 100 %. Vous devez plutôt utiliser la couverture de code avec un plan de test complet qui intègre diverses méthodes de test, y compris les tests unitaires, les tests d'intégration, les tests de bout en bout et les tests manuels.

Consultez l'exemple de code complet et les tests avec une bonne couverture de code. Vous pouvez également exécuter le code et les tests via cette démonstration en direct.

/* coffee.js - a complete example */

export function calcCoffeeIngredient(coffeeName, cup = 1) {
  if (!isValidCoffee(coffeeName)) return {};

  let espresso, water;

  if (coffeeName === 'espresso') {
    espresso = 30 * cup;
    return { espresso };
  }

  if (coffeeName === 'americano') {
    espresso = 30 * cup; water = 70 * cup;
    return { espresso, water };
  }

  throw new Error (`${coffeeName} not found`);
}

function isValidCoffee(name) {
  return ['espresso', 'americano', 'mocha'].includes(name);
}
/* coffee.test.js - a complete test suite */

import { describe, expect, it } from 'vitest';
import { calcCoffeeIngredient } from '../src/coffee-complete';

describe('Coffee', () => {
  it('should have espresso', () => {
    const result = calcCoffeeIngredient('espresso', 2);
    expect(result).to.deep.equal({ espresso: 60 });
  });

  it('should have americano', () => {
    const result = calcCoffeeIngredient('americano');
    expect(result.espresso).to.equal(30);
    expect(result.water).to.equal(70);
  });

  it('should throw error', () => {
    const func = () => calcCoffeeIngredient('mocha');
    expect(func).toThrowError(new Error('mocha not found'));
  });

  it('should have nothing', () => {
    const result = calcCoffeeIngredient('unknown')
    expect(result).to.deep.equal({});
  });
});