Tests unitarios, de integración y de aceptación en Angular con Jasmine, Karma y Protractor

2
32280

En este tutorial vamos a hablar de un tema que como desarrolladores deberíamos tener presente en cualquier tecnología que
estemos utilizando para implementar y validar nuestras soluciones: los tests.

Índice de contenidos

1. Entorno

Este tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil Mac Book Pro 15″ (2,3 Ghz Intel Core i7, 16 GB DDR3)
  • Sistema Operativo: Mac OS Sierra
  • VSCode 1.12.2
  • @angular/cli 1.0.6
  • jasmine 2.5.2
  • protrator 5.1.0
  • karma 1.4.1

2. Introducción

Este tutorial parte como explicación en texto de lo que se vio en el taller de testing de NgLabs en el marco del Meetup de
Angular Madrid y donde Jorge Baumann (@baumannzone) grabó
un screencast con los pasos que dimos en el taller para la implementación de los tests. Que podéis seguir en este enlace:
Taller de testing con Angular.

Cuando hablas con desarrolladores te das cuenta de que muy pocos saben y hacen tests de sus implementaciones. Entonces, les
pregunto ¿cómo me demuestras que lo que has hecho está bien? Siempre me suelen contestar «pues mira abro la aplicación,
pincho en el botón y sale lo que tiene que salir», me dicen orgullosos. Entonces es cuando les digo y si te pido que esto
me lo demuestres cada vez que hagas un cambio en el código junto con las otras mil historias que has implementado para
saber que esto se puede poner en producción… entonces es cuando algunos cambian el rictus y otros, los más atrevidos,
dicen «bueno pero esto ya lo validará el equipo de QA que para eso está».

Y ese, amigos, es uno de los mayores problemas de las grandes compañías que tienen un equipo de QA, que encima no automatiza
las pruebas con lo que cual el tiempo desde que una persona de negocio tiene una superidea, que va a reportar millones
a su empresa, es directamente proporcional al tiempo que el equipo de QA, con sus pruebas en Excel supercurradas, va a
tardar en validar ese desarrollo para su puesta en producción; perdiendo de esta forma la ventana de oportunidad y por
tanto la ganancia de la idea.

¿Cómo podemos los desarrolladores minimizar este periodo al máximo? La respuesta es sencilla… estando seguros en todo momento
que lo que se va a subir a producción es funcional y técnicamente correcto desde la fase de desarrollo; y esto solo se
puede conseguir con tests y procesos automáticos que podamos repetir una y otra vez. De esta forma podríamos hacer subidas
a producción con total confianza varias veces al día si fuera necesario.

En este tutorial voy a aportar mi granito de arena para todos los que desarrollan con el framework JavaScript de moda, por
su sencillez, flexibilidad y productividad: Angular.

3. Preparación del entorno

Uno de los puntos clave a la hora de implementar tests con Angular es la configuración adecuada del entorno con Jasmine,
Karma y Protractor; que gracias al maravilloso @angular/cli ya tenemos de serie; así que simplemente tenemos que tener
una instancia de NodeJS con npm y ejecutar:

$> npm install -g @angular/cli

Y creamos un proyecto, en el taller creamos el siguiente:

$> ng new nglabs

Ahora abrimos el proyecto con un editor de textos, a mí el que más me gusta es Visual Studio Code porque tiene una integración
perfecta con TypeScript y gracias a estos plugins favorece nuestra productividad: (sé de más de uno que en el taller empezó
con otro y se pasó rápido a VSCode)

  • Auto Import: nos quita de lo más tedioso de trabajar con TypeScript que es hacer los imports necesarios.
  • Angular Language Service: nos permite el autocompletado en los ficheros en los templates de los componentes y marca
    error cuando interpolamos una variable que no está definida como atributo.
  • TSLint: en el propio editor nos marca los errores de estilo y como se integra con codelyzer nos aplica las reglas
    de estilo definidas por el equipo de Angular.
  • Sort TypeScript Imports: nos da un atajo de teclado (o al salvar el fichero) para organizar los imports que utilicemos.
  • TypeScript Hero: nos facilita un atajo de teclado para eliminar todos los imports que no se estén utilizando en
    el fichero.
  • vscode-icons: muestra un icono distinto en función de la naturaleza del fichero, ayuda a identificar más rápidamente
    los ficheros.
  • bma-coverage: esta es una extensión especifica de testing que se integra con el fichero lcov generado al ejecutar
    los tests con el flag –code-coverage para marcar en el código si esa línea tiene cobertura o no.

Después de importar estas extensiones algunas de ellas llevan una configuración adicional dentro del fichero Preferences
–> Settings que tiene que quedar de esta forma:

{
    "window.zoomLevel": 3,
    "workbench.iconTheme": "vscode-icons",
    "typescript.extension.sortImports.sortOnSave": true,
    "tslint.autoFixOnSave": true,
    "files.autoSave": "off",
    "bma-coverage":{
        "lcovs":[
            "./coverage/lcov.info"
        ]
    }
}

A destacar la propiedad «tslint.autoFixOnSave» que aplica todas las normas del fichero tslint automáticamente al guardar
el fichero y la propiedad lcovs del plugin de bma-coverage que permite decirle donde está el fichero .lcov para mostrar
gráficamente las líneas de código que están cubiertas (punto verde) y las que no (punto rojo).

Probamos los tests que vienen de serie cuando creamos el proyecto con angular-cli con el comando:

$> npm run test -- --code-coverage

Nota que no he usado directamente el comando ng de angular-cli sino con npm ejecutando la tarea que viene definida en la
sección «scripts» del package.json; de esta forma nos aseguramos de estar usando la versión de angular-cli local y no la
que tengamos instalada de forma global para generarlos. Además incluyo la opción –code-coverage con lo que verás que genera
la carpeta coverage con los informes en HTML y el fichero lcov.info.

Como ves todos los tests están en verde así que tenemos un buen punto de partida.

4. Vamos al lío

Vamos a desarrollar una aplicación que recupere los primeros usuarios de GitHub atacando al API (https://api.github.com/users) y por cada uno muestre por pantalla los campos: login, avatar, url y
admin. Para ello lo primero que vamos a hacer es crear el componente que se encargará de mostrarlos por pantalla, gracias
al angular-cli esto es tan sencillo como ejecutar:

$> npm run ng -- generate component list-users

Esto nos va a crear una carpeta con los ficheros del componente, entre ellos un .spec que como la mayoría no sabe lo que
es, tiende a borrarse de forma inmediata, pero para eso está este tutorial. 😉

Ahora pensamos en la solución y, por favor, que a nadie se le ocurra utilizar el servicio Http directamente en el componente.
Personalmente, este tipo de componentes los estructuro en tres capas: un servicio de proxy que solo tiene como misión conectar
con el API y devolver la respuesta, un servicio de adaptación de la respuesta que viene del servidor al modelo de mi aplicación
y el componente que se encarga de visualizar esta información por pantalla.

Por tanto creamos el servicio de proxy que inicialmente, como no va a ser utilizado por nadie más, lo vamos a incluir dentro
de la carpeta list-users. Para ello ejecutamos:

$> npm run ng -- generate service list-users/list-users-proxy

Y lo implementamos de esta forma:

import { environment } from '../../environments/environment';
import { Http, Response } from '@angular/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';


@Injectable()
export class ListUsersProxyService {

  constructor(private http: Http) { }

  getUsers(): Observable {
    return this.http.get(`https://api.github.com/users`);
  }

}

Fíjate que el método devuelve un Observable con la Response de Angular y no hace ni debe hacer más lógica que ésta. Ahora
lo único que queremos verificar es que la llamada física se está haciendo correctamente, por lo tanto, tenemos que implementar
un test de integración que lo verifique y no tiene sentido que hagamos un test unitario de esta parte. Así que en el fichero
.spec asociado a este servicio de proxy vamos a realizar y verificar la llamada de esta forma:

import { async, inject, TestBed } from '@angular/core/testing';
import { HttpModule } from '@angular/http';
import { ListUsersProxyService } from './list-users-proxy.service';


describe('ListUsersProxyServiceIT', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpModule],
      providers: [ListUsersProxyService]
    });
  });

  it('should be created', inject([ListUsersProxyService], (service: ListUsersProxyService) => {
    expect(service).toBeTruthy();
  }));

  it ('should get users', async(() => {
    const service: ListUsersProxyService = TestBed.get(ListUsersProxyService);
    service.getUsers().subscribe(
      (response) => expect(response.json()).not.toBeNull(),
      (error) => fail(error)
    );
  }));
});

Te habrás dado cuenta de que el 80% de este código ya nos lo había proporcionado Angular y que nuestra labor como desarrolladores
«solo» se limita a configurar la clase TestBed con todas las dependencias necesarias, en este caso, la importación de HttpModule
porque estamos usando el servicio Http y subscribirnos a la llamada para verificar el resultado. En este tipo de tests
no hay que ser muy específico en los expects ya que los datos de la llamada pueden variar con frecuencia.

Podemos ejecutar el comando de test para verificar que efectivamente el test pasa; pero cuidado porque si olvidas el «async»
que envuelve la función puedes estar incurriendo en un falso positivo dado que la parte asíncrona del test no se estará
ejecutando. Para evitar esto te aconsejo que pongas un console.log inicialmente y veas que realmente se muestra en la consola.

Listo nuestro primer test de integración y no ha dolido mucho a que no. 😉

Tienes que tener en cuenta que una de las cosas que más complica este tipo de tests es la asincronía así que el truco está
en eliminar esta asincronía en el resto de tests para lo cual vamos a crear un fake del servicio de proxy. Para crear el
fake tenemos que tener en cuenta que cumpla con la misma signatura de la función que estamos utilizando, esto es, que devuelva
una Observable de tipo Response, pero lo que no hacemos es inyectar el servicio Http sino que creamos un Observable síncrono
con los datos de la respuesta real, la cual establecemos como constante en un fichero llamado «list-users.fake.spec.ts»
(dejamos la extensión .spec para que no se incluya en el código de producción)

export const LIST_USERS_FAKE = [
  {
    'login': 'mojombo',
    'id': 1,
    'avatar_url': 'https://avatars3.githubusercontent.com/u/1?v=3',
    'gravatar_id': '',
    'url': 'https://api.github.com/users/mojombo',
    'html_url': 'https://github.com/mojombo',
    'followers_url': 'https://api.github.com/users/mojombo/followers',
    'following_url': 'https://api.github.com/users/mojombo/following{/other_user}',
    'gists_url': 'https://api.github.com/users/mojombo/gists{/gist_id}',
    'starred_url': 'https://api.github.com/users/mojombo/starred{/owner}{/repo}',
    'subscriptions_url': 'https://api.github.com/users/mojombo/subscriptions',
    'organizations_url': 'https://api.github.com/users/mojombo/orgs',
    'repos_url': 'https://api.github.com/users/mojombo/repos',
    'events_url': 'https://api.github.com/users/mojombo/events{/privacy}',
    'received_events_url': 'https://api.github.com/users/mojombo/received_events',
    'type': 'User',
    'site_admin': false
  },
  {
    'login': 'defunkt',
    'id': 2,
    'avatar_url': 'https://avatars3.githubusercontent.com/u/2?v=3',
    'gravatar_id': '',
    'url': 'https://api.github.com/users/defunkt',
    'html_url': 'https://github.com/defunkt',
    'followers_url': 'https://api.github.com/users/defunkt/followers',
    'following_url': 'https://api.github.com/users/defunkt/following{/other_user}',
    'gists_url': 'https://api.github.com/users/defunkt/gists{/gist_id}',
    'starred_url': 'https://api.github.com/users/defunkt/starred{/owner}{/repo}',
    'subscriptions_url': 'https://api.github.com/users/defunkt/subscriptions',
    'organizations_url': 'https://api.github.com/users/defunkt/orgs',
    'repos_url': 'https://api.github.com/users/defunkt/repos',
    'events_url': 'https://api.github.com/users/defunkt/events{/privacy}',
    'received_events_url': 'https://api.github.com/users/defunkt/received_events',
    'type': 'User',
    'site_admin': true
  }
]

Y ahora lo usamos en el fake «list-users-proxy.service.fake.spec.ts» con el siguiente contenido:

import 'rxjs/add/observable/of';
import { LIST_USERS_FAKE } from './list-users.fake.spec';
import { Response, ResponseOptions } from '@angular/http';
import { Observable } from 'rxjs/Observable';


export class ListUsersProxyServiceFake {
  
  getUsers(): Observable {
    const responseOptions: ResponseOptions = new ResponseOptions({
      body: LIST_USERS_FAKE
    });
    const response: Response = new Response(responseOptions);
    return Observable.of(response);
  }

}

De este modo cuando invoquemos al método usando esta implementación no se llamará al servicio real pero la respuesta será
la misma a todos los efectos.

Es el momento de crear nuestro modelo que como he comentado anteriormente, tiene 4 campos. En este caso nos podemos plantear
si construir el modelo con una interfaz o con una clase. La diferencia reside en que con la interfaz no añades más código
a la aplicación, es simplemente para que el IDE te pueda autocompletar este tipo de datos; mientras que con la clase sí
estás añadiendo código real y no es tan flexible a la hora de inicializar los datos a través del constructor.

A mí últimamente me gusta más hacerlo con interfaces así que la podemos crear con el siguiente comando:

$> npm run ng -- generate interface list-users/user

Con el siguiente contenido:

export interface User {
    login: string;
    avatar: string;
    url: string;
    admin: boolean;
}

El siguiente paso es crear el servicio que adapta los datos de la respuesta al modelo. Para ello ejecutamos:

$> npm run ng -- generate service list-users/list-users

Este servicio va a inyectar el proxy y gracias a la programación reactiva con la función .map() que ofrece la librería rxjs podemos fácilmente
hacer el mapeo entre los campos de la respuesta y el modelo de nuestro negocio que, como es nuestro caso, no tienen por
qué coincidir y nos permite desacoplarnos de la respuesta y dar un sentido semántico al desarrollo que facilita la legibilidad
y el mantenimiento de la aplicación. El contenido de este servicio es el siguiente:

import 'rxjs/add/operator/map';
import { ListUsersProxyService } from './list-users-proxy.service';
import { User } from './user';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';


@Injectable()
export class ListUsersService {

  constructor(private proxy: ListUsersProxyService) { }

  getUsers(): Observable {
    return this.proxy.getUsers().map(
      (response) => {
        let listUsers: User[] = [];
        const data = response.json();
        data.forEach(d => {
          const user: User = {
            login: d.login,
            avatar: d.avatar_url,
            url: d.url,
            admin: d.site_admin
          };
          //listUsers.push(user);
          listUsers = [...listUsers, user];
        });
        return listUsers;
      }
    );
  }

}

Nota: Fíjate que donde antes lo normal sería utilizar el método push para añadir el elemento al array; ahora hacemos uso de spread parameter (…) consiguiendo que el resultado sea inmutable. Esto es muy importante a la hora de poder simplificar el change detection y ganar en rendimiento.

Ahora vamos a configurar e implementar el test asociado que como vamos a utilizar el fake será un test unitario no haciendo
falta la implementación de un test de integración. Este es el contenido del test:

import { ListUsersProxyService } from './list-users-proxy.service';
import { ListUsersProxyServiceFake } from './list-users-proxy.service.fake.spec';
import { ListUsersService } from './list-users.service';
import { inject, TestBed } from '@angular/core/testing';


describe('ListUsersService', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        ListUsersService,
        {provide: ListUsersProxyService, useClass: ListUsersProxyServiceFake}
      ]
    });
  });

  it('should be created', inject([ListUsersService], (service: ListUsersService) => {
    expect(service).toBeTruthy();
  }));

  it('should get users', () => {
    const service: ListUsersService = TestBed.get(ListUsersService);
    service.getUsers().subscribe(
      (users) => {
        expect(users[0].login).toEqual('mojombo');
        expect(users[0].avatar).toBeDefined();
      }
    );
  });
});

Fíjate que la clave está en la definición del provider donde establecemos que la implementación la proporcione el fake creado,
de esta forma no necesitamos la función async y podemos ser más específicos en los expects dado que esta respuesta sí la
estamos controlando, y solo queremos verificar que el mapeo de campos se está haciendo de forma adecuada. Ejecutamos el
comando de test y vemos que todos los tests van pasando y que tenemos un buen grado de cobertura.

Teniendo ya los servicios implementados y probados, es el momento de implementar nuestro componente. El cual dentro del método
ngOnInit va a establecer el valor del atributo «users» que será un array de tipo User. No olvidéis establecer la suscripción
para poder desubscribir y así evitar posibles «memory leaks».

import { ListUsersService } from './list-users.service';
import { User } from './user';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs/Subscription';

@Component({
  selector: 'app-list-users',
  templateUrl: './list-users.component.html',
  styleUrls: ['./list-users.component.css']
})
export class ListUsersComponent implements OnInit, OnDestroy {

  users: User[];

  subs: Subscription;

  constructor(private service: ListUsersService) { }

  ngOnInit() {
    this.subs = this.service.getUsers().subscribe(
      users => this.users = users
    );
  }

  ngOnDestroy() {
    this.subs.unsubscribe();
  }

}

Y en el template podemos establecer el siguiente contenido atendiendo a poner los ids adecuados que faciliten los tests de
aceptación. Un posible contenido (sin mucho estilo, aquí es donde digo que entrarían los diseñadores con sus componentes
de Polymer supercurrados donde el desarrollador solo tiene que pasarle una estructura de datos definida para que los datos
se pinten de forma corporativa y mucho más bonita) podría ser este:

{{user.login}}

{{user.url}}

{{user.admin | admin}}

avatar

Ahora implementamos el test asociado donde es muy importante que lo limitemos a verificar que los atributos del componente
se establecen adecuadamente y no empecemos a liarnos a verificar elementos del DOM que van a hacer que nuestro tests sea
mucho más frágil. El contenido del test unitario sería este:

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ListUsersComponent } from './list-users.component';
import { ListUsersProxyService } from './list-users-proxy.service';
import { ListUsersProxyServiceFake } from './list-users-proxy.service.fake.spec';
import { ListUsersService } from './list-users.service';
import { NO_ERRORS_SCHEMA } from '@angular/core';


describe('ListUsersComponent', () => {
  let component: ListUsersComponent;
  let fixture: ComponentFixture;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ ListUsersComponent ],
      providers: [
        ListUsersService,
        {provide: ListUsersProxyService, useClass: ListUsersProxyServiceFake}
      ],
      schemas: [NO_ERRORS_SCHEMA]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(ListUsersComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  afterEach(() => {
    component.ngOnDestroy();
  });

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

  it('should set users', () => {
    component.ngOnInit();
    expect(component.users[0].login).toEqual('mojombo');
  });

});

La propia implementación por defecto ya nos ofrece las instancias de fixture (para comprobar el DOM) y component que no es
más que la instancia del componente que nos permite llamar a los métodos y verificar los atributos.

¡Pues ya está! Nuestra aplicación implementada y probada. Ahora cualquier cambio no nos dará pánico porque habrá un test
que nos dirá si estamos rompiendo funcionalidad y estaremos mucho más confiados a la hora de poner nuestros desarrollos
en producción. Recuerda más vale 10 subidas pequeñas al día controladas que una cada 3 meses con un montón de funcionalidad
que no da tiempo a verificar en el momento de pasar a producción.

Ahora podemos configurar apropiadamente nuestro fichero «app.module.ts»,
indicando que el componente principal es el que acabamos de crear, con el siguiente contenido:

import { AppComponent } from './app.component';
import { BrowserModule } from '@angular/platform-browser';
import { HttpModule } from '@angular/http';
import { ListUsersComponent } from './list-users/list-users.component';
import { ListUsersProxyService } from './list-users/list-users-proxy.service';
import { ListUsersService } from './list-users/list-users.service';
import { NgModule } from '@angular/core';


@NgModule({
  declarations: [
    AppComponent,
    ListUsersComponent
  ],
  imports: [
    BrowserModule,
    HttpModule
  ],
  providers: [ListUsersService, ListUsersProxyService],
  bootstrap: [ListUsersComponent]
})
export class AppModule { }

Por lo que ya podemos arrancar nuestra aplicación con el comando:

$> npm run start

Y verificar que la funcionalidad es correcta. Este es un punto fundamental en los tests de aceptación que necesitan que la
aplicación esté desarrollada y corriendo.

Angular almacena los tests de aceptación en la carpeta e2e y maneja el patrón Page Object donde tenemos un fichero .po que
almacena las funciones de acceso al DOM y otros .spec que implementan los tests haciendo uso de los .po.

De este modo lo primero es crear el fichero list-users.po.ts dentro de la carpeta e2e con el siguiente contenido, donde a
través de los elementos de protractor nos quedamos con la instancia del DOM del primer usuario a través del id que le hemos
puesto.

import { browser, by, element } from 'protractor';

export class ListUsersPage {
  navigateTo() {
    return browser.get('/');
  }

  getFirstUser() {
    return element(by.id('user-0'));
  }

}

Ahora creamos el fichero «list-users.spec.ts» donde hacemos el flujo de cargar la aplicación y verificar que el primer usuario
es ‘mojombo’

import { browser } from 'protractor';
import { ListUsersPage } from '../pos/list-users.po';

describe('nglabs List Users', () => {
  let page: ListUsersPage;

  beforeEach(() => {
    page = new ListUsersPage();
  });

  it('should check first user is mojombo', () => {
    page.navigateTo();
    page.getFirstUser().getText().then(
      text => {
        expect(text).toContain('mojombo');
      }
    );
    expect(page.getFirstUser().getText()).toContain('mojombo');
  });

  afterEach(() => {
    browser.driver.sleep(3000); //Esto es solo para que nuestro ojo humano pueda ver el resultado en el navegador
  });

});

Ahora podemos ejecutar estos tests con el comando:

$> npm run e2e

Obviamente este tipo de tests son los más frágiles pero sí que nos valen para registrar los flujos más críticos de nuestra
aplicación y lanzarlos como «smoke tests» en cualquier entorno para verificar que una subida a producción se ha hecho de
forma satisfactoria, por ejemplo. Esto es mucho más rápido y efectivo que 10 equipos quedando a las 4.00 am para subir
a producción y de 4.05 am a varias horas después están verificando manualmente que no han roto nada (esto lo he vivido
en cliente); cuando con este tipo de tests en cuestión de minutos y de forma automática está verificado y si fallan se
puede configurar para hacer un rollback a la versión anterior.

Nota: es posible que tengáis que adaptar los ficheros de tests que vienen con la configuración inicial, simplemente borrad todos aquellos casos de tests que ya no tengan validez.

5. Conclusiones

Como ves no es tan complicado hacer las cosas bien y vivir mucho más tranquilos dejando el temor de pasar a producción y
ayudando a que el mantenimiento sea mucho menos costoso. Pudiendo hacer realidad las fantasías de cualquier persona de
negocio de ver su idea en producción, realmente, lo antes posible.

Si te quedan dudas de cómo hacer esto o quieres que te ayudemos, contáctanos y hablamos.

Cualquier duda o sugerencia en la zona de comentarios.

Saludos.

2 COMENTARIOS

  1. Un artículo excelente, claro y conciso sobre un tema que muchas veces se deja de lado.

    Tengo una duda sobre el test end-to-end del final.
    ¿ Por qué se hace 2 veces la comprobación del texto del elemento primer usuario ?

    Una directamente con el método definido en el fichero page-object, sobre el que se realiza el assert directamente:
    expect(page.getFirstUser().getText()).toContain(‘mojombo’);

    Y la otra anterior de una forma como asíncrona (lo digo por el «then») usando una función callback.

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad