Unit Testing

Todos los que alguna vez hemos desarrollado nos encontramos con situaciones en donde intentas arreglar un bug, o tratas de implementar una pequeña funcionalidad, y de repente otras partes dejan de funcionar. La manera de evitarlo es usar unit tests.

¿Qué es?

  • Es una forma de comprobar el correcto funcionamiento de una unidad de código.
  • Sirve para asegurar que cada unidad funcione correctamente y eficientemente por separado.
  • Además verifica que sea correcto el nombre, los nombres y tipos de los parámetros, el tipo de lo que se devuelve, que si el estado inicial es válido entonces el estado final es válido

Características

Completas
Deben cubrir la mayor cantidad de código.

Automatizable
No debería requerirse una intervención manual. Esto es especialmente útil para integración continua.

Profesionales
Las pruebas deben ser consideradas igual que el código, con la misma profesionalidad, documentación, etc.

Repetibles o Reutilizables
No se deben crear pruebas que sólo puedan ser ejecutadas una sola vez. También es útil para integración continua.

Independientes
La ejecución de una prueba no debe afectar a la ejecución de otra.

Ventajas

Fomentan el cambio

Las pruebas unitarias facilitan que el programador cambie el código para mejorar su estructura (lo que se ha dado en llamar refactorización), puesto que permiten hacer pruebas sobre los cambios y así asegurarse de que los nuevos cambios no han introducido errores.

Simplifica la integración

Puesto que permiten llegar a la fase de integración con un grado alto de seguridad de que el código está funcionando correctamente. De esta manera se facilitan las pruebas de integración.

Documenta el código

Las propias pruebas son documentación del código puesto que ahí se puede ver cómo utilizarlo.

Separación de la interfaz y la implementación

Dado que la única interacción entre los casos de prueba y las unidades bajo prueba son las interfaces de estas últimas, se puede cambiar cualquiera de los dos sin afectar al otro, a veces usando objetos mock (mock object) para simular el comportamiento de objetos complejos.

Los errores están más acotados y son más fáciles de localizar

Dado que tenemos pruebas unitarias que pueden desenmascararlos.

En el caso de Angular:

 

Permite desarrollar el Frontend sin depender del Backend

No es necesario que el backend se esté desarrollando a la misma velocidad que el frontend. Se pueden crear mocks de datos y simular el funcionamiento de un API REST. ¡Nos permite desarrollar sin backend!

Limitaciones

  • Las pruebas unitarias no descubrirán todos los errores del código.
  • La técnica de generación aleatoria de objetos para amplificar el alcance de las pruebas de unidad (testing aleatorio) sólo prueban las unidades por sí solas. No descubrirán errores de integración, problemas de rendimiento y otros problemas que afectan a todo el sistema en su conjunto.
  • Puede no ser trivial anticipar todos los casos especiales de entradas que puede recibir la unidad de programa bajo estudio.
  • Sólo son efectivas si se usan en conjunto con otras pruebas de software.

Herramientas

  • JUnit: Java
  • PHPUnit
  • CPPUnit
  • NUnit: .NET
  • QUnit: Javascript
  • PyUnit: Python
  • ...

Ejemplo con NodeJS

(Mocha + Chai)

Mocha

  • Es un framework de testing que corre sobre NodeJS y en el navegador web.
  • Hace el testing asíncrono simple y divertido.
  • Es el que permite "correr" los tests.

Chai

  • Es una librería para realizar assertions (afirmaciones) bajo BDD o TDD.
  • Funciona tanto con NodeJS como en el navegador web.
// persona.js
class Persona {
  constructor(nombre, apellido) {
    this.nombre = nombre;
    this.apellido = apellido;
  }
  toString() {
    let str = this.nombre + ' ' + this.apellido;
    return str;
  }
}
// persona.spec.js

const chai = require('chai');
const expect = chai.expect;

const Persona = require('../src/persona');

describe('Persona', () => {
  it('toString() debería devolver nombre y apellido en un string', () => {
    let pers = new Persona('Nombre', 'Apellido');

    expect(pers).respondTo('toString');
    expect(pers.toString()).to.be.a('string');
    expect(pers.toString()).to.equal('Nombre Apellido');
  });
});
// funciones.js
function getEdad(persona) {
  let today = new Date();
  let age = today.getFullYear() - persona.fechaNacimiento.getFullYear();

  if (today.getMonth() < persona.fechaNacimiento.getMonth() - 1) {
    age--;
  }

  if (persona.fechaNacimiento.getMonth() - 1 == today.getMonth() &&
    today.getDate() < persona.fechaNacimiento.getDate()) {
    age--;
  }

  return age;
}
// funciones.spec.js
const chai = require('chai');
const expect = chai.expect;

const Persona = require('../src/persona');
const getEdad = require('../src/funciones').getEdad;

describe('Funciones', () => {
  it('getEdad() debería devolver la edad de acuerdo a la fecha de nacimiento', () => {
    let pers = new Persona('nombre', 'apellido', '01/08/1994');
    const age = getEdad(pers);

    expect(age).to.equal(22);
  });
});
  • Con expect podemos hacer comprobaciones de valores, para asegurar que obtenemos los valores esperados cuando llamamos a una función, o comprobar que existan determinados métodos, o incluso el nombre de dichos métodos.
  • describe: nos permitirá agrupar unit tests en secciones. Pueden anidarse unos describe dentro de otros, para crear subsecciones de unit tests.
  • it: encapsula la implementación de un unit test

Otras funciones útiles

  • before: Este método irá dentro de una sección describe, y se ejecutará antes que ninguno de los unit tests (it) de la sección. Nos servirá para inicializar variables necesarias para la ejecución de los unit tests.
  • after: Se ejecutará después de todos los unit tests de la sección. Podremos utilizarlo para dejar el sistema en el estado en el que estaba antes de ejecutar los unit tests de la sección.
  • También existen beforeEach y afterEach que se ejecutarán antes y después de cada unit test de la sección.

Herramientas

Jasmine

Angular testing utilities

Karma

Protractor

Ejemplos oficiales en Plnkr.

Tests aislados

Examinan una instancia de una clase por sí misma sin considerar niguna dependencia con Angular o cualquier otro valor inyectado.

Dicha instancia es creada por el tester (new), proveyendo todos los parámetros necesarios.

Angular testing

Incluye la clase TestBed y  funciones de ayuda para facilitar el testing en @angular/core/testing.

Los tests aislados no revelan como los componentes interactúan con Angular u otros componentes. Es decir, no corren dentro del framework de Angular.

// example.spec.ts

describe('1st tests', () => {
  it('true is true', () => {
    expect(true).toBe(true);
  });
});

* Considere que si el test anterior falla, usted debería comenzar a correr, el fin está cerca.

Si se utilizó @angular/cli para generar el proyecto, toda la suite para testing ya está configurada dentro del proyecto.

Para ejecutar los tests desde consola:

# instalar dependencias si aún no han sido instaladas
$ npm install

# correr tests
$ npm test

Los tests correrán en un instancia del navegador que se ejecutará automáticamente, preparada para correr tests con Jasmine y poder ver los mensajes de error y fallos.

Esto quedará corriendo, esperando a cambios en el proyecto para volver a ejecutar los tests casi instantáneamente.

Ver el test corriendo en Plnkr

Ejemplo de lo que muestra el navegador

TDD

Desarrollo guiado por pruebas de software, o Test-driven development

  • Involucra escribir las pruebas primero (Test First Development) y Refactorización (Refactoring).
  • En primer lugar, se escribe una prueba y se verifica que las pruebas fallan. A continuación, se implementa el código que hace que la prueba pase satisfactoriamente y seguidamente se refactoriza el código escrito.
  • El propósito es lograr un código limpio que funcione.
  • La idea es que los requisitos sean traducidos a pruebas, de este modo, cuando las pruebas pasen se garantizará que el software cumple con los requisitos que se han establecido.

¿Qué se debe testear al desarrollar con Angular?

  • Componentes:
    • Simples (ejemplo)
    • Con template externo (ejemplo)
    • Con dependencia de servicios, sean síncronos o asíncronos.
    • Con @Input() y @Output()
    • Con rutas definidas
  • Tests aislados (Isolated unit tests) con sus dependencias:
    • Servicios
    • Directivas
    • Pipes

TestBed

  • Es la primera y más importante utilidad que provee Angular en cuanto a testing.
  • Crea un módulo de testing (clase @NgModule) que se puede configurar para producir el entorno necesario para la lo que se quiere testear.
  • Sirve para hacer independiente del resto de la aplicación el componente o servicio que se quiere testear.
// ...
// async transforma lo asíncrono en síncrono
beforeEach(async(() => {
  TestBed.configureTestingModule({
    imports: [
      SomeModule
    ],
    declarations: [
      AppComponent
    ],
  }).compileComponents();
}));
// ...

compileComponents(): compila el template (html) y css

  it('should create the app', async(() => {
    // fixture es el objeto que representa nuestro componente
    // con el que podemos manipularlo y acceder sus elementos
    // del DOM (template html)
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  }));
  it(`should have as title 'app'`, async(() => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app.title).toEqual('app');
  }));
  it('should render title in a h1 tag', async(() => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!!');
  }));

detectChanges(): le informa a Angular que tiene que realizar una detección de cambios, lanzando el data binding y la propagación de la propiedad "title" en el elemento DOM <h1>.

Componente con @Input() y @Output()

export class IOComponent implements OnInit {
  @Input() input: string;
  @Output() output = new EventEmitter<string>();

  constructor() { }

  ngOnInit() {  }

  change() {
    this.output.emit(this.input + '!');
  }
}
<p>
  {{input}}
</p>
<button (click)="change()"></button>
it('should show the input property', () => {
  component.input = 'hello';
  fixture.detectChanges();

  const de = fixture.debugElement.query(By.css('p'));
  expect(de.nativeElement.textContent).toContain('hello');
});
it('should emit a change when the button is clicked', (done) => {
  component.input = 'hello';
  fixture.detectChanges();

  component.output.subscribe(value => {
    expect(value).toBe('hello!');
    done();
  });

  const de = fixture.debugElement.query(By.css('button'));
  de.nativeElement.click();
});

Servicios

Síncrono

describe('SyncService', () => {
  beforeEach(() => {
    // Angular se encarga de instancia el servicio
    TestBed.configureTestingModule({
      providers: [SyncService]
    });
  });
});

Código de sync.service.ts

it('should allow add an element', inject([SyncService], (service: SyncService) => {
  service.addElement('123', {name: 'Hola'});
  expect(service.hasElement('123')).toBeTruthy();
  expect(service.getElement('123')).toEqual({name: 'Hola'});
}));
// El servicio se inyecta dentro del test con inject(...)
it('should be created', inject([SyncService], (service: SyncService) => {
  expect(service).toBeTruthy();
}));

Asíncrono

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

  beforeEach(() => {
    // Si el servicio no tiene dependencias se puede
    // instanciar manualmente
    service = new AsyncService();
  });
});

Código de async.service.ts

// done es una función que el framework de
// testing pasa al test unitario, de esta forma
// nos servirá para indicarle al mismo que el
// test ha terminado al llamarla, y así
// testear las partes asíncronas
it('should add item and notify', (done) => {
  service.addItem('Nuevo item');

  // puedo testear las partes asíncronas,
  // sólo debo llamar a done() cuando haya terminado
  service.notifications().subscribe(items => {
    expect(items.length).toBe(1);
    expect(items).toEqual(['Nuevo item']);
    done();
  });
});

Servicio con Http (Angular) como dependencia

Código de http.service.ts

describe('HttpService', () => {
  let service: HttpService;
  let backend: MockBackend;
  let lastConnection: MockConnection;

  beforeEach(() => {
    // ReflectiveInjector permite resolver las dependencias
    // entre servicios
    this.injector = ReflectiveInjector.resolveAndCreate([
      // puedo pasar mis propias clases u objetos (useValue)
      // para reemplazar a los providers por defecto
      {provide: ConnectionBackend, useClass: MockBackend},
      {provide: RequestOptions, useClass: BaseRequestOptions},
      Http,
      HttpService
    ]);
    service = this.injector.get(HttpService);
    backend = this.injector.get(ConnectionBackend) as MockBackend;
    backend.connections.subscribe((connection: MockConnection) => {
      lastConnection = connection
    });
  });
});
it('should getPosts()', fakeAsync(() => {
  // Fake data (mock)
  const posts = [
    {
      "userId": 1,
      "id": 1,
      "title": "Título 1",
      "body": "Asd erqwe zc zxc"
    },
    {
      "userId": 1,
      "id": 2,
      "title": "Título 2",
      "body": "Qwertrtysdf df"
    }
  ];
  service.getPosts(); // se genera un MockConnection
  lastConnection.mockRespond(new Response(new ResponseOptions({
    body: JSON.stringify(posts)
  })));
  tick(); // ejecuta un ciclo del event loop
  expect(lastConnection.request.method).toBe(RequestMethod.Get);
  expect(lastConnection.request.url)
    .toBe('https://jsonplaceholder.typicode.com/posts');
}));
$ npm test

Consideraciones

  • Se pueden instanciar los componentes o servicios a testear de forma manual o a través de Angular (TestBed y ReflectiveInjector). Conviene delegar la tarea de instanciar a Angular si los servicios tienen dependencias con otros servicios.
  • Se puede simular un test asíncrono como si fuese síncrono con async(..) o fakeAsync(..).
    • fakeAsync() requiere que se llama a tick() para simular un ejecución del event loop.
  • fixture es un objeto que nos permite manipular la instancia del componente a testear. Se puede consultar por elementos del DOM para testear parte de la interfaz gráfica.
    • Se debe llamar a detectChanges() de fixture para que nuestro componente tome los nuevos cambio y realice el data binding con el template.
  • Para realizar mock de datos cuando un servicio se comunica con un API REST a través del servicio Http de Angular se debe utilizar MockBackend, una clase especial que reemplaza el funcionamiento interno de Http para realizar consultas y a su vez nos permite proveer nuestras propias respuestas como si de la API se tratase. Esto nos permite trabajar sin Backend.

spyOn()

// Mock data
const post = {
  userId: 123,
  id: 8123,
  title: 'Asd qwd: zxc',
  body: 'Text asd zxc llsd qwe'
};

// spyOn es una función de Jasmine
const spy = spyOn(service, 'getPostById')
      .and.returnValue(Observable.of(post));

Ver más en documentación de Jasmine

it('should have been called', () => {
  // Aquí puedo llamar a algún componente o servicio
  // que utilice de fondo a mi servicio
  // HttpService (encargado de consultar publicaciones
  // en un API REST)

  // Luego, puedo comprobar si se ha llamado al método
  // de HttpService, como en este caso getPostById('id')
  expect(service.getPostById).toHaveBeenCalled();
  expect(service.getPostById).toHaveBeenCalledWith('8123');
  expect(service.getPostById).toHaveBeenCalledTimes(1);
});

Reemplazar providers

Cuando un componente o servicio tiene dependencias con otros servicios (inyectados a través del constructor), podemos reemplazar dichos servicios con nuestras propias clases o instancias.

    TestBed.configureTestingModule({
       declarations: [ WelcomeComponent ],
    // providers:    [ UserService ]  // No proveer el servicio real
       providers:    [
         {provide: UserService, useValue: userServiceStub }
       ]
    });
class MyUserServiceStub {
  // ...
  getUserDetail() {
    // devuelve los detalles del usuario logueado
  }
  // ...
}

const userServiceStub = new MyUserServiceStub();

También se puede proveer como clase, y Angular se encargará de instanciar el servicio

    TestBed.configureTestingModule({
       declarations: [ WelcomeComponent ],
    // providers:    [ UserService ]  // No proveer el servicio real
       providers:    [
         {provide: UserService, useClass: MyUserServiceStub }
       ]
    });

Angular 2: Unit testing

By Alan Boglioli

Angular 2: Unit testing

  • 508