¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
Conoce nuestra marca.¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
Conoce nuestra marca.dev
Sergio Casado 10/07/2024 Cargando comentarios…
Aprende cómo Stryker puede mejorar la calidad de tus tests unitarios y facilitar la identificación de nuevos casos de prueba.
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.
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.
Existen diferentes tipos de mutación que se pueden agrupar en tres grandes grupos:
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/
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.
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();
}
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.
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.
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.
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.
Si lo analizamos más en profundidad, veremos las diferentes mutaciones:
Hello, ${name}!
por ``.Si deseas ver el código completo de este ejemplo, puedes encontrarlo aquí.
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.
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.
Cuéntanos qué te parece.