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.
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.
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!
Mocha
Chai
// 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);
});
});
Jasmine
Angular testing utilities
Karma
Protractor
Ejemplos oficiales en Plnkr.
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.
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
Desarrollo guiado por pruebas de software, o Test-driven development
// ...
// 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>.
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();
});
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();
}));
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
// 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);
});
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 }
]
});