"El testing te lleva al fallo, y el fallo te lleva al entendimiento."

-- 鉁嶏笍 Burt Rutan

La comunicaci贸n as铆ncrona presenta uno de los retos m谩s comunes al desarrollar, y por tanto probar, aplicaciones web. Hemos avanzado mucho desde los viejos callbacks hasta las modernos async- await. Pero hay algo inherentemente complejo a la programaci贸n as铆ncrona. Vamos a ver c贸mo afrontarla desde el punto de vista de las pruebas.

Programaci贸n as铆ncrona

He modificado el ejemplo bancario y ahora disponemos de un servicio para guardar y recuperar transacciones en un API remota. Puedes ver el c贸digo en el repositorio.

Tenemos una clase y un m贸dulo con funciones de ayuda. Veamos primero la clase BankClient

import { getAllTransactions, postTransaction } from './bank-service';

export class BankClient {
  constructor() {}
  async deposit(amount) {
    const depositTransaction = { date: Date(), type: 'deposit', amount };
    const savedTransaction = await postTransaction(depositTransaction);
    return savedTransaction;
  }

  async withdraw(amount) {
    const withdrawTransaction = { date: Date(), type: 'withdraw', amount };
    const savedTransaction = await postTransaction(withdrawTransaction);
    return savedTransaction;
  }

  async getPosition() {
    return await getAllTransactions();
  }
}

Depende del m贸dulo bank.service que exporta funciones as铆ncronas, las cuales preparan la petici贸n, esperan por su ejecuci贸n y retornan un resultado adecuado.

const url = 'https://api-base.herokuapp.com/api/pub/transactions/';
export { getAllTransactions, postTransaction };

async function getAllTransactions() {
  const request = createRequest(url, 'GET');
  const res = await fetch(request);
  return await getDataOrEmpty(res, []);
}
async function postTransaction(transaction) {
  const request = createRequest(url, 'POST', transaction);
  const res = await fetch(request);
  return getDataOrEmpty(res, {});
}

function createRequest(url, method = 'GET', payload = null) {
  return new Request(url, {
    method: method,
    body: payload ? JSON.stringify(payload) : null,
    headers: new Headers({
      'Content-Type': 'application/json',
    }),
  });
}
async function getDataOrEmpty(res, empty) {
  let result = empty;
  if (res.status >= 200 && res.status < 400) {
    const data = await res.text();
    result = data ? JSON.parse(data) : empty;
  }
  return result;
}

Estrategias de prueba: integraci贸n vs unitaria

Antes de nada debemos recordar lo aprendido en el tema de Pruebas con esp铆as y dobles. Si ejercitamos el c贸digo de BankClient a alto nivel, estaremos haciendo una integraci贸n que necesita que sus dependencias funcionen. Y merece la pena pensar en sus dependencias.

  • BankClient

    • bank-service
    • fetch

      • Remote API

Con el problema browser-node hemos topado. Una de las librer铆as m谩s usadas en JavaScript es la est谩ndar fetch que no tiene equivalente en Node. Y las pruebas Jest son en realidad aplicaciones Node que ejecutan nuestro c贸digo. As铆 que mejor buscar un sustituto. Afortunadamente es f谩cil encontrarlo, yo uso y te recomiendo jest-fetch-mock.

Como su nombre indica,jest-fetch-mock, nos crea un doble de fetch para poder realizar las pruebas con Jest. Y por supuesto con distintas modalidades. Vayamos de menos a m谩s.

Para configurarlo simplemente agrega esta l铆nea antes de tus pruebas o en un fichero de setUp require('jest-fetch-mock').enableMocks();

Pruebas de integraci贸n as铆ncronas

Con las herramientas adecuados y los conceptos b谩sicos sobre asincronismo ya podemos probar nuestro sistema.

Sin mocks

Usaremos un mock sin mocks. 馃ゴ Me explico; al no poder usar fetch desde Node, usaremos un doble que s铆 funciona y que hace exactamente lo mismo. Es un simple polyfill pero con tecnolog铆a mock. Esta es la aparentemente contradictoria instrucci贸n que lo consigue: fetchMock.dontMock();

Alto nivel

Y ahora la prometida prueba de integraci贸n sencilla. Vamos a ejercitar ciegamente BankClient y todas sus dependencias hasta el servidor del API. Como digo, es lo m谩s sencillo. Y a煤n as铆 al estar en en entorno as铆ncrono tenemos que hacer ciertos cambios.

Lo m谩s evidente es que las funciones de prueba son ahora s铆ncronas. Y, por supuesto, debemos esperar por las respuestas de nuestras dependencias.

import { BankClient } from '../bank-client';
let sut;
describe('GIVEN: a BankClient system', () => {
  beforeAll(() => {
    fetchMock.dontMock();
    sut = new BankClient();
  });
  test('WHEN: save a deposit THEN it posts the transaction', async () => {
    const input = 10;
    const actual = await sut.deposit(input);
    const expected = 10;
    expect(actual.amount).toEqual(expected);
    expect(actual._id).toBeDefined();
  });
});

Con async await es pan comido y no hay nada inherentemente malo en esta prueba de integraci贸n. Especialmente si la prueba pasa; pues indica un grado alto de confianza en el sistema. Pero hay que ser conscientes de que si esta prueba no pasa, entonces sabremos poco acerca de d贸nde est谩 el problema.

Bajo nivel

驴Y si hago las prueba un piso m谩s abajo? Est谩 claro que cuanto mas bajo sea el nivel de tu c贸digo menos dependencias tendr谩. Y por tanto menos culpables potenciales. El c贸digo sigue muy parecido al anterior, s贸lo que ahora ejercitamos directamente las funciones as铆ncronas del m贸dulo bank-service.

import { getAllTransactions, postTransaction } from '../bank-service';
describe('GIVEN: a connected Bank service', () => {
  beforeAll(() => {
    fetchMock.dontMock();
  });
  test('WHEN: i post a transaction THEN it returns the _id', async () => {
    const input = { date: new Date(), type: 'Deposit', amount: 10 };
    const actual = await postTransaction(input);
    expect(actual._id).toBeDefined();
  });
  test('WHEN: i ask for all transactions THEN it returns an array', async () => {
    const actual = await getAllTransactions();
    const expected = 1;
    expect(actual.length).toBeGreaterThanOrEqual(expected);
  });
});

Lo dicho, esto sigue siendo un prueba de integraci贸n porque dependemos del API para su ejecuci贸n. Para focalizar el problema hay que volver a las pruebas unitarias, pero en modo as铆ncrono y con mocks de verdad.

Tests Unitarios as铆ncronos

Vamos a repetir las pruebas de arriba a abajo pero ahora sin depender de c贸digo externo. Es decir vamos al Unit Test as铆ncrono.

Con servicios mock

Lo primero es probar el BankClient pero sin depender del m贸dulo bank-service. Aqu铆 parece de nuevo la instrucci贸n Jest para generar dobles jest.mock('../bank-service'); Y desaparece la extra帽a fetchMock.dontMock();

Claro que un buen doble tiene que simular el comportamiento de su gemelo. Recordamos que con Jest podemos hacerlo con funcion.mockReturnValue(fakeResult). S贸lo que ahora debemos adaptarnos al ambiente as铆ncrono y retornar promesas.

import { BankClient } from '../bank-client';

import { postTransaction } from '../bank-service';

jest.mock('../bank-service');

let sut;

describe('GIVEN: a BankClient class', () => {
  beforeAll(() => {
    const fake = { _id: 42, amount: 10 };
    postTransaction.mockReturnValue(Promise.resolve(fake));
    sut = new BankClient();
  });
  test('WHEN: save a deposit THEN it posts the transaction', async () => {
    const input = 10;
    const actual = await sut.deposit(input);
    console.log({ actual });
    const expected = 10;
    expect(actual.amount).toEqual(expected);
    expect(actual._id).toBeDefined();
  });
});

Ya est谩; si ahora algo va mal, el culpable es BankClient. Ese es el objetivo de la prueba unitaria: aislar la fuente de problemas.

Con llamadas mock

En el punto anterior, no hemos hecho uso de fetchMock. El propio sistema de doblaje de Jest nos aisl贸 de las llamadas fetch a bajo nivel que realiza el bank-service. Pero, 驴y si queremos probar esas funciones?

El objetivo ahora es aislarnos del API, as铆 que volvemos a usar la librer铆a jest-fetch-mock pero ahora simulando las llamadas http. Lo hacemos con una instrucci贸n mucho m谩s coherente fetchMock.doMock();

import { getAllTransactions } from '../bank-service';

describe('GIVEN: a disconnected Bank service', () => {
  beforeAll(() => {
    fetchMock.doMock();
  });
  test('WHEN: i ask for all transactions THEN it returns an empty array', async () => {
    const actual = await getAllTransactions();
    const expected = [];
    expect(actual).toEqual(expected);
  });
});

Eso s铆, no esper茅is grandes resultados. De hecho lo 煤nico esperable es que cada llamada real a fetch retorne un valor fake indefinido. Estamos aislados del API, todo funciona, pero con respuestas poco pr谩cticas y mon贸tonas.

Con respuestas fake

Afortunadamente es muy f谩cil preparar el sistema para que devuelva la respuesta que nos parezca m谩s adecuada. Incluso podemos simular errores, c贸digos de respuesta, cabeceras... Es decir, simular lo que har铆a un API de verdad.

import { getAllTransactions } from '../bank-service';

describe('GIVEN: a mocked Bank service', () => {
  beforeAll(() => {
    fetchMock.doMock();
    fetch.mockResponseOnce(
      JSON.stringify([
        { id: 1, amount: 1 },
        { id: 2, amount: 20 }
      ])
    );
  });
  test('WHEN: i ask for all transactions THEN it returns the expected array', async () => {
    const actual = await getAllTransactions();
    const expected = [
      { id: 1, amount: 1 },
      { id: 2, amount: 20 }
    ];
    expect(actual).toEqual(expected);
  });
});

Ahora cualquier fallo, cualquier prueba que no pase, apuntar谩 directamente al 煤nico involucrado. El 煤nico culpable posible es el m贸dulo bank-service y sus funciones.

Prueba superada; podemos probar cualquier clase, m贸dulo o funci贸n, s铆ncrona o as铆ncrona sin depender de nadie.