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.
  1. Decision mutations: modifican las expresiones condicionales y 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.

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:

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.

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.

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:

Ejemplo de mutación aritmética
Ejemplo de mutación incremento / decremento
Ejemplo de mutación condicional
Ejemplo de mutación literal
Ejemplo de mutación lógica
Ejemplo de mutación asignación
Ejemplo de mutación salida
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

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.

Suscríbete