La función act()

La función act()

¿Qué es y para qué se utiliza la función act() cuando estamos construyendo nuestros test?

En el anterior artículo de la serie vimos cómo podíamos testear un custom hook que habíamos desarrollado pero únicamente centrándonos en ver cómo probar el valor que se le asignará a un atributo que definimos dentro del estado del hook. En concreto el código de nuestro custom hook es el que mostramos a continuación:

import { useState } from 'react'

type UseCounterArgs = {
  initialCount?: number
}

export const useCounter = ({ initialCount = 0 }: UseCounterArgs = {}) => {
  const [count, setCount] = useState(initialCount)

  const increment = () => setCount(count + 1)
  const decrement = () => setCount(count - 1)

  return {
    count,
    increment,
    decrement
  }
}

Y los test que habíamos desarrollado hasta este momento son los siguientes:


import { renderHook } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('should render the initial count', () => {
    const { result } = renderHook(useCounter)
    expect(result.current.count).toBe(0)
  })

  it('should accept and render the same initial count', () => {
    const { result } = renderHook(useCount, {
      initialProps: {
        initialCount: 10
      }
    })
    expect(result.current.count).toBe(10)
  })
})

La idea en este artículo es tratar de completar los test que estarán asociados con el hook useCounter y para ello tenemos que ver cómo probar las funciones increment y decrement que forman parte de lo que se está retornando.

Testeando increment

Vamos a comenzar con el test que nos va a permitir probar la función increment y para ello dentro de nuestra suite creamos un nuevo test como sigue:

test('should increment the count', () => {})

Ahora dentro de la función que se le pasa como segundo parámetro (que recordemos que es la que se ejecutará cuando se lance el test) vamos a hacer uso de renderHook tal y come hemos hecho en el resto de test que llevamos desarrollados hasta este momento quedándonos con el atributo result del objeto que retorna la invocación de esta función:

test('should increment the count', () => {
  const { result } = renderHook(useCounter)
})

Ahora que tenemos el objeto result tenemos que recordar que en su atributo current vamos a tener el objeto que ha retornado el hook que estamos probando (es decir, el objeto que retorna useCounter) lo que signifacará que vamos a tener acceso al método increment al cual vamos a poder invocar como si se tratase de cualquier otro método:

test('should increment the count', () => {
  const { result } = renderHook(useCounter)
  result.current.increment()
})

Sabiendo esto ya podemos pasar a hacer la aserción en nuestro test y puesto que sabemos que como no le hemos pasado un valor inicial para el contandor a useCounter este se inicializa con cero y que al haber invocado al método increment el valor del mismo se deberá incrementar en 1 por lo que deberá valer 1 (0 + 1 = 1) así que escribimos la siguiente aserción:

test('should increment the count', () => {
  const { result } = renderHook(useCounter)
  result.current.increment()
  expect(result.current.counter).toBe(1)
})

Nota: recordemos que en el atributo current del objeto result además de los métodos que retorna el hook tendremos los atributo que haya podido retornar como es el caso del atrubuto counter en el ejemplo que estamos desarrollando.

¿Qué sucederá ahora si ejecutamos este test? Pues por la consola nos aparecerá algo como lo que podemos ver a continuación:

Vemos por lo tanto que nuestro test está fallando puesto que esperábamos que el valor de counter pasase a ser 1 pero sin embargo es 0. ¿Qué es lo que está pasando aquí porque todo lo que hemos escrito parece correcto?

Para entenderlo bien lo que tenemos que hacer es un poco de scroll vertical en el resultado del test que ha fallado donde se nos está ofreciendo una explicación más detallada en la forma de un warning:

El mensaje que vemos es an update to TestComponent inside a test was not wrapped in act(...) y un poco más abajo nos explica cómo podemos resolverlo When testing, code that cause React state updates should be wrapped into act(...) ¡Vamos que pese a que nos están diciendo la solución nos podemos quedar tal y como estábamos!

Entonces, ¿qué es lo que nos quieren decir con estos mensajes? Para ello nos vamos a ir a los React Docs y más concretamente a la sección donde se explica la función act. Una captura de lo que ahí se dice la podemos ver en la siguiente imagen:

Aquí vemos que se nos está diciendo que cuando estamos escribiendo test para probar nuestra UI, las tareas como el renderizado, los eventos del usuario o el traerse datos pueden ser considerados como unidades de interacción con dicha interfaz. Así puesto react-dom/test-utils nos va a proporcionar la función act que lo que viene a hacer es asegurarnos que todas estas unidades de procesado se han llevado a cabo y se han aplicado en el DOM antes de que podamos hacer las aserciones en nuestros test.

Nota: con esto lo que se está logrando es que los test que escribamos estén mucho más cerca con lo que los usuarios reales de nuestras aplicaciones experimentan cuando interaccionan con ella.

Pero hay un detalle más en esta explicación que resulta realmente interesante y es que prácticamente nos viene a decir que todos los test que vayamos a ir definiciendo en nuestras aplicaciones los vamos a tener que ir encerrando (_wrapped_) dentro de una función act por lo que el código de nuestros test quedará bastante verboso. Ahora bien, librerías como React Testing Library, cuando nos ofrecen sus funciones para trabajar con nuestros test ya estarán haciendo este envoltorio por nosotros lo que nos va a facilitar enormemente nuestro trabajo.

Importante: lo que nos tiene que quedar claro es que act es una función que nos va a garantizar que todas las actualizaciones de nuestra interfaz van a ser llevadas a cabo antes de que se realicen cualquiera de las aserciones que podamos recoger en el código de nuestros test.

Con esto en mente ya podemos entender mejor el error que nos está apareciendo en nuestro test ya que nos nos viene a decir que por lo general cuando estamos invocando a una función que actualice un valor del estado este código deberá ir envuelto en una función act (dicho de otra manera, que todas estas actualizaciones del estado deberán ser llevadas a cabo dentro de la función act).

Ahora ya podemos entender qué es lo que está pasando en nuestro código y es que en nuestro test estamos invocando directamente al método increment (que recordemos que es un método en el que dentro del mismo se produce una actualización en el estado con la invocación de setCount que es la que realmente provoca la actualización del estado). De hecho como React Testing Library no puede envolver esta actualación del estado con act nos informa de ello indicándonos que tenemos que ser nosotros los que la llevemos a cabo de manual.

Esta es la razón por la que en el test que se ha ejecutado los valores esperados y recibidos no coinciden porque las actualizaciones del estado no se han llevado a cabo en el momento en el que se ejecutan las aserciones.

Solución

Para resolver nuestro problema lo primero que tenemos que hacer es importar la función act de React Testing Library:

import { act, renderHook } from '@testing-library/react'

El segundo paso que tenemos que ejecutar consisitirá en envolver la llamada a la función que realiza la actualización del estado dentro de la función act pero ¿esto cómo se hace? Pues tenemos que saber que act espera recibir como parámetro una función que será ejecutada cuando se ejecute la función act ¿y qué irá dentro de esta función? Pues todas aquellas llamadas que se encarguen de realizar la actualización del estado en nuestros componentes. En nuestro ejemplo:

test('should increment the count', () => {
  const { result } = renderHook(useCounter)
  act(() => result.current.increment())
  expect(result.current.counter).toBe(1)
})

Ahora si volvemos a ejecutar nuestros test podemos ver como todos ellos pasan correctamente tal y como esperábamos:

Testeando decrement

Ahora que ya sabemos los pasos que tenemos que realizar cuando en nuestros test están involucradas llamadas a funciones que actualizan el estado es sencillo declarar el test que se encargará de probar que la función decrement funciona correctamente:

test('should decrement the count', () => {
  const { result } = renderHook(useCounter)
  act(() => result.current.decrement())
  expect(result.current.counter).toBe(-1)
})

Si ahora guardamos nuestro trabajo y volvemos a ejecutar los test vamos a ver cómo todos ellos pasan correctamente tal y como esperábamos:

Nota: en la mayoría de las situaciones React Testing Library es capaz de hacer uso internamente de act por nosotros sin que tenemos que preocuparnos de nada, pero es en situaciones como la que acabamos de cubrir en este ejemplo donde se produce una actualización del estado dentro de un hook donde deberemos hacer nosotros uso de esta función de forma explícita en el código si queremos que los test pasen.

Código completo

A continuación mostramos el código completo de la suite de test que llevamos desarrollada hasta estos momentos para poderla consultar rápidamente en el caso de que lo necesitásemos:

import { act, renderHook } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('should render the initial count', () => {
    const { result } = renderHook(useCounter)
    expect(result.current.count).toBe(0)
  })

  it('should accept and render the same initial count', () => {
    const { result } = renderHook(useCount, {
      initialProps: {
        initialCount: 10
      }
    })
    expect(result.current.count).toBe(10)
  })

  test('should increment the count', () => {
    const { result } = renderHook(useCounter)
    act(() => result.current.increment())
    expect(result.current.counter).toBe(1)
  })
})