Quattro tipi comuni di copertura del codice

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

Hai sentito la frase "copertura del codice"? In questo post scopriremo 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 non essere eseguiti test adeguati.

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

File % dichiarazioni % ramo % funzioni % righe Righe scoperte
file.js 90% 100% 90% 80% 89.256
coffee.js 55,55% 80% 50% 62,5% 10 - 11 e 18

Man mano che aggiungi nuove funzionalità e test, l'aumento delle percentuali di copertura del codice può darti maggiore sicurezza che la tua applicazione è stata testata in modo approfondito. Tuttavia, c'è ancora molto da scoprire.

Quattro tipi comuni di copertura del codice

Esistono quattro modi comuni per raccogliere e calcolare la copertura del codice: copertura di funzioni, linee, ramo e istruzione.

Quattro tipi di copertura testuale.

Per vedere come ogni tipo di copertura del codice calcola la propria percentuale, considera il seguente esempio di codice per calcolare gli 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 su questa demo live o consultare il repository.

Copertura della funzione

Copertura del codice: 50%

/* coffee.js */

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

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

La copertura della funzione è una metrica semplice. Acquisisce la percentuale di funzioni nel codice richiamate 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 è pari 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 delle linee misura la percentuale di righe di codice eseguibili eseguite dalla tua suite di test. Se una riga di codice non è stata eseguita, significa che una parte del codice non è stata testata.

L'esempio di codice contiene otto righe di codice eseguibile (evidenziato 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 linea del 62,5%.

Tieni presente che la copertura delle righe non prende in considerazione le istruzioni della dichiarazione, 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 eseguiti o punti di decisione nel codice, come istruzioni o loop. Determina se i test esaminano sia i rami veri e quelli falsi delle dichiarazioni condizionali.

Nell'esempio di codice sono presenti cinque rami:

  1. Chiamata a calcCoffeeIngredient con soli coffeeName Chek.
  2. Chiamata a calcCoffeeIngredient con coffeeName e cup Chek.
  3. Il caffè è espresso Chek.
  4. Il caffè è americano Simbolo X.
  5. Altro caffè Chek.

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

Copertura degli estratti conto

Copertura 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 se "la copertura non è uguale alla copertura delle linee?". In effetti, la copertura delle dichiarazioni è simile alla copertura delle righe, ma tiene conto di singole righe di codice che contengono più istruzioni.

Nell'esempio di codice ci sono otto righe di codice eseguibile, ma nove istruzioni. Riesci a individuare la riga che contiene due affermazioni?

Verificare la risposta

È la seguente riga: espresso = 30 * cup; water = 70 * cup;

I test riguardano solo cinque delle nove affermazioni, pertanto la copertura della dichiarazione è pari al 55,55%.

Se scrivi sempre un'affermazione per riga, la copertura della linea sarà simile a quella dell'estratto conto.

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 comuni. La scelta della metrica di copertura del codice a cui dare la priorità dipende da requisiti specifici del progetto, pratiche di sviluppo e obiettivi di test.

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 del ramo e la copertura delle funzioni misurano se i test chiamano una condizione (ramo) o una funzione. Di conseguenza, costituiscono un avanzamento naturale dopo la copertura dell'affermazione.

Una volta ottenuta un'elevata copertura delle dichiarazioni, è possibile passare alla copertura delle diramazioni e delle funzioni.

La copertura dei test equivale alla copertura del codice?

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

  • Copertura del test: metrica acqualitica che misura il livello di efficacia della suite di test nel coprire le funzionalità del software. Aiuta a determinare il livello di rischio.
  • Copertura del codice: una metrica quantitativa che misura la proporzione di codice eseguito durante il test. Si tratta della quantità di codice coperta dai test.

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

  • La copertura dei test misura l'efficacia dei test sulle stanze della casa.
  • La copertura del codice misura quanta parte dell'abitazione sono state attraversate dai test.

Una copertura del codice pari al 100% non significa che non ci siano bug

Sebbene sia certamente auspicabile ottenere una copertura elevata del codice nei test, il 100% di copertura del codice 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 una copertura del 100% di funzioni, linee, rami ed istruzioni, ma non ha senso perché in realtà non testa il codice. L'asserzione expect(true).toBe(true) viene sempre passata indipendentemente dal fatto che il codice funzioni correttamente.

Una metrica errata è peggiore di nessuna metrica

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

Per evitare questo scenario:

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

Utilizzo della copertura del codice in diversi tipi di test

Diamo un'occhiata più da vicino a come puoi utilizzare la copertura del codice con i tre tipi di test comuni:

  • Test delle unità. Sono il tipo di test migliore per la raccolta della copertura del codice perché sono progettati per coprire più scenari e percorsi di test di piccole dimensioni.
  • Test di integrazione. Possono essere utili per raccogliere la copertura del codice per i test di integrazione, ma usali con cautela. In questo caso, si calcola la copertura di una porzione maggiore del codice sorgente e può essere difficile determinare quali test coprono effettivamente quali 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). Misurare la copertura del codice per i test E2E è difficile e impegnativo a causa della natura complessa di questi test. Invece di 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 cruciale nel tuo 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.

Puntare a una copertura del codice del 100% non è l'obiettivo. Dovresti usare la copertura del codice insieme a un piano di test completo che incorpori una varietà di metodi di test, tra cui test delle unità, di integrazione, test end-to-end e test manuali.

Vedi l'esempio di codice completo e testa il codice con una buona copertura. 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({});
  });
});