Cuatro tipos comunes de cobertura de código

Descubre qué es la cobertura de código y conoce cuatro formas comunes de medirla.

¿Has escuchado la frase "cobertura de código"? En esta publicación, exploraremos qué es la cobertura de código en las pruebas y cuatro formas comunes de medirla.

La cobertura de código es una métrica que mide el porcentaje de código fuente que ejecutan tus pruebas. Te ayuda a identificar áreas que pueden no tener pruebas adecuadas.

A menudo, la grabación de estas métricas se ve de la siguiente manera:

Archivo Declaraciones de porcentaje % de la sucursal % de funciones % de líneas Líneas no cubiertas
file.js 90% 100% 90% 80% 89,256
coffee.js 55.55% 80% 50% 62.5% 10-11, 18

A medida que agregas funciones y pruebas nuevas, aumentar los porcentajes de cobertura de código puede darte más confianza en que tu aplicación se probó en detalle. Sin embargo, hay más por descubrir.

Cuatro tipos comunes de cobertura de código

Existen cuatro formas comunes de recopilar y calcular la cobertura de código: cobertura de función, línea, rama y sentencia.

Cuatro tipos de cobertura de texto.

Para ver cómo cada tipo de cobertura de código calcula su porcentaje, considera el siguiente ejemplo de código para calcular los ingredientes del 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);
}

Las pruebas que verifican la función calcCoffeeIngredient son las siguientes:

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

Puedes ejecutar el código y las pruebas en esta demostración en vivo o consultar el repositorio.

Cobertura de la función

Cobertura de código: 50%

/* coffee.js */

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

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

La cobertura de funciones es una métrica sencilla. Captura el porcentaje de funciones en tu código a las que llaman tus pruebas.

En el ejemplo de código, hay dos funciones: calcCoffeeIngredient y isValidCoffee. Las pruebas solo llaman a la función calcCoffeeIngredient, por lo que la cobertura de la función es del 50%.

Cobertura de línea

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

La cobertura de líneas mide el porcentaje de líneas de código ejecutables que ejecutó tu conjunto de pruebas. Si una línea de código no se ejecuta, significa que no se probó alguna parte del código.

El ejemplo de código tiene ocho líneas de código ejecutable (destacadas en rojo y verde), pero las pruebas no ejecutan la condición americano (dos líneas) ni la función isValidCoffee (una línea). Esto da como resultado una cobertura de línea del 62.5%.

Ten en cuenta que la cobertura de líneas no tiene en cuenta las sentencias de declaración, como function isValidCoffee(name) y let espresso, water;, porque no son ejecutables.

Cobertura de la sucursal

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 {};
}

La cobertura de ramas mide el porcentaje de ramas o puntos de decisión ejecutados en el código, como las sentencias if o los bucles. Determina si las pruebas examinan las ramas verdaderas y falsas de las sentencias condicionales.

Hay cinco ramas en el ejemplo de código:

  1. Llamada a calcCoffeeIngredient con solo coffeeName Marca de verificación.
  2. Llamada a calcCoffeeIngredient con coffeeName y cup Marca de verificación.
  3. El café es espresso Marca de verificación.
  4. El café es americano Marca de X.
  5. Otro café Marca de verificación.

Las pruebas abarcan todas las ramas, excepto la condición Coffee is Americano. Por lo tanto, la cobertura de la rama es del 80%.

Cobertura de la declaración

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

La cobertura de sentencias mide el porcentaje de sentencias en tu código que ejecutan tus pruebas. A primera vista, podrías preguntarte: "¿No es lo mismo que la cobertura de líneas?". En efecto, la cobertura de sentencias es similar a la cobertura de líneas, pero tiene en cuenta las líneas de código individuales que contienen varias sentencias.

En el ejemplo de código, hay ocho líneas de código ejecutable, pero hay nueve sentencias. ¿Puedes ver la línea que contiene dos instrucciones?

Es la siguiente línea: espresso = 30 * cup; water = 70 * cup;

Las pruebas abarcan solo cinco de las nueve sentencias, por lo que la cobertura de sentencias es del 55.55%.

Si siempre escribes una sentencia por línea, la cobertura de líneas será similar a la de sentencias.

¿Qué tipo de cobertura de código deberías elegir?

La mayoría de las herramientas de cobertura de código incluyen estos cuatro tipos de cobertura de código comunes. La elección de la métrica de cobertura de código que se priorizará depende de los requisitos específicos del proyecto, las prácticas de desarrollo y los objetivos de prueba.

En general, la cobertura de sentencias es un buen punto de partida porque es una métrica simple y fácil de entender. A diferencia de la cobertura de sentencias, la cobertura de ramas y la cobertura de funciones miden si las pruebas llaman a una condición (rama) o a una función. Por lo tanto, son una progresión natural después de la cobertura de las sentencias.

Una vez que hayas logrado una alta cobertura de sentencias, puedes pasar a la cobertura de ramas y la cobertura de funciones.

¿La cobertura de pruebas es lo mismo que la cobertura de código?

No. La cobertura de pruebas y la cobertura de código suelen confundirse, pero son diferentes:

  • Cobertura de pruebas: Métrica cualitativa que mide qué tan bien el paquete de pruebas abarca las funciones del software. Ayuda a determinar el nivel de riesgo involucrado.
  • Cobertura de código: Es una métrica cuantitativa que mide la proporción de código ejecutado durante las pruebas. Se trata de la cantidad de código que cubren las pruebas.

Esta es una analogía simplificada: imagina una aplicación web como una casa.

  • La cobertura de la prueba mide qué tan bien las pruebas cubren las habitaciones de la casa.
  • La cobertura de código mide qué parte de la casa recorrieron las pruebas.

El 100% de cobertura de código no significa que no haya errores.

Si bien es deseable lograr una alta cobertura de código en las pruebas, el 100% de cobertura de código no garantiza la ausencia de errores o fallas en el código.

Una forma sin sentido de lograr una cobertura de código del 100%

Considera la siguiente prueba:

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

Esta prueba logra una cobertura del 100% de las funciones, líneas, instrucciones y ramas, pero no tiene sentido porque no prueba el código. La aserción expect(true).toBe(true) siempre se aprobará, independientemente de si el código funciona correctamente.

Una métrica mala es peor que no tener ninguna.

Una métrica incorrecta puede darte una falsa sensación de seguridad, lo que es peor que no tener ninguna métrica. Por ejemplo, si tienes un paquete de pruebas que logra una cobertura de código del 100%, pero las pruebas no tienen sentido, es posible que tengas una falsa sensación de seguridad de que tu código está bien probado. Si borras o rompes accidentalmente una parte del código de la aplicación, las pruebas se aprobarán, aunque la aplicación ya no funcione correctamente.

Para evitar esta situación, haz lo siguiente:

  • Revisión de la prueba. Escribe y revisa las pruebas para asegurarte de que sean significativas y prueba el código en diferentes situaciones.
  • Usa la cobertura de código como guía, no como la única medida de la eficacia de las pruebas o la calidad del código.

Cómo usar la cobertura de código en diferentes tipos de pruebas

Analicemos en detalle cómo puedes usar la cobertura de código con los tres tipos comunes de pruebas:

  • Pruebas de unidades. Son el mejor tipo de prueba para recopilar la cobertura de código, ya que están diseñados para abarcar varias situaciones y rutas de prueba pequeñas.
  • Pruebas de integración. Pueden ayudar a recopilar la cobertura de código para las pruebas de integración, pero úsalos con precaución. En este caso, se calcula la cobertura de una parte más grande del código fuente, y puede ser difícil determinar qué pruebas cubren realmente qué partes del código. Sin embargo, calcular la cobertura de código de las pruebas de integración puede ser útil para los sistemas heredados que no tienen unidades bien aisladas.
  • Pruebas de extremo a extremo (E2E). Medir la cobertura de código para las pruebas de E2E es difícil y desafiante debido a la naturaleza compleja de estas pruebas. En lugar de usar la cobertura de código, la cobertura de requisitos podría ser la mejor opción. Esto se debe a que el enfoque de las pruebas de E2E es cubrir los requisitos de la prueba, no enfocarse en el código fuente.

Conclusión

La cobertura de código puede ser una métrica útil para medir la eficacia de tus pruebas. Puede ayudarte a mejorar la calidad de tu aplicación, ya que garantiza que la lógica fundamental de tu código esté bien probada.

Sin embargo, recuerda que la cobertura de código es solo una métrica. Asegúrate de considerar también otros factores, como la calidad de tus pruebas y los requisitos de tu solicitud.

El objetivo no es lograr una cobertura de código del 100%. En su lugar, debes usar la cobertura de código junto con un plan de pruebas bien definido que incorpore una variedad de métodos de prueba, incluidas pruebas de unidades, pruebas de integración, pruebas de extremo a extremo y pruebas manuales.

Consulta el ejemplo de código completo y las pruebas con una buena cobertura de código. También puedes ejecutar el código y las pruebas con esta demostración en 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({});
  });
});