Aprende cómo Stryker puede mejorar la calidad de tus tests unitarios y facilitar la identificación de nuevos casos de prueba.

¿Qué es Stryker y cómo asegura la calidad de los tests en tus proyectos Angular?

A menudo, los desarrolladores medimos la calidad de nuestras pruebas unitarias en función del porcentaje de cobertura de líneas de código. Sin embargo, este criterio no es fiable, ya que la eficacia de las pruebas depende de si se está evaluando de forma correcta la lógica de negocio.

Tener un alto porcentaje de cobertura no garantiza que los tests realizan validaciones efectivas; es posible que solo estén recorriendo líneas de código sin verificar su correcto funcionamiento.

De ahí la importancia de la utilización de los test de mutación. Estos tests se encargan de proporcionar un porcentaje real de cobertura de líneas que están siendo realmente probadas. Para lograrlo, mutan el código en memoria y lanzan los tests unitarios. Como resultado, la mayoría de los tests unitarios debería fallar; de no ser así, seguramente la lógica no está siendo probada de manera correcta.

Ciclo de vida de los tests de mutación en un proyecto
Ciclo de vida de los tests de mutación en un proyecto

Una herramienta destacada para realizar dichos tests de mutación es Stryker, la cual es ideal para tecnologías frontend como Angular, React, Vue y Vanilla JavaScript. Stryker permite identificar áreas del código que no están siendo adecuadamente cubiertas por los tests, ayudando así a mejorar la calidad y robustez del código.

Tipos de mutación

Existen diferentes tipos de mutación que se pueden agrupar en tres grandes grupos:

  1. Value mutations: son aquellos que conllevan cambios en los valores de los literales o de las variables para evaluar la robustez de las condiciones y operaciones.
  • Mutaciones aritméticas: modifican los operadores aritméticos (como +, -, *, /) en el código. Tienen como objetivo detectar errores en las operaciones matemáticas y en la lógica que depende de resultados específicos.
  • Mutaciones de incremento/decremento: incrementan o decrementan variables (como cambiar i++ a i--). Su objetivo es verificar que los tests son sensibles a los cambios en los valores de las variables.
  • Mutaciones de literales: modifican cadenas de literales en el código. Su propósito es determinar si los tests dependen de valores específicos de cadenas y cómo responden a cambios en esos valores.
  1. Decision mutations: modifican las expresiones condicionales y decisiones lógicas dentro del código.
  • Mutaciones condicionales: cambian los operadores condicionales (==, !=, >, <, >=, <=) y la manera en que se estructuran las sentencias if. Su finalidad es asegurar que los tests evalúan eficazmente tanto las condiciones mismas como sus límites extremos.
  • Mutaciones lógicas: modifican los operadores lógicos (como &&, ||, !). Están orientados a probar la robustez de las condiciones complejas y las decisiones lógicas dentro del código.
  1. Statement mutations: alteran las sentencias del código para verificar si los tests pueden detectar alteraciones en los flujos de ejecución.
  • Mutaciones de asignación: modifican los operadores de asignación (como =, +=, -=, *=). Su objetivo es evaluar la estabilidad del código ante cambios en cómo se asignan los valores a las variables.
  • Mutaciones de respuesta: cambian las sentencias de respuesta para devolver valores distintos, como por ejemplo null. Su propósito es asegurar que los tests detectan cambios en los valores devueltos por funciones y métodos.
  • Mutaciones de método/función: modifican llamadas a métodos o funciones por otras opciones o por llamadas que devuelven valores por defecto. Se encargan de verificar si los tests detectan correctamente el comportamiento esperado de las funciones y métodos.

Instalación Stryker

Antes de proceder a la instalación de Stryker, vamos a clonarnos este repositorio, utilizado en mi post anterior “Angular y Jest: guía esencial para desarrolladores”, como guía base sobre la cual trabajar:

git clone https://github.com/scasado93/project-test-jest

Contaremos con un entorno de desarrollo configurado con las siguientes características:

  • Versión de Angular CLI: 17.3.6
  • Node: 18.18.0
  • Gestor de paquetes: npm 9.8.1
  • Sistema Operativo: darwin arm64

A la hora de instalar Stryker, existe una CLI propia de la librería para facilitar su integración. Sin embargo, para la opción de Angular, la configuración la realiza teniendo en cuenta Karma y, en nuestro caso, estamos utilizando Jest. Aun así, podríamos instalarlo utilizando el CLI seleccionando la opción ‘Other’. En nuestro ejemplo, vamos a proceder a instalarlo y configurarlo de manera manual.

Este proceso nos ayudará a entender mejor su funcionamiento y sus diferentes opciones de configuración.

Lo primero de todo será instalar la dependencia necesaria de Stryker para Jest:

npm install @stryker-mutator/jest-runner --save-dev

Después, crearemos un fichero stryker.conf.json en la ruta raíz de nuestro proyecto. A continuación, se muestra un ejemplo cómo debería ser el contenido de este archivo:

{
 "packageManager": "npm",
 "reporters": ["html", "clear-text", "progress"],
 "testRunner": "jest",
 "coverageAnalysis": "perTest",
 "tsconfigFile": "tsconfig.json",
 "mutate": [
   "src/**/*.ts",
   "!src/**/*.spec.ts",
   "!src/main.ts",
   "!src/setup-jest.ts",
   "!src/environments/*.ts",
   "!src/app/core/models/*",
   "!src/app/shared/mocks/**/*",
   "!src/app/shared/constants/**/*"
  ],
 "jest": {
   "projectType": "custom",
   "configFile": "jest.config.js",
   "config": {
     "testEnvironment": "jest-environment-jsdom"
   }
 },
 "ignoreStatic": true,
 "disableTypeChecks": "src/**/*.ts",
 "timeoutMS": 10000,
 "thresholds": {
   "high": 100,
   "low": 85,
   "break": 80
 },
 "incremental": true
}

packageManager: especifica el gestor de paquetes utilizado en el proyecto.

reporters: define los reportes generados por Stryker. En el caso de html genera un informe visual, para clear-text muestra los resultados en texto claro, y la opción progress indica el progreso de las pruebas.

testRunner: indica cuál será el ejecutor de pruebas utilizado para correr los tests unitarios.

coverageAnalysis: determina cómo se analizará la cobertura de las pruebas. La opción perTest significa que se medirá la cobertura para cada prueba individualmente.

tsconfigFile: especifica la ubicación del archivo de configuración de TypeScript.

mutate: contiene la lista de patrones de los archivos que deben ser mutados, incluyendo exclusiones.

  • projectType: define el tipo de proyecto de Jest.
  • configFile especifica la ubicación del archivo de configuración de Jest.
  • config:
    • testEnvironment: establece el entorno de prueba de Jest, en este caso, jest-environment-jsdom, que simula un entorno de navegador.

ignoreStatic: indica a Stryker que ignore el análisis de archivos estáticos (no mutables).

disableTypeChecks: desactiva las comprobaciones de tipo en los archivos TypeScript especificados, agilizando el proceso de mutación.

timeoutMS: establece el tiempo de espera máximo para una prueba en milisegundos.

thresholds: define los umbrales de calidad para las pruebas.

  • high: indica una cobertura de mutación perfecta.
  • low: indica una cobertura aceptable.
  • break: si la cobertura cae por debajo de este valor, Stryker marcará el análisis como fallido.

incremental: habilita la mutación incremental, lo que permite que Stryker solo mute y pruebe partes del código que han cambiado, mejorando la eficiencia.

También, actualizaremos los scripts de nuestro package.json, para ello añadiremos esta línea:

 "scripts": {
   ...,
   "test-mutation": "stryker run"
   ...
 },

Además, en el archivo .gitignore, incluiremos esta otra línea:

.stryker-tmp/

Eficacia de los tests unitarios en Angular utilizando tests de mutación

En este apartado vamos a comparar la efectividad entre dos enfoques distintos a la hora de realizar los tests unitarios. Para ello vamos a crear dos componentes con el mismo código, pero con diferentes casos de prueba.

Creación de módulo y componentes

Primero, creamos un módulo nuevo y los dos componentes:

ng generate module modules/mutation-testing --routing      
ng generate component modules/mutation-testing/components/good-tests
ng generate component modules/mutation-testing/components/bad-tests

El contenido de los archivos good-tests.component.ts y bad-tests.component.ts será el mismo y contendrá diversas funciones que nos servirán para los tests unitarios:

 add(a: number, b: number): number {
   return a + b;
 }

 increase(value: number): number {
   return value + 1;
 }

 sendGreeting(name: string): string {
   return `Hello, ${name}!`;
 }

 isNegative(number: number): boolean {
   return number < 0;
 }

 isValid(age: number, hasPermission: boolean): boolean {
   return age >= 18 && hasPermission;
 }

 updateStock(currentStock: number, additionalUnits: number): number {
   currentStock += additionalUnits;
   return currentStock;
 }

 getGreeting(): string {
   return 'Hello, world!';
 }

 getData(callback: () => void): void {
   callback();
 }

Pruebas del componente BadTestsComponent

A continuación, realizaremos tests unitarios para el componente BadTestsComponent:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BadTestsComponent } from './bad-tests.component';
import { MutationTestingModule } from '../../mutation-testing.module';

describe('BadTestsComponent', () => {
 let component: BadTestsComponent;
 let fixture: ComponentFixture<BadTestsComponent>;

 beforeEach(async () => {
   await TestBed.configureTestingModule({
     imports: [MutationTestingModule],
   }).compileComponents();

   fixture = TestBed.createComponent(BadTestsComponent);
   component = fixture.componentInstance;
   fixture.detectChanges();
 });

 it('should call add method', () => {
   const spy = jest.spyOn(component, 'add');
   component.add(6, 3);
   expect(spy).toHaveBeenCalled();
 });

 it('should call increase method', () => {
   const spy = jest.spyOn(component, 'increase');
   component.increase(1);
   expect(spy).toHaveBeenCalled();
 });

 it('should call sendGreeting method', () => {
   const spy = jest.spyOn(component, 'sendGreeting');
   component.sendGreeting('world');
   expect(spy).toHaveBeenCalled();
 });

 it('should call isNegative method', () => {
   const spy = jest.spyOn(component, 'isNegative');
   component.isNegative(2);
   expect(spy).toHaveBeenCalled();
 });

 it('should call isValid method', () => {
   const spy = jest.spyOn(component, 'isValid');
   component.isValid(20, true);
   expect(spy).toHaveBeenCalled();
 });

 it('should call updateStock method', () => {
   const spy = jest.spyOn(component, 'updateStock');
   component.updateStock(5, 5);
   expect(spy).toHaveBeenCalled();
 });

 it('should call getGreeting method', () => {
   const spy = jest.spyOn(component, 'getGreeting');
   component.getGreeting();
   expect(spy).toHaveBeenCalled();
 });

 it('should call getData method', () => {
   const spy = jest.spyOn(component, 'getData');
   const mockCallback = jest.fn();
   component.getData(mockCallback);
   expect(spy).toHaveBeenCalled();
 });
});

Como se puede observar, estos tests se limitan simplemente a recorrer las líneas de la función, pero no verifican la lógica interna ni los resultados esperados. Esta es una mala práctica, ya que no estamos probando la lógica funcional o de negocio, lo que no nos brinda seguridad sobre nuestro código, ya que no lo estamos haciendo resiliente a cambios.

Pruebas del componente GoodTestsComponent

Después realizaremos tests unitarios para el componente GoodTestsComponent:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GoodTestsComponent } from './good-tests.component';
import { MutationTestingModule } from '../../mutation-testing.module';

describe('GoodTestsComponent', () => {
 let component: GoodTestsComponent;
 let fixture: ComponentFixture<GoodTestsComponent>;

 beforeEach(async () => {
   await TestBed.configureTestingModule({
     imports: [MutationTestingModule],
   }).compileComponents();

   fixture = TestBed.createComponent(GoodTestsComponent);
   component = fixture.componentInstance;
   fixture.detectChanges();
 });

 it('should add two numbers', () => {
   expect(component.add(6, 3)).toBe(9);
   expect(component.add(-1, 1)).toBe(0);
   expect(component.add(0, 0)).toBe(0);
   expect(component.add(3.5, 2.5)).toBe(6);
 });

 it('should increase the value', () => {
   expect(component.increase(1)).toBe(2);
 });

 it('should send greeting the user', () => {
   expect(component.sendGreeting('world')).toBe('Hello, world!');
   expect(component.sendGreeting('')).toBe('Hello, !');
   expect(component.sendGreeting('Sergio')).toBe('Hello, Sergio!');
 });

 it('should return true for negative numbers', () => {
   expect(component.isNegative(2)).toBe(false);
   expect(component.isNegative(0)).toBe(false);
   expect(component.isNegative(-2)).toBe(true);
 });

 it('should check valid', () => {
   expect(component.isValid(19, true)).toBe(true);
   expect(component.isValid(19, false)).toBe(false);
   expect(component.isValid(18, true)).toBe(true);
   expect(component.isValid(18, false)).toBe(false);
   expect(component.isValid(17, true)).toBe(false);
   expect(component.isValid(17, false)).toBe(false);
 });

 it('should update the stock', () => {
   expect(component.updateStock(5, 5)).toBe(10);
   expect(component.updateStock(7, -5)).toBe(2);
   expect(component.updateStock(0, 0)).toBe(0);
 });

 it('should return the greeting', () => {
   expect(component.getGreeting()).toBe('Hello, world!');
   expect(component.getGreeting()).not.toBeNull();
   expect(component.getGreeting()).not.toBe('');
 });

 it('should call the callback function', () => {
   const mockCallback = jest.fn();
   component.getData(mockCallback);
   expect(mockCallback).toHaveBeenCalled();
   expect(mockCallback).toHaveBeenCalledTimes(1);
 });
});

A diferencia de los tests anteriores, estos sí verifican en detalle la lógica de los métodos invocados. No solo recorren las líneas de código, sino que también aseguran que cada método funcione correctamente en todos sus caminos posibles.

Este enfoque garantiza que la lógica interna de los métodos coincida con el comportamiento esperado, tanto desde una perspectiva funcional como de negocio, proporcionando una mayor confianza en la resiliencia y robustez del código.

Informe de cobertura unitaria

Para obtener el porcentaje de cobertura de tests unitarios, ejecutaremos el comando:

npm run coverage

En el informe generado en la ruta coverage/lcov-report/index.html podemos observar que, aunque los tests del componente BadTestsComponent no están probando la lógica, obtienen los mismos resultados de cobertura que los del GoodTestsComponent. Este es el problema que mencionamos al principio: tener un 100% de cobertura unitaria no significa que tengamos tests efectivos o de calidad.

Imagen que muestra un informe de la cobertura unitaria de la prueba

Informe de cobertura de mutación

Para calcular el porcentaje de cobertura de tests de mutación, ejecutaremos:

npm run test-mutation

En el informe generado en la ruta reports/mutation/mutation.html, podemos ver que el porcentaje de tests de mutación es distinto al de cobertura unitaria. Esta diferencia indica que, aunque los tests unitarios pueden tener una alta cobertura, no están detectando todos los errores posibles. El análisis de mutación muestra qué partes del código no están bien probadas y ayuda a identificar qué casos hay que cubrir para que los tests sean más efectivos y fiables.

Imagen que muestra los resultados del informe de cobertura de mutación en la prueba

Si lo analizamos más en profundidad, veremos las diferentes mutaciones:

  • Mutación aritmética: cambia el operador + a -.
Ejemplo de mutación aritmética
  • Mutación incremento/decremento: modifica el operador de la variable para restar en vez de sumar.
Ejemplo de mutación incremento / decremento
  • Mutación condicional: modifica el operador condicional < por <=.
Ejemplo de mutación condicional
  • Mutación literal: modifica el literal Hello, ${name}!por ``.
Ejemplo de mutación literal
  • Mutación lógica: modifica el operador lógico && por ||.
Ejemplo de mutación lógica
  • Mutación asignación: modifica el operador de asignación += por -=.
Ejemplo de mutación asignación
  • Mutación salida: modifica el valor devuelto por la función.
Ejemplo de mutación salida
  • Mutación método/función: elimina la llamada a una función.
Ejemplo de mutación método función

Si deseas ver el código completo de este ejemplo, puedes encontrarlo aquí.

Mejores prácticas y consejos

  • Incluye tests unitarios en cada desarrollo: cada tarea de desarrollo debe ir acompañada de sus correspondientes pruebas unitarias. Incorporar tests desde el inicio es esencial para cubrir una amplia variedad de casos y garantizar la calidad del código. Aunque la cobertura de líneas no garantiza tests de calidad, es un buen punto de partida para detectar y corregir errores en fases tempranas del desarrollo.
  • Configura Stryker adecuadamente: adapta el archivo stryker.conf.json para tu proyecto específico. Asegúrate de excluir archivos irrelevantes y ajustar los umbrales de calidad según las necesidades del proyecto.
  • Utiliza mutaciones incrementales: habilitar la mutación incremental puede mejorar la eficiencia del proceso de pruebas, al enfocarse solo en las partes del código que han cambiado.
  • Prioriza la legibilidad y mantenibilidad: al escribir tests, prioriza la claridad y la mantenibilidad. Tests bien escritos facilitan la identificación de problemas y la realización de cambios futuros.
  • Revisa regularmente los informes de mutación: analiza los informes de Stryker para identificar áreas de tu código que no están bien probadas. Utiliza esta información para mejorar tus tests de manera continua.
  • Integra las pruebas de mutación en tu CI/CD: automatiza la ejecución de Stryker como parte de tu pipeline de integración y entrega continua. Esto asegura que cualquier cambio en el código se evalúe inmediatamente en términos de su impacto en la cobertura de mutación.
  • Acompaña a tu equipo en la adopción de pruebas de mutación: asegúrate de que todos los miembros de tu equipo comprendan la importancia de estas pruebas y sepan cómo interpretar los informes de Stryker. Fomenta una cultura de mejora continua en la calidad de los tests, promoviendo el aprendizaje y la colaboración.

Conclusión

Los tests de mutación son esenciales para garantizar la eficacia de tus tests unitarios en los proyectos. Aunque un alto porcentaje de cobertura de líneas es importante, no garantiza por sí solo que tus pruebas sean de calidad. Los tests de mutación revelan áreas del código que no están bien probadas y ayudan a identificar casos adicionales que deben cubrirse para asegurar que tus tests sean robustos y fiables.

Implementar Stryker en tu flujo de trabajo te permitirá detectar errores potenciales que, de otra manera, serían difíciles de identificar, mejorando así la calidad general de tu código y haciéndolo más seguro y mantenible.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.