DEV Community

Cover image for Testing de componentes React: ¿cuándo y por qué necesitamos usar act()?
Kus Cámara
Kus Cámara

Posted on

Testing de componentes React: ¿cuándo y por qué necesitamos usar act()?

¿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 */
Enter fullscreen mode Exit fullscreen mode

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.

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

  return (
    <div>
      <button onClick={() => setText('New text')}>Click me</button>
      <p>{text}</p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

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:

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

  // assert
  expect(screen.getByText('any')).toBeInTheDocument()
})
Enter fullscreen mode Exit fullscreen mode

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:

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()
})
Enter fullscreen mode Exit fullscreen mode

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():

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()
})
Enter fullscreen mode Exit fullscreen mode

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:

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()
})
Enter fullscreen mode Exit fullscreen mode

Ejemplo con userEvent (asíncrono):

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()
})
Enter fullscreen mode Exit fullscreen mode

¿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:

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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:

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()
})
Enter fullscreen mode Exit fullscreen mode

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:

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')
})
Enter fullscreen mode Exit fullscreen mode

La ejecución de este test tarda más de 1 segundo.

Captura de la ejecución del test anterior (lento)

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():

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()
})
Enter fullscreen mode Exit fullscreen mode

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.

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()
})
Enter fullscreen mode Exit fullscreen mode

¡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)

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.

Top comments (0)