Testing de componentes React: ¿cuándo y por qué necesitamos usar act()?
Tags: testing, react, spanish, webdev
Published: 2023-10-21 11:43:44

¿A quién no le ha pasado? Cuando hacemos test de componentes React, de vez en cuando nos encontramos con este warning:

```
Warning: An update to MyComponent inside a test was not wrapped in act(...).

When testing, code that causes React state updates should be wrapped into act(...):

act(() => {
  /* fire events that update state */
});
/* assert on the output */
```
Aunque el mensaje es bastante claro (una vez que lo entendemos), es habitual no entender ni por qué aparece, ni cómo solucionarlo, dando como resultado que acabemos ignorándolo. 

## Actualizaciones de estado y rerenders

Cuando hacemos una actualización de estado en un componente, React se encarga de volver a renderizarlo para actualizar el DOM si es necesario. Esto ocurre automáticamente como respuesta a los eventos de usuario (clicks, input, etc.) gestionados por React. Ahora bien, los tests son otra historia.

Para demostrarlo, vamos a suponer que tenemos un componente muy sencillo que muestra un botón y un texto que cambia al hacer click sobre ese botón.

``` jsx
function MyComponent({ initialText }) {
  const [text, setText] = useState(initialText)

  return (
    <div>
      <button onClick={() => setText('New text')}>Click me</button>
      <p>{text}</p>
    </div>
  )
}
```

Cuando interactuamos con este componente en el navegador, React se encarga de volver a renderizarlo (volver a ejecutar `render`) cuando necesita actualizar su estado, dando como resultado en este caso una actualización del DOM.

Por el contrario, cuando renderizamos este componente en un test, React no espera que ocurran actualizaciones de estado entre lo que renderizamos inicialmente y las aserciones que hacemos sobre el DOM. 

Para entenderlo mejor, digamos que el trabajo de React en un test termina después de ejecutar el `render`. Y para terminar de endender el nombre de esa función `act()`, vamos a suponer que **de las tres A (_Arrange_, _Act_ y _Assert_)** React solo espera que en un test ocurran la primera y la última:

```jsx
test('shows the text specified in "initialText"', () => {
  // arrange
  render(<MyComponent initialText="any" />)

  // assert
  expect(screen.getByText('any')).toBeInTheDocument()
})
```

Normalmente en un test vamos a necesitar simular ciertos eventos para comprobar que el resultado de una interacción es el esperado. En este caso queremos comprobar que al hacer click sobre el botón, el texto cambia a "New text". 

Los métodos que Testing Library nos proporciona para simular las interacciones de usuario **(`fireEvent` y `userEvent`) utilizan internamente `act()`** y por eso no necesitamos usarlo en la mayoría de los casos. Esto es, en mi opinión, lo que contribuye a que no tengamos claro por qué a veces aparece ese warning, ya que no siempre es evidente que ya lo estamos usando.

Para demostrar esto, en lugar de `fireEvent` o `userEvent` vamos a usar el método `click()` nativo del botón:

```jsx
test('changes initial text after clicking the button', () => {
  // arrange
  render(<MyComponent initialText="any" />)

  // 👉 esto provoca un warning de act()
  screen.getByRole('button').click() 

  // assert
  expect(screen.getByText('New text')).toBeInTheDocument()
})
```

El resultado de ejecutar este test será **un warning de `act()` y un test fallido**. Lo que React nos está diciendo con ese warning viene a ser algo así:

> ¡Ey! Estás haciendo algo que actualiza el estado. **Necesito que me avises para que pueda volver a renderizar el componente**. De lo contrario no te puedo garantizar que estés probando lo que esperas.

Y como hemos podido ver en este ejemplo, nos avisa por una buena razón: el DOM que estamos probando no está actualizado.

## Avisando a React de que necesita volver a renderizar

Ahora que ya hemos entendido por qué aparece el warning, vamos a ver cómo solucionarlo. 

Siempre que hagamos algo en un test que provoque un cambio de estado, tenemos que **envolverlo en `act()`**:

```jsx
test('changes initial text after clicking the button', () => {
  // arrange
  render(<MyComponent initialText="any" />)

  // act (Soy un comentario innecesario pero didáctico 😅)
  act(() => screen.getByRole('button').click())

  // assert
  expect(screen.getByText('New text')).toBeInTheDocument()
})
```
O mucho mejor, utilizar los métodos que Testing Library nos proporciona para simular interacciones de usuario, que ya hacen uso de `act()` _"under the hood"_:

**Ejemplo con `fireEvent`**:

```jsx
test('changes initial text after clicking the button', () => {
  // arrange
  render(<MyComponent initialText="any" />)

  // fireEvent usa act() internamente
  fireEvent.click(screen.getByRole('button'))

  // assert
  expect(screen.getByText('New text')).toBeInTheDocument()
})
```

**Ejemplo con `userEvent` (asíncrono)**:

```jsx
test('changes initial text after clicking the button', async () => {
  // arrange
  render(<MyComponent initialText="any" />)

  // userEvent usa act() internamente
  await userEvent.click(screen.getByRole('button'))

  // assert
  expect(screen.getByText('New text')).toBeInTheDocument()
})
```

## ¿Cuándo es necesario usar act()?

No todas las actualizaciones de estado ocurren inmediatamente después de una interacción de usuario. En ocasiones, **el estado se actualiza como respuesta a un evento asíncrono**, como por ejemplo un `setTimeout`.

Para ejemplificarlo, vamos a modificar un poco nuestro componente para que el texto cambie después de 1 segundo:

```jsx
function MyComponent({ initialText }) {
  const [text, setText] = useState(initialText)
  const handleClick = () => setTimeout(
    () => setText('New text'), 
    1000
  )

  return (
    <div>
      <button onClick={handleClick}>Click me</button>
      <p>{text}</p>
    </div>
  )
}
```

En este caso, aunque usemos los métodos de Testing Library para simular la interacción de usuario, **el test fallará** porque el cambio de estado no ocurre inmediatamente como respuesta a un click, sino después de 1 segundo:

```jsx
test('changes initial text after clicking the button', () => {
  render(<MyComponent initialText="any" />)

  fireEvent.click(screen.getByRole('button'))

  // ❌ FAIL -> necesitamos esperar 1 segundo
  expect(screen.getByText('New text')).toBeInTheDocument()
})
```
Podemos sentir la tentación de solucionarlo usando las queries asíncronas de Testing Library, pero en ese caso no estaremos evitando que pase 1 preciado segundo en nuestro test:

```jsx
test('changes initial text after clicking the button', async () => {
  render(<MyComponent initialText="any" />)

  fireEvent.click(screen.getByRole('button'))

  // Las queries de tipo findBy* son asíncronas
  await screen.findByText('New text')
})
```
La ejecución de este test tarda más de 1 segundo.   

![Captura de la ejecución del test anterior (lento)](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5xm0zgck5tflvlmrz2f5.gif)  

Lo normal y recomendable cuando usamos temporizadores en nuestros componentes, es "mockearlos" para evitar que ese tiempo se consuma realmente en nuestros tests. Para ello, Jest o Vi nos proporcionan el método `useFakeTimers()`:

```jsx
beforeEach(() => {
  vi.useFakeTimers()
})

afterEach(() => {
  vi.clearAllTimers()
})

test('changes initial text after clicking the button', async () => {
  render(<MyComponent initialText="any" />)

  fireEvent.click(screen.getByRole('button'))

  // 👉 avanzamos 1 segundo
  vi.advanceTimersByTime(1000) 

  // No necesitamos usar findBy* porque el cambio de estado ocurre inmediatamente
  expect(screen.getByText('New text')).toBeInTheDocument()
})
```

Sin embargo, este test fallará con el mismo warning de `act()` que hemos visto anteriormente. Dado que la actualización de estado no ocurre como respuesta al `onClick` del botón, sino al callback del `setTimeout`, necesitamos avisar a React de que tiene que volver a renderizar el componente tras forzar el avance del temporizador.

```jsx
test('changes initial text after clicking the button', async () => {
  render(<MyComponent initialText="any" />)

  fireEvent.click(screen.getByRole('button'))

  // 👉 avanzamos 1 segundo y avisamos a React de que tiene que volver a renderizar
  act(() => vi.advanceTimersByTime(1000))

  expect(screen.getByText('New text')).toBeInTheDocument()
})
```

¡Y voilà! Nuestro test no solo pasa, sino que además su ejecución es mucho más rápida gracias a los _fake timers_.

![Captura de la ejecución del test anterior (rápido)](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m8nbk6k8gglabjikvw9x.gif)

## Conclusión

Siempre que en nuestros tests estemos provocando actualizaciones de estado, necesitamos avisar a React de que tiene que volver a renderizar el componente mediante `act()` si no estamos usando alguno de los métodos de Testing Library que lo hacen internamente, como `fireEvent`. 

Ignorar este warning puede provocar que nuestros tests no sean fiables (incluso cuando no fallen), ya que el DOM que estamos probando no está actualizado. 

Las queries asíncronas de Testing Library (`findBy*`) también usan `act()` internamente, pero no siempre son la solución adecuada, especialmente cuando usamos temporizadores en nuestros componentes que consumen tiempo real en nuestros tests.