Hace unos cuantos posts hablé sobre cómo configurar tu entorno de desarrollo orientado a TDD, usando Karma, Browserify y BabelJS. Si bien es un primer paso, estoy seguro de que todos sabemos que tener un entorno de testeo y tener buenos tests son dos cosas muy distintas.

Personalmente, una de las cosas que más me complica la vida a la hora de tener unos tests suficientemente aislados son las dependencias. Vamos a ver, pues, cómo aislar tu test de las dependencias externas; es decir, crear tests unitarios. Lo haremos usando Builder Object, un patrón de diseño para construir objetos bajo demanda.

El problema

Imagínate que tienes un objeto Car que depende de una instancia del objeto Engine para poder arrancar. Supongamos, además, que el método start del objeto Engine es asíncrono y que funciona el 50% de las veces, mientras que el otro 50% de las veces devolverá un fallo, todo de manera aleatoria. A poco que pensemos un poco en esto y tratemos de codificarlo, llegaríamos a una solución como la que sigue:

Para nuestra clase Engine:

// src/engine.js

import * as Promise from 'bluebird'

export default class Engine {  
  constructor(){
  }

  // La mitad de las veces arranca y la mitad de las veces falla
  start(){
    return ( Math.random() * (10 - 0) + 0 ) > 5 ? Promise.resolve() : Promise.reject()
  }
}

En este tutorial, siempre que necesite usar promesas, usaré BlueBird.

Y para nuestro objeto Car:

// src/car.js

export default class Car {  
  constructor( engine ){
    this._engine = engine;
    this._started = false;
  }

  start(){
    return this._engine.start()
    .then( () => this._started = true )
    .catch( () => this._started = false )
  }

  get isStarted(){
    return this._started;
  }

}

Veamos ahora un ejemplo de un cliente del objeto Car, intentando usarlo. El código sería como sigue:

// src/index.js

import Engine from './engine'  
import Car from './car'

const TRIALS = 10  
let engine = new Engine();  
let car = new Car( engine );

( async function startTimes( times ){
  while(--times){
    await car.start()
    console.log( car.isStarted ? '¡El coche ha arrancado!' : 'Algo malo le pasa al motor' )
  }
}( TRIALS ) );

Si ejecutamos el código que acabamos de escribir deberíamos obtener lo siguiente:

async function es6

Un apunte sobre el código del cliente: startTimes es un tipo especial de funciones que se ha definido para ES7 llamadas async functions. Puedes pensar en ellas como una especie de generadores y promesas.

Hasta aquí lo fácil, pero si quisiéramos testear este código, tendríamos que lidiar con un función asíncrona, que además devuelve un resultado aleatorio. Esto hace que sea imposible de testear si no se mockea de alguna manera.

La solución

Con Builder Objects podemos generar objetos bajo demanda que inyectaremos como dependencias. La ventaja es que podemos controlar totalmente el comportamiento de estos objetos. Volviendo al ejemplo, vamos a generar instancias de Engine de las que sabremos de antemano qué devolverá el método 'start'.

En este caso nuestro Builder Object para Engine sería algo así:

// test/fixtures/engineBuilder.js

import * as sinon from 'sinon'  
import * as Promise from 'bluebird'

const factory = function(){  
  let Factory = function(){};
  Factory.prototype.start = sinon.stub()
  return Factory;
}

export default class EngineBuilder {

  static SUCCESS = "success"
  static FAIL = "fail"

  constructor(){
    this._Factory = factory();
  }

  startWith( status, ...args ){
    let promise = status === EngineBuilder.SUCCESS ? Promise.resolve : Promise.reject;
    this._Factory.prototype.start.returns( promise.apply( Promise, args ) );
    return this;
  }

  getInstance(){
    return new this._Factory();
  }
}

Soy consciente de que EngineBuilder.SUCCESS y EngineBuilder.FAIL, no deberían ser Strings, sino que deberían ser Symbols. Es decir, algo así: static SUCCESS = Symbol(). Pero he sido incapaz de cargar el Polyfill de Babel en el runner de Karma. Agradecería cualquier ayuda al respecto :)

En resumen, Builder Object crea un nuevo objeto Factory en cada instancia, que mimetiza la API de un objeto Engine. Además, da métodos para modificar el comportamiento, como startWith, que nos permite controlar los métodos del objeto Factory (que simula ser Engine). Por último, estos métodos devuelven this para asegurarnos que podemos encadenar las llamadas y tener un código más fluido a la hora de usarlo.

Un ejemplo de uso de Builder Object es:

let engine = new EngineBuilder().startWith( EngineBuilder.FAIL ).getInstance();  

Ahora engine es una instancia de un objeto que mimetiza la API de Engine, pero su método start SIEMPRE devolverá una promesa rechazada. Esto lo hace idóneo para los tests.

En unos tests reales, así es como usaríamos Builder Object:

// test/carSpec.js

import {expect} from 'chai'  
import Car from '../src/car'  
import EngineBuilder from './fixtures/engineBuilder'


describe( 'Car', () =>{  
  it( 'should exist', () => {
    expect( Car ).to.not.be.undefined
  } );

  describe( 'An instance', () =>{
    it( 'When the engine fails the car has not started', ( done ) => {
      let engine = new EngineBuilder().startWith( EngineBuilder.FAIL ).getInstance();
      let car = new Car( engine );
      car.start()
      .then( () => {
        expect( car.isStarted ).to.be.false
        done()
      } );
    } );

    it( 'When the engine does not fail the car has started', ( done ) => {
      let engine = new EngineBuilder().startWith( EngineBuilder.SUCCESS ).getInstance();
      let car = new Car( engine );
      car.start()
      .then( () => {
        expect( car.isStarted ).to.be.true
        done()
      } );
    } );

  } );
} );

Si ejecutamos los tests, veríamos que todos pasan como esperamos:

karma tests runner builder objects

El código usado en este post lo puedes encontrar en Github

Si te ha gustado este post, difunde la palabra. Tampoco dudes en dejar comentarios u observaciones. ¡Gracias! :)

Suscríbete a mi lista de correo

* Campos obligatorios