Mejorando testing assertions

05 Jan 2022

Hacer test con jest es una actividad que realizan los desarrolladores para conseguir que las aplicaciones sean mantenibles y a prueba del paso del tiempo. Aprender un framework de testing puede ser una ardua tarea, ya que hay mucho que  explorar. La assertion API (Application Programming Interface) es una de las más importantes ya que es la que más utiliza el desarrollador durante el flujo de TDD (Test Driven Development) independientemente del stack tecnológico en el que trabaje. En este ejercicio nos vamos a centrar en javascript.

 

La razón de ser de assertion API es comparar valores; equals match es la más utilizada (según mi experiencia). Que sea una de las más utilizadas también puede ser porque las otras que ofrece el framework de pruebas no se conocen tanto. Esta falta de conocimiento o experiencia puede dar lugar a errores comunes, como el entorno en el que se ejecuta jest o el async behaviour.

Esta publicación tiene como objetivo cubrir diferentes assertions para evitar usar siempre toEqual y hacer que los test sean más expresivos. Para cada ejemplo, primero trato de representar cómo sería con toEqual y posteriormente muestro otra forma de hacerlo usando una diferente.

Si prefieres acercarte a este contenido en formato video, echa un vistazo al meetup que hicimos para hablar de este tema. Aquí te dejamos las slides que para la presentación en speakerdeck.

Assertions 

Esta sección se centra en assertions que podemos utilizar y las alternativas a los “assertion smells”. Para enfatizarlo lo que hacemos es comparar to.Equal con otra assertion más expresiva para cada escenario planteado. Además de eso, cada sección se clasifica de la siguiente manera:  primitives 🧱, modifiers 🪣, callbacks 📱, timers ⏰ y async 🏹.

PRIMITIVES  🧱

ANY

Any es una generalización para usar cuando el valor del resultado no es necesario:

const isNumber = number => number

expect(typeof isNumber(2)).toEqual('number')

Una alternativa podría ser esta: 

const isNumber = number => number

expect(isNumber(2)).toEqual(expect.any(Number))

ARRAY CONTAINING

Pensando en assert.equal, un enfoque podría ser:

const expectedFruits = ['banana', 'mango', 'watermelon']

expect(expectedFruits[0]).toEqual('banana')

expect(expectedFruits[1]).toEqual('mango')

expect(expectedFruits[0]).toEqual('watermelon')

Y otro enfoque diferente sería utilizar  arrayContaining:

const expectedFruits = ['banana', 'mango', 'watermelon']

const actualFruits = () => ['banana', 'mango', 'watermelon']

expect(expectedFruits).toEqual(expect.arrayContaining(actualFruits))

TO HAVE LENGTH

Para comprobar el tamaño de una matriz es posible utilizar la propiedad length. Hay diferentes formas de lograrlo. Así lo haríamos con assert equals:

const myList = [1, 2, 3]

expect(myList.length).toEqual(3)

Pero jest ofrece un comparador específicamente para eso. El mismo ejemplo  usando toHaveLength podría ser:

const myList = [1, 2, 3]

expect(myList).toHaveLength(3)

TO BE GREATER THAN

Si en este ejemplo aplicamos equals sería así:

const expected = 10

const actual = 3

expect(expected > actual).toEqual(true)

El inconveniente de este ejemplo es que se necesita algo más de esfuerzo para comprender el código, ya que es necesario entender qué está sucediendo con las variables expected y  actual. Para compensar esto, jest ofrece una assertion más legible (y con un mensaje más amigable cuando falla).

const expected = 10

const actual = 3

expect(actual).toBeGreaterThan(expected)

Utilizar toBeGreaterThan aligera la carga adicional que se necesita para leer las dos variables.


MODIFIERS 🪣

NOT

El not modifier es útil cuando se trata de afirmar la negación de una oración determinada. Veamos este ejemplo:

const isOff = false

expect(!isOff).toBe(true) // <--- this sometimes is tricky to spot, same extra load thinking about the negation of the variable off

Otra forma de lograr el mismo resultado pero siendo más explícito sería la  siguiente: 

const isOff = false

expect(isOff).not.toBe(true)

El .not operator se puede usar en diferentes assertions dentro de jest. Echa un vistazo a este ejemplo .not.toBeInTheDocument.

CALLBACKS 📱

Callbacks son el corazón de javascript y, cuando se hace testing con ellas se aplica un estilo asíncrono, ya que callback podría (o no) ser llamada en diferentes momentos flujo de ejecución.

TO HAVE BEEN CALLED

Se puede invocar una callback de diferentes formas, para ello el primer enfoque (y no recomendado) es utilizar el estilo async:

const callAsyncFunc = (callback) => callback();

Entonces:

it('callback has been invoked', done => {

  callAsyncFunc(() => {

    expect(true).toEqual(true) <--- assumes it has been called, but it was not, jest thinks the test is passing

  })

})

Una assertion más legible sería  toHaveBeenCalled, ya que nos resulta más sencillo comprender qué es lo que está afirmando el test. Jest usa este espía para afirmar llamadas en su contra, y comprobar si la función ha sido llamada, independientemente del número de llamadas.

it('callback has been invoked', done => {

  const result = jest.fn()

  callAsyncFunc(result)

  expect(result).toHaveBeenCalled()

})

TO HAVE BEEN CALLED TIMES

Esta es la assertion más básica. Existen otras variantes que son más estrictas. Veamos el siguiente ejemplo: 

it('callback has been invoked', done => {

  const result = jest.fn()

  callAsyncFunc(result)

  callAsyncFunc(result)

  callAsyncFunc(result)

  callAsyncFunc(result)

 

  expect(result).toHaveBeenCalledTimes(4)

})

El código anterior afirma que el espía se llama 4 veces, cualquier número diferente a ese fallará en el test.

TIMERS ⏰

Por lo general, lo relativo a tiempos y todo lo relacionado con fechas es non-deterministic. En un escenario en el que necesita esperar a que suceda algo durante la fase de test, necesitamos simular esta funcionalidad. Por ejemplo, el siguiente código se construye sobre el ejemplo anterior, pero usando fake timers.

describe("timers", () => {

  beforeEach(() => {

    jest.useFakeTimers();

  });

  afterEach(() => {

    jest.useRealTimers();

  });

  test("should handle next scene", async () => {

    const value = callAsyncFunction();

    jest.advanceTimersByTime(2000);

    expect(await value).toBe(true);

  });

});

El uso de fake timers, nos permite adelantarnos en el test sin tener que esperar a que pase el tiempo de forma real.

ASYNC 🏹

Jest proporciona una API más legible para testear funciones asíncronas. Es fácil caer en la trampa de usar assert equals después de que se ha cumplido una promise.

RESOLVES

Testear código asíncrono conlleva desafíos y el enfoque para hacer ese tipo de test también cambia. Una forma de realizar test en este escenario es utilizar la variable que proviene de la devolución de llamada. Veamos el siguiente código:

function callAsyncFunction() {

  return new Promise((resolve) => {

    setTimeout(() => {

      resolve(true);

    }, 1000);

  });

}

Podría ser algo así: 

it('my async test', done => {

  callAsyncFunc().

    then((value) => {

      expect(value).toBe(true)

      done()

    })

})

El código anterior describe cómo comprobar un valor una vez que se resuelve la promise. Jest proporciona una forma más legible de hacer las cosas con resolves:

it('my async test', async () => {

  await expect(callAsyncFunc()).resolves.toEqual(true)

})

Lo mismo se aplica a una promise rechazada, en este caso cambiaríamos resolves por rejects.

it('my async test', async () => {

  await expect(callAsyncFunc()).rejects.toEqual(false)

})

Y ahora ¿ dónde vamos? 


Con suerte, estos ejemplos te darán la oportunidad de explorar jest como una forma de mejorar tus assertions y la legibilidad de tus test. Esos detalles sobre la escritura de código pueden ser la la clave para encontrar la causa de lo que está fallando en un test.

Si no estás familiarizado con Jest o usas otro stack tecnológico, también está bien, tu framework de pruebas favorito te dará el apoyo necesario con  funcionalidades similares.

Os recomiendo escuchar a mis colegas en el podcast "Construyendo una cultura de testing" y a que veáis este workshop (en español) TPP, de lo básico a específico y a genérico para profundizar en el tema,  así como este resumen de la primera conferencia internacional sobre TDD 

Y como siempre:  ¡Feliz testing!