Quatro tipos comuns de cobertura de código

Saiba o que é a cobertura de código e descubra quatro maneiras comuns de medi-la.

Você já ouviu a expressão "cobertura de código"? Neste post, vamos explicar o que é a cobertura de código em testes e quatro maneiras comuns de medi-la.

A cobertura de código é uma métrica que mede a porcentagem do código-fonte que seus testes executam. Ele ajuda a identificar áreas que podem não ter testes adequados.

Muitas vezes, a gravação dessas métricas é assim:

Arquivo Declarações de % % de ramificação Funções% % de linhas Linhas descobertas
file.js 90% 100% 90% 80% 89.256
coffee.js 55,55% 80% 50% 62,5% 10-11, 18

À medida que você adiciona novos recursos e testes, aumentar as porcentagens de cobertura de código pode dar mais confiança de que seu aplicativo foi testado completamente. No entanto, há mais para descobrir.

Quatro tipos comuns de cobertura de código

Há quatro maneiras comuns de coletar e calcular a cobertura de código: cobertura de função, linha, ramificação e instrução.

Quatro tipos de cobertura de texto.

Para saber como cada tipo de cobertura de código calcula a porcentagem, considere o seguinte exemplo de código para calcular os ingredientes do 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);
}

Os testes que verificam a função calcCoffeeIngredient são:

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

Você pode executar o código e os testes nesta demonstração ao vivo ou conferir o repositório.

Cobertura de função

Cobertura de código: 50%

/* coffee.js */

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

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

A cobertura da função é uma métrica simples. Ele captura a porcentagem de funções no código que são chamadas pelos testes.

No exemplo de código, há duas funções: calcCoffeeIngredient e isValidCoffee. Os testes chamam apenas a função calcCoffeeIngredient, então a cobertura da função é de 50%.

Cobertura de linha

Cobertura de código: 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);
}

A cobertura de linha mede a porcentagem de linhas de código executáveis que o conjunto de testes executou. Se uma linha de código não for executada, significa que parte do código não foi testada.

O exemplo de código tem oito linhas de código executável (realçadas em vermelho e verde), mas os testes não executam a condição americano (duas linhas) e a função isValidCoffee (uma linha). Isso resulta em uma cobertura de linha de 62,5%.

A cobertura de linha não considera instruções de declaração, como function isValidCoffee(name) e let espresso, water;, porque elas não são executáveis.

Cobertura da ramificação

Cobertura de código: 80%

/* coffee.js */

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

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

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

  return {};
}

A cobertura da ramificação mede a porcentagem de ramificações ou pontos de decisão executados no código, como instruções "if" ou loops. Ele determina se os testes examinam os ramos verdadeiros e falsos das instruções condicionais.

Há cinco ramos no exemplo de código:

  1. Chamada para calcCoffeeIngredient com apenas coffeeName Marca de seleção.
  2. Ligando para calcCoffeeIngredient com coffeeName e cup (Marca chek.)
  3. O café é Espresso Marca de seleção.
  4. O café é americano Marcação com "X".
  5. Outro café Marca chek.

Os testes abrangem todas as ramificações, exceto a condição Coffee is Americano. Assim, a cobertura da filial é de 80%.

Cobertura da declaração

Cobertura de código: 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);
}

A cobertura de instruções mede a porcentagem de instruções no código que os testes executam. À primeira vista, você pode se perguntar: "Isso não é o mesmo que cobertura de linha?". De fato, a cobertura de instruções é semelhante à cobertura de linha, mas leva em conta linhas de código únicas que contêm várias instruções.

No exemplo de código, há oito linhas de código executável, mas nove instruções. Você consegue identificar a linha que contém duas instruções?

É a seguinte linha: espresso = 30 * cup; water = 70 * cup;

Os testes abrangem apenas cinco das nove declarações, portanto, a cobertura de declaração é de 55,55%.

Se você sempre escrever uma instrução por linha, a cobertura de linha será semelhante à cobertura de instrução.

Que tipo de cobertura de código você deve escolher?

A maioria das ferramentas de cobertura de código inclui esses quatro tipos de cobertura de código comuns. A escolha da métrica de cobertura de código a ser priorizada depende dos requisitos específicos do projeto, das práticas de desenvolvimento e das metas de teste.

De modo geral, a cobertura de declaração é um bom ponto de partida porque é uma métrica simples e fácil de entender. Ao contrário da cobertura de instruções, a cobertura de ramos e de funções mede se os testes chamam uma condição (ramificação) ou uma função. Portanto, elas são uma progressão natural após a cobertura da declaração.

Depois de alcançar uma alta cobertura de instruções, você pode passar para a cobertura de ramificações e de funções.

A cobertura de teste é a mesma que a cobertura de código?

Não. A cobertura de teste e a cobertura de código são frequentemente confundidas, mas são diferentes:

  • Cobertura de teste: métrica qualitativa que mede a eficiência com que o pacote de testes abrange os recursos do software. Ele ajuda a determinar o nível de risco envolvido.
  • Cobertura de código: é uma métrica quantitativa que mede a proporção do código executado durante o teste. É sobre a quantidade de código que os testes abrangem.

Aqui está uma analogia simplificada: imagine um aplicativo da Web como uma casa.

  • A cobertura do teste mede o quanto os testes cobrem os cômodos da casa.
  • A cobertura de código mede o quanto dos testes foram executados.

Cobertura de código de 100% não significa ausência de bugs

Embora seja desejável alcançar uma alta cobertura de código nos testes, a cobertura de 100% não garante a ausência de bugs ou falhas no código.

Uma maneira sem sentido de alcançar 100% de cobertura de código

Considere o seguinte teste:

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

Esse teste alcança 100% de cobertura de função, linha, ramificação e instrução, mas não faz sentido porque não testa o código. A declaração expect(true).toBe(true) sempre será transmitida, independentemente de o código funcionar corretamente.

Uma métrica ruim é pior do que nenhuma métrica

Uma métrica ruim pode dar a você uma falsa sensação de segurança, o que é pior do que não ter nenhuma métrica. Por exemplo, se você tem um pacote de testes que atinge 100% de cobertura de código, mas todos os testes não fazem sentido, é possível que você tenha uma falsa sensação de segurança de que o código foi bem testado. Se você excluir ou quebrar acidentalmente uma parte do código do aplicativo, os testes ainda serão aprovados, mesmo que o aplicativo não funcione mais corretamente.

Para evitar esse cenário:

  • Revisão do teste. Crie e revise testes para garantir que eles sejam significativos e teste o código em vários cenários diferentes.
  • Use a cobertura do código como orientação, não como a única medida de eficácia de teste ou qualidade do código.

Como usar a cobertura de código em diferentes tipos de teste

Vamos analisar como usar a cobertura de código com os três tipos comuns de teste:

  • Testes de unidade. Eles são o melhor tipo de teste para coletar a cobertura de código porque foram projetados para cobrir vários cenários e caminhos de teste pequenos.
  • Testes de integração. Eles podem ajudar a coletar a cobertura de código para testes de integração, mas use-os com cautela. Nesse caso, você calcula a cobertura de uma parte maior do código-fonte, e pode ser difícil determinar quais testes realmente abrangem quais partes do código. No entanto, calcular a cobertura de código dos testes de integração pode ser útil para sistemas legados que não têm unidades bem isoladas.
  • Testes de ponta a ponta (E2E). Medir a cobertura de código para testes E2E é difícil e desafiador devido à natureza complexa desses testes. Em vez de usar a cobertura de código, a cobertura de requisitos pode ser a melhor opção. Isso ocorre porque o foco dos testes E2E é cobrir os requisitos do teste, não se concentrar no código-fonte.

Conclusão

A cobertura de código pode ser uma métrica útil para medir a eficácia dos seus testes. Ela pode ajudar a melhorar a qualidade do aplicativo, garantindo que a lógica crucial do código seja bem testada.

No entanto, lembre-se de que a cobertura de código é apenas uma métrica. Considere também outros fatores, como a qualidade dos testes e os requisitos do aplicativo.

O objetivo não é ter 100% de cobertura de código. Em vez disso, use a cobertura de código com um plano completo que incorpore vários métodos de teste, incluindo testes de unidade, de integração, completos e manuais.

Confira o exemplo completo de código e os testes com boa cobertura de código. Também é possível executar o código e os testes com esta demonstração ao 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({});
  });
});