Quattro tipi comuni di copertura del codice

Scopri cos'è la copertura del codice e scopri quattro modi comuni per misurarla.

Hai sentito la frase "copertura del codice"? In questo post, esamineremo che cos'è la copertura del codice nei test e quattro modi comuni per misurarla.

Che cos'è la copertura del codice?

La copertura del codice è una metrica che misura la percentuale di codice sorgente eseguita dai test. Ti aiuta a identificare le aree in cui potrebbero mancare test adeguati.

Spesso, la registrazione di queste metriche ha il seguente aspetto:

File Dichiarazioni relative alla percentuale % Branch Funzioni % % righe Righe scoperte
file.js 90% 100% 90% 80% 89.256
coffee.js 55,55% 80% 50% 62,5% 10-11, 18

Man mano che aggiungi nuove funzionalità e test, aumentare le percentuali di copertura del codice può darti maggiore certezza che la tua applicazione sia stata testata a fondo. Tuttavia, c'è ancora molto altro da scoprire.

Quattro tipi comuni di copertura del codice

Esistono quattro modi comuni per raccogliere e calcolare la copertura del codice: funzione, riga, ramo e copertura istruzioni.

Quattro tipi di copertura del testo.

Per vedere come ogni tipo di copertura del codice calcola la propria percentuale, considera il seguente esempio di codice per il calcolo degli ingredienti del caffè:

/* 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);
}

I test che verificano la funzione calcCoffeeIngredient sono:

/* 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({});
  });
});

Puoi eseguire il codice e i test in questa demo in tempo reale o consultare il repository.

Copertura delle funzioni

Copertura del codice: 50%

/* coffee.js */

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

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

La copertura delle funzioni è una metrica semplice. Acquisisce la percentuale di funzioni nel codice chiamate dai test.

Nell'esempio di codice sono presenti due funzioni: calcCoffeeIngredient e isValidCoffee. I test chiamano solo la funzione calcCoffeeIngredient, quindi la copertura della funzione è al 50%.

Copertura della linea

Copertura del codice: 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 copertura riga misura la percentuale di righe di codice eseguibili eseguite dalla suite di test. Se una riga di codice non viene eseguita, significa che una parte del codice non è stata testata.

L'esempio di codice ha otto righe di codice eseguibile (evidenziate in rosso e verde), ma i test non eseguono la condizione americano (due righe) e la funzione isValidCoffee (una riga). Ciò si traduce in una copertura di linea del 62,5%.

Tieni presente che la copertura delle righe non prende in considerazione le dichiarazioni, come function isValidCoffee(name) e let espresso, water;, perché non sono eseguibili.

Copertura dei rami

Copertura del codice: 80%

/* coffee.js */

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

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

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

  return {};
}

La copertura dei rami misura la percentuale di rami o punti decisionali eseguiti nel codice, ad esempio istruzioni if o loop. Determina se i test esaminano sia i rami veri che quelli falsi delle istruzioni condizionali.

L'esempio di codice contiene cinque branche:

  1. Chiamata a calcCoffeeIngredient solo con coffeeName Segno di spunta.
  2. Chiamata a calcCoffeeIngredient con coffeeName e cup Segno di spunta.
  3. Il caffè è espresso Segno di spunta.
  4. Il caffè è americano Segno X.
  5. Altro caffè Segno di spunta.

I test coprono tutti i rami tranne la condizione Coffee is Americano. La copertura dei rami è quindi dell'80%.

Copertura della dichiarazione

Copertura del codice: 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 copertura delle dichiarazioni misura la percentuale di istruzioni nel codice eseguite dai test. A prima vista, potresti chiederti: "non è la stessa cosa della copertura riga?" In effetti, la copertura delle istruzioni è simile alla copertura riga, ma prende in considerazione singole righe di codice che contengono più istruzioni.

Nell'esempio di codice sono presenti otto righe di codice eseguibile, ma nove istruzioni. Riesci a individuare la riga contenente due istruzioni?

Controlla la risposta

Si tratta della seguente riga: espresso = 30 * cup; water = 70 * cup;

I test coprono solo cinque delle nove affermazioni, pertanto la copertura delle affermazioni è del 55,55%.

Se scrivi sempre un'istruzione per riga, la copertura della riga sarà simile a quella dell'istruzione.

Quale tipo di copertura del codice dovresti scegliere?

La maggior parte degli strumenti di copertura del codice include questi quattro tipi di copertura del codice comune. La scelta della metrica di copertura del codice a cui dare la priorità dipende da requisiti di progetto, pratiche di sviluppo e obiettivi di test specifici.

In generale, la copertura delle dichiarazioni è un buon punto di partenza, in quanto si tratta di una metrica semplice e di facile comprensione. A differenza della copertura delle istruzioni, la copertura dei rami e la copertura delle funzioni misurano se i test chiamano una condizione (ramo) o una funzione. Pertanto, rappresentano un passaggio naturale dopo la copertura delle dichiarazioni.

Una volta raggiunta una copertura elevata delle affermazioni, puoi passare alla copertura dei rami e delle funzioni.

La copertura dei test è la stessa della copertura del codice?

No. La copertura dei test e la copertura del codice spesso sono confuse, ma sono due cose diverse:

  • Copertura del test: metrica qualitativa che misura l'efficacia con cui la suite di test copre le funzionalità del software. Aiuta a determinare il livello di rischio.
  • Copertura del codice: una metrica quantitativa che misura la proporzione di codice eseguita durante i test. Riguarda la quantità di codice coperta dai test.

Ecco un'analogia semplificata: immagina un'applicazione web come una casa.

  • La copertura del test misura l'efficacia dei test per coprire le stanze della casa.
  • La copertura del codice misura la quantità di codice esaminata dai test.

La copertura del codice al 100% non significa assenza di bug

Sebbene sia certamente auspicabile ottenere una copertura del codice elevata durante i test, la copertura del codice al 100% non garantisce l'assenza di bug o difetti nel codice.

Un modo privo di significato per ottenere una copertura del codice del 100%

Considera il seguente test:

/* 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
  });
});

Questo test raggiunge il 100% di copertura di funzioni, righe, rami e istruzioni, ma non ha senso perché non testa effettivamente il codice. L'affermazione expect(true).toBe(true) passerà sempre, indipendentemente dal fatto che il codice funzioni correttamente.

Una metrica sbagliata è peggio di nessuna metrica

Una metrica sbagliata può darti un falso senso di sicurezza, il che è peggio che non avere alcuna metrica. Ad esempio, se hai una suite di test che raggiunge il 100% di copertura del codice, ma i test sono tutti privi di significato, potresti avere un falso senso di sicurezza che il codice sia ben testato. Se elimini o interrompi accidentalmente una parte del codice dell'applicazione, i test passeranno comunque, anche se l'applicazione non funziona più correttamente.

Per evitare questo scenario:

  • Revisione del test. Scrivi e rivedi i test per assicurarti che siano significativi e testa il codice in una serie di scenari diversi.
  • Utilizza la copertura del codice come linea guida, non come unica misura dell'efficacia del test o della qualità del codice.

Utilizzo della copertura del codice in diversi tipi di test

Vediamo più da vicino come utilizzare la copertura del codice con i tre tipi comuni di test:

  • Test delle unità. Sono il tipo di test migliore per raccogliere la copertura del codice perché sono progettati per coprire più piccoli scenari e percorsi di test.
  • Test di integrazione. Possono aiutarti a raccogliere la copertura del codice per i test di integrazione, ma utilizzali con cautela. In questo caso, viene calcolata la copertura di una porzione maggiore del codice sorgente ed è difficile determinare quali test coprono effettivamente determinate parti del codice. Tuttavia, il calcolo della copertura del codice dei test di integrazione può essere utile per i sistemi legacy che non hanno unità ben isolate.
  • Test end-to-end (E2E). La misurazione della copertura del codice per i test E2E è difficile e complessa a causa della natura intricata di questi test. Anziché utilizzare la copertura del codice, la copertura dei requisiti potrebbe essere la soluzione migliore. Questo perché l'obiettivo dei test E2E è coprire i requisiti del test, non concentrarsi sul codice sorgente.

Conclusione

La copertura del codice può essere una metrica utile per misurare l'efficacia dei test. Può aiutarti a migliorare la qualità della tua applicazione garantendo che la logica fondamentale del codice sia ben testata.

Tuttavia, ricorda che la copertura del codice è solo una metrica. Assicurati di prendere in considerazione anche altri fattori, come la qualità dei test e i requisiti dell'applicazione.

Non è necessario puntare alla copertura del codice al 100%. Dovresti invece utilizzare la copertura del codice insieme a un piano di test completo che includa una varietà di metodi di test, tra cui test delle unità, test di integrazione, test end-to-end e test manuali.

Visualizza l'esempio di codice completo e i test con una buona copertura del codice. Puoi anche eseguire il codice e i test con questa demo dal vivo.

/* 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({});
  });
});