Quatro tipos comuns de cobertura de código

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

Você já ouviu a frase "cobertura de código"? Nesta postagem, vamos abordar o que é a cobertura de código em testes e quatro maneiras comuns de medi-la.

O que é a cobertura de código?

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

Geralmente, o registro dessas métricas é semelhante ao seguinte:

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

À medida que você adiciona novos recursos e testes, aumentar as porcentagens de cobertura de código aumenta a confiança de que o aplicativo foi completamente testado. No entanto, ainda há muito o que 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 conferir como cada tipo de cobertura de código calcula a porcentagem, considere o seguinte exemplo de código para calcular os ingredientes de 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({});
  });
});

É possível executar o código e os testes nesta demonstração ao vivo ou conferir o repositório.

Cobertura da 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 direta. Ela captura a porcentagem de funções no código que os testes chamam.

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

Cobertura de linhas

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 seu conjunto de testes executou. Se uma linha de código não for executada, isso significa que alguma parte do código não foi testada.

O exemplo de código tem oito linhas de código executável (destacadas 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 as instruções de declaração, como function isValidCoffee(name) e let espresso, water;, porque elas não são executáveis.

Cobertura da filial

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 de ramificação mede a porcentagem de ramificações ou pontos de decisão executados no código, como instruções if ou loops. Determina se os testes examinam as ramificações verdadeiras e falsas de declarações condicionais.

Há cinco ramificações no exemplo de código:

  1. Ligando para calcCoffeeIngredient com apenas coffeeName Marca de tachado.
  2. Ligando para calcCoffeeIngredient com coffeeName e cup Marca de tachado.
  3. Café é expresso Marca de tachado.
  4. O café é Americano Marcação com "X".
  5. Outro café Marca de tachado.

Os testes abrangem todas as ramificações, exceto a condição Coffee is Americano. Então, a cobertura das agências é de 80%.

Cobertura do extrato

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 a cobertura de linha?” Na verdade, a cobertura da instrução é semelhante à da linha, mas leva em consideração linhas únicas de código que contêm várias instruções.

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

Conferir sua resposta

Esta é a linha: espresso = 30 * cup; water = 70 * cup;

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

Se você sempre escrever uma declaração por linha, a cobertura da sua linha será semelhante à cobertura do seu extrato.

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

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

Em geral, a cobertura da declaração é um bom ponto de partida, porque é uma métrica simples e fácil de entender. Ao contrário da cobertura da instrução, a cobertura da ramificação e da função medem 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 atingir uma alta cobertura de instruções, você pode passar para a cobertura da filial e da função.

A cobertura de teste é igual à cobertura de código?

Não. As coberturas de teste e de código costumam ser confundidas, mas são diferentes:

  • Cobertura de teste: métrica qualitativa que mede o quanto 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. Ela diz respeito à quantidade de código que os testes abrangem.

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

  • A cobertura de teste mede o quanto os testes cobrem os ambientes da casa.
  • A cobertura de código mede quanto da casa os testes passaram.

Ter 100% de cobertura de código não significa que não há bugs

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

Uma maneira sem sentido de atingir 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 atinge 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 vai ser transmitida, independente do código funcionar corretamente.

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

Uma métrica ruim pode dar 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 com 100% de cobertura de código, mas todos os testes são irrelevantes, isso pode gerar uma falsa sensação de segurança de que o código foi bem testado. Se você acidentalmente excluir ou corromper 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, faça o seguinte:

  • Análise de teste. Programe e revise os testes para garantir que sejam significativos e teste o código em várias situações diferentes.
  • Use a cobertura do código como uma diretriz, não como a única medida de eficácia do teste ou qualidade do código.

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

Confira mais detalhes sobre como usar a cobertura do código com os três tipos comuns de teste:

  • Testes de unidade. Eles são o melhor tipo de teste para coletar cobertura de código porque são projetados para cobrir vários cenários pequenos e caminhos de teste.
  • Testes de integração. Elas podem ajudar a coletar a cobertura de código para testes de integração, mas precisam ser usadas 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 do 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 do 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, e não se concentrar no código-fonte.

Conclusão

A cobertura do código pode ser uma métrica útil para medir a eficácia dos seus testes. Ela pode ajudar você a melhorar a qualidade do seu 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.

Não ter 100% de cobertura de código é o objetivo. Em vez disso, você deve usar a cobertura de código com um plano de testes completo que incorpore uma variedade de métodos de teste, incluindo testes de unidade, testes de integração, testes de ponta a ponta e testes manuais.

Confira o exemplo de código completo 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({});
  });
});