Angular Testing

Nota: faltan detalles

Jasmine y Karma

Angular usa Jasmine como framework de pruebas y Karma como test Runner. A partir de la versión 15 de Angular se eliminaron algunos archivos de configuración para hacer más fácil el entendimiento del framework para nuevos desarrolladores. Karma.conf.js ya no se crea por default con el ng new, sin embargo lo podemos agregar con el siguiente comando:

ng generate config karma

Automáticamente karma va a leer todos los archivos del proyecto que acaben con spec.ts para ejecutar las pruebas.

Estructura

describe('HomeComponent', () => {
  let component: HomeComponent;

  it('should create', () => {
    expect(component).toBeTruthy();
  });
  
});

describe - define una suite de tests. Una colección de tests. Recibe dos parámetros, un string con el nombre de la suite y una function() donde se definen los tests.

it - define un test en particular. Recibe como parámetro el nombre del test y una función a ejecutar por el test.

expect - Lo que esperar recibir ese test. Con expect se hace la comprobación del test.

Se puede seguir unos pasos muy utiles

Arrange:(Arreglar). Se establece el estado inicial, conocida como el sujeto a probar. Aquí se inicializan variables, importaciones. Se crea el ambiente a probar.

Act (Actuar): Se generan acciones o estímulos. Se llaman métodos, o se simulan clicks por ejemplo

Assert (Afirmar): observar el comportamiento. Los resultados son los esperados. Eje: Que algo cambie, se incremente, o no suceda nada.

import { Calculator } from './calculator';

describe('Test for Calculator', () => {
  it('#multiply should return a nine', () => {
    //Arrange
    const calculator = new Calculator();
    //Act
    const rta = calculator.multiply(3,3);
    //Assert
    expect(rta).toEqual(9);
  });
});

Matchers

//Comunes
.toBe();
.not.toBe();
.toEqual();

//Veracidad
.toBeNull()
.toBeUndefined()
.toBeDefined()
.toBeUndefined()
.toBeTruthy() 
.toBeFalsy() 

//Numeros
.toBeGreaterThan(3);
.toBeGreaterThanOrEqual(3.5);
.toBeLessThan(5);
.toBeLessThanOrEqual(4.5);

//Numeros decimales
expect(0.3).toBeCloseTo(0.3)

//Strings
.not.toMatch(/I/);
.toMatch(/stop/);

//Arrays
.toContain('milk');

//Ecepciones
myfunction.toThrow(Error);

Reporte de covertura

Para poder generar un reporte de covertura (más visual y específico) que no estará en modo escucha continua, lo que debe hacer es correr el sigueinte comando en la términal

ng test --no-watch --code-coverage

Con esto podemos tener mas detalle, por ejemplo de que funciones no tienen pruebas etc.

Se pueden enfocar en ciertas pruebas u omitir agregando la letra x o f al inicio de las palabras claves de los test.

  • con fdescribeejecuta únicamente el suite de test

  • con xdescribe se omite el suite de test

  • con fit ejecuta el focus sobre un test

  • con xit se omite un test

Umbral mínimo de covertura

En el archivo karma.conf.js se agrega lo siguiente para actualizarlo y saber qué necesita ser cubierto con pruebas de acuerdo al umbral.

// karma.conf.js
...
coverageReporter: {
      ...
      check: {
        global: {
          statements: 80,
          branches: 80,
          functions: 80,
          lines: 80
        }
      }
    },
...

Opcionalmente podemos tener un reporter adicional con mejoras visuales

Instalar Mocha Report

npm i karma-mocha-reporter --save-dev

Agregar en karma.conf.js en plugins

...
plugins: [
      ...
      require('karma-mocha-reporter')
    ],
...

Cambiar reporters a

...
    reporters: ['mocha'],
...

Testing a Servicios

// value.service.ts
export class ValueService {
  private value = 'my value';

  constructor() { }

  getValue() {
    return this.value;
  }

  setValue(value: string) {
    this.value = value;
  }

  getPromiseValue() {
    return Promise.resolve('promise value');
  }

  getObservableValue() {
    return of('observable value');
  }
}
// value.service.spec.ts
describe('ValueService', () => {
  let service: ValueService;

  // Cada que corre una prueba se ejecuta
  beforeEach(() => {
    service = new ValueService();
  });

  // Es recomendable primero validar si el servicio fue creado de una manera exitosa
  it('should be create', () => {
    expect(service).toBeTruthy();
  });

  describe('Tests for getValue', () => {
    it('should return "my value"', () => {
      expect(service.getValue()).toBe('my value');
    });
  });

  describe('Tests for setValue', () => {
    it('should change the value', () => {
      expect(service.getValue()).toBe('my value');
      service.setValue('change');
      expect(service.getValue()).toBe('change');
    });
  });

  describe('Tests for getPromiseValue', () => {
    it('should return "promise value" from promise with then', (done) => {
      service.getPromiseValue()
      .then((value) => {
        // assert
        expect(value).toBe('promise value');
        done();
      });
    });

    it('should return "promise value" from promise using async', async () => {
      const rta = await service.getPromiseValue();
      expect(rta).toBe('promise value');
    });
  });

});

Cada que colocamos un it estamos indicando un escenario de prueba, cada escenario de prueba debería manejarse de manera insolada. Esto significa que una prueba no debería afectar a la siguiente o a otra prueba.

Lo que hacemos es crear las instancias que ocupamos para cada prueba, por ejemplo la creación del servicio. Para optimizar el código se usa la función beforeEach que va a ejecutarse antes de cada prueba.

Cada vez que se corra una funcion asíncrona dentro de un test y se resuelva la promesa dentro de una función then(), por ejemplo, se recibirá una funcion para indicar manualmente que ha terminado la ejecución. Generalmenet se usa done(). Aunque lo recomendable es usar async/await.

Test a Servicios con Dependencias

Recordar que si vamos a testear un servicio que tiene dependencias, nosotros no debemos usar las dependencias reales, es decir, ya que no le corresponde a nuestra prueba que las dependencias nos devuelval los valores deseados, nosotros solo debemos encargarnos de que nuestra prueba funcione, entonces podriamos hacer un mock de datos fake para completar nuestra prueba.

El resto de dependencias tendrán sus propias pruebas ya, así que no nos deben de importar para la nuestra. Ejemplo:

Tenemos otro servicio.

export class MasterService {
  constructor(
    private valueService: ValueService
  ) { }

  getValue() {
    return this.valueService.getValue();
  }
}

Para aislar completamente estas pruebas usamos el siguiente código.

describe('MasterService', () => {
  it('should be return "other value" from the fake service', () => {
    const fakeValueService = new FakeValueService();
    const masterService = new MasterService(fakeValueService as unknown as ValueService);
    expect(masterService.getValue()).toBe('fake value');
  });
});

/* Este código no sería el correcto
describe('MasterService', () => {
  it('should be return "my value" from the real service', () => {
    const valueService = new ValueService();
    const masterService = new MasterService(valueService);
    expect(masterService.getValue()).toBe('my value');
  });
});
*/

y con un fakeValueService

export class FakeValueService {
  constructor() { }

  getValue() {
    return 'fake value';
  }

  setValue (value: string) { }

  getPromiseValue() {
    return Promise.resolve('fake promise value');
  }
}

Aunque esto es una solución un poco mas correcta, en custion de escalabilidad y mantenimiento puede ser un problema al largo plazo.

Otra forma de hacerlo es con un objeto, por que los objetos en JS funcionan casi como clases por lo que puedo hacer que se pase directamente con un objeto en master.service.spec.ts.

...
  it('should be return "other value" from the fake object', () => {
    const fake = {getValue: () => 'fake from obj'};
    const masterService = new MasterService(fake as ValueService);
    expect(masterService.getValue()).toBe('fake from obj');
  });
...

Esto es especialemente util al momento de probar funcionalidades que por ejemplo se conectan a una API como google maps, etc.

Spies

Para mejorar el tema del acceso a las dependencias que pueda tener un servicio podemos usar herramientas con las que cuenta Jasmine, como Spy.

Un Spy permite interceptar una función y trackear las llamadas a esta y sus argumentos, no necesariamente para obtener el valor que devuelve la función. Estos Spies solo existen en el bloque describe o it en el que fueron definidos, serán removidos luego de su implementación. Podemos definir que hara el Spy luego de ser invocado con and.

...
  it('should call to getValue from ValueService', () => {
    const valueServiceSpy = jasmine.createSpyObj('ValueService', ['getValue']);
    valueServiceSpy.getValue.and.returnValue('fake value');
    const masterService = new MasterService(valueServiceSpy);
    expect(masterService.getValue()).toBe('fake value'); // ok
    // verifica si realmente se ejecuto la variable
    expect(valueServiceSpy.getValue).toHaveBeenCalled();
    expect(valueServiceSpy.getValue).toHaveBeenCalledTimes(1);
  });
...

En este contexto nosotros estamos espiando un servicio que está siendo llamado desde el servicio que estamos probando que es el de MasterService.

Mocking

Son objetos simulados (pseudo-objetos, mock object, objetos de pega) a los que imitan el comportamiento de objetos reales de una forma controlada

Para este ejemplo usamos el concepto de mocking con jasmine a través de sus spies.

TestBed

Angular tiene una suit que nos permite hacer pruebas en un entorno de una manera más sencilla.

Para configurarlo, sería:

import { TestBed } from '@angular/core/testing';

describe('ValueService', () => {
...
  let service: ValueService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [ ValueService ] //*
    });
    service = TestBed.inject(Value);
  });
...
}

Con esto se obtiene un contexto más limpio ya que estamos usando el patrón de inyección de dependencias. Los providers ayudan a aislar cierto servicio, componente etc. Para ser probado, ahi solo debemos meter los módulos que estemos testeando.

TestBed unido con los spies nos ayudara para resolver la inyección de dependencias. Tomando como ejemplo a master service

master.service.spec.ts

import { TestBed } from '@angular/core/testing';
import { MasterService } from './master.service';
import { ValueService } from './value.service';

describe('MasterService', () => {

    let masterService: MasterService;
    let valueServiceSpy: jasmine.SpyObj<ValueService>;

  beforeEach(() => {
    const spy = jasmine.createSpyObj('ValueService', ['getValue']);
    TestBed.configureTestingModule({
       providers: [ MasterService,
                  { provide: ValueService, useValue: spy }
                  ]
    });
    masterService = TestBed.inject(MasterService);
    valueServiceSpy = TestBed.inject(ValueService) as jasmine.SpyObj<ValueService>;
  });

  it('should be created', () => {
    expect(masterService).toBeTruthy();
  });
  
   it('should call to getValue from ValueService', () => {
    const valueServiceSpy = jasmine.createSpyObj('ValueService', ['getValue']);
    valueServiceSpy.getValue.and.returnValue('fake value');
    expect(masterService.getValue()).toBe('fake value');
    expect(valueServiceSpy.getValue).toHaveBeenCalled();
    expect(valueServiceSpy.getValue).toHaveBeenCalledTimes(1);
  });
});

HttpClientTestingModule

La responsabilidad del API donde se conecta el frontend es del backend, es decir, para las pruebas unitarias no debemos tener en cuenta si el servidor está corriendo o no.

HttpClientTestingModule es un módulo de Angular que se utiliza para realizar pruebas unitarias de componentes y servicios que utilizan HttpClient, que es la clase de Angular encargada de realizar solicitudes HTTP.

...
describe('ProductService', () => {
  let service: ProductService;
  let httpController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [ProductService],
    });
    service = TestBed.inject(ProductService);
    httpController = TestBed.inject(HttpTestingController);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  describe('Tests for getProducts', () => {
    it('should return any length', (doneFn) => {
      // Arrange
      const mockData: Words[] = [
        {
          id: '2o2',
          text_es: 'Valor',
          text_en: 'Value',
          rating: 10,
        },
      ];
      // Act
      service.getProducts('bar').subscribe((data) => {
        // Assert
        expect(data.length).toEqual(mockData.length);
        doneFn();
      });

      // http config*
      const url = `${environment.API_URL}/api/product`;
      const req = httpController.expectOne(url);
      req.flush(mockData);
      httpController.verify();

    });
  });
});
...

Al hacer una configuración http podemos decirle a la prueba que los valores los tome de un mock de datos en lugar de hacer una petición al servidor real. para este caso usamos HttpTestingController.

Mocking

Para esto podemos usar la librería Faker JS

npm i @faker-js/faker --save-dev
// product.mock.ts
import { faker } from '@faker-js/faker';

import { Product } from './product.model';

export const generateOneProduct = (): Product => {
  return {
    id: faker.datatype.uuid(),
    title: faker.commerce.productName(),
    price: parseInt(faker.commerce.price(), 10),
    description: faker.commerce.productDescription(),
    category: {
      id: faker.datatype.number(),
      name: faker.commerce.department()
    },
    images: [faker.image.imageUrl(), faker.image.imageUrl()]
  };
}

export const generateManyProducts = (size = 10): Product[] => {
  const products: Product[] = [];
  for (let index = 0; index < size; index++) {
    products.push(generateOneProduct());
  }
  return [...products];
}

y cambiamos el mocking en el archivo product.service.spec.ts

...
// Arrange
const mockData: Words[] = generateManyProducts();
...

Testing a Componentes

Por defecto Angular crea un componente parecido a este:

import { ComponentFixture, TestBed } from '@angular/core/testing';

import { SearchComponent } from './search.component';
import { FormsModule } from '@angular/forms';

fdescribe('SearchComponent', () => {
  let component: SearchComponent;
  let fixture: ComponentFixture<SearchComponent>;

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

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

  it('should create', () => {
    expect(component).toBeTruthy();
  });
  
  // Utilizando el artefacto fixture
  it('should have a <input> tag with a placeholder: Search for...', () => {
    const searchEl = fixture.nativeElement;
    const inputEl = searchEl.querySelector('input');
    expect(inputEl).toBeTruthy();
    expect(inputEl.placeholder).toEqual('Search for...');
    
    // Agnóstico a la plataforma
    const searchDebug = fixture.debugElement
    const inputDebug = searchDebug.query(By.css('input'));
    const inputEl = inputDebug.nativeElement;
    expect(inputEl).toBeTruthy();
    expect(inputEl.placeholder).toEqual('Search for...');
  });
  
});

Es muy similar al .spec al de los servicios pero tiene ciertas diferencias.

Con ComponentFixture tenemos un ambiente que nos Provee Angular para probar el componente, es la manera en que es renderizado y por tanto creado, de ese modo podemos utilizarlo. Con ayuda del artefacto fixture Angular creará el componente para obtener todos sus métodos.

Al igual que con las pruebas de servicios también necesitamos un módulo pequeño, para preparar nuestras pruebas usando TestBed.configureTestingModule (aunque de manera asíncrona).

Si queremos que la prueba a los elementos sea agnóstica a la plataforma debemos utilizar fixture.debugElement.

Cuando corremos en el navegador la aplicación de manera normal, Agular detecta los cambios en automático, sin embargo, en modo pruebas debemos utilizar fixture.detectChanges(); cada que realizamos un cambio dentro del componente, por ejemplo, la detección de un @Input().

Last updated