Una guía completa para probar los ganchos de reacción
Publicado: 2022-03-11Los ganchos se introdujeron en React 16.8 a fines de 2018. Son funciones que se conectan a un componente funcional y nos permiten usar funciones de estado y componentes como componentDidUpdate
, componentDidMount
y más. Esto no era posible antes.
Además, los ganchos nos permiten reutilizar componentes y lógica de estado en diferentes componentes. Esto era complicado de hacer antes. Por lo tanto, los ganchos han cambiado las reglas del juego.
En este artículo, exploraremos cómo probar React Hooks. Elegiremos un gancho lo suficientemente complejo y trabajaremos para probarlo.
Esperamos que sea un ávido desarrollador de React que ya esté familiarizado con React Hooks. En caso de que desee refrescar sus conocimientos, debe consultar nuestro tutorial, y aquí está el enlace a la documentación oficial.
El gancho que usaremos para la prueba
Para este artículo, usaremos un gancho que escribí en mi artículo anterior, Recuperación de datos obsoletos mientras se revalida con ganchos de React. El gancho se llama useStaleRefresh
. Si no ha leído el artículo, no se preocupe, recapitularé esa parte aquí.
Este es el gancho que estaremos probando:
import { useState, useEffect } from "react"; const CACHE = {}; export default function useStaleRefresh(url, defaultValue = []) { const [data, setData] = useState(defaultValue); const [isLoading, setLoading] = useState(true); useEffect(() => { // cacheID is how a cache is identified against a unique request const cacheID = url; // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); setLoading(false); } else { // else make sure loading set to true setLoading(true); setData(defaultValue); } // fetch new data fetch(url) .then((res) => res.json()) .then((newData) => { CACHE[cacheID] = newData; setData(newData); setLoading(false); }); }, [url, defaultValue]); return [data, isLoading]; }
Como puede ver, useStaleRefresh
es un enlace que ayuda a obtener datos de una URL mientras devuelve una versión almacenada en caché de los datos, si existe. Utiliza un almacenamiento en memoria simple para almacenar el caché.
También devuelve un valor isLoading
que es verdadero si aún no hay datos o caché disponibles. El cliente puede usarlo para mostrar un indicador de carga. El valor de isLoading
se establece en falso cuando la memoria caché o la respuesta nueva están disponibles.
En este punto, le sugiero que dedique algún tiempo a leer el enlace anterior para obtener una comprensión completa de lo que hace.
En este artículo, veremos cómo podemos probar este gancho, primero sin usar bibliotecas de prueba (solo React Test Utilities y Jest) y luego usando react-hooks-testing-library.
La motivación detrás de no usar bibliotecas de prueba, es decir, solo un corredor de prueba Jest
, es demostrar cómo funciona la prueba de un gancho. Con ese conocimiento, podrá depurar cualquier problema que pueda surgir al usar una biblioteca que proporciona abstracción de prueba.
Definición de los casos de prueba
Antes de comenzar a probar este gancho, hagamos un plan de lo que queremos probar. Como sabemos lo que se supone que debe hacer el gancho, este es mi plan de ocho pasos para probarlo:
- Cuando el enlace se monta con la URL
url1
,isLoading
estrue
y los datos sondefaultValue
. - Después de una solicitud de recuperación asincrónica, el gancho se actualiza con datos
data1
yisLoading
esfalse
. - Cuando la URL se cambia a
url2
,isLoading
vuelve a ser verdadero y data esdefaultValue
. - Después de una solicitud de recuperación asíncrona, el enlace se actualiza con datos nuevos
data2
. - Luego, volvemos a cambiar la URL a
url1
. Los datosdata1
se reciben instantáneamente ya que se almacenan en caché.isLoading
es falso. - Después de una solicitud de recuperación asíncrona, cuando se recibe una respuesta nueva, los datos se actualizan a
data3
. - Luego, volvemos a cambiar la URL a
url2
. Los datosdata2
se reciben instantáneamente ya que se almacenan en caché.isLoading
es falso. - Después de una solicitud de recuperación asíncrona, cuando se recibe una respuesta nueva, los datos se actualizan a
data4
.
El flujo de prueba mencionado anteriormente define claramente la trayectoria de cómo funcionará el gancho. Por lo tanto, si podemos garantizar que esta prueba funcione, estamos bien.
Probar ganchos sin una biblioteca
En esta sección, veremos cómo probar ganchos sin usar ninguna biblioteca. Esto nos proporcionará una comprensión profunda de cómo probar React Hooks.
Para comenzar esta prueba, primero, nos gustaría simular fetch
. Esto es para que podamos tener control sobre lo que devuelve la API. Aquí está la fetch
.
function fetchMock(url, suffix = "") { return new Promise((resolve) => setTimeout(() => { resolve({ json: () => Promise.resolve({ data: url + suffix, }), }); }, 200 + Math.random() * 300) ); }
Esta fetch
modificada asume que el tipo de respuesta siempre es JSON y, de forma predeterminada, devuelve la url
del parámetro como el valor de los data
. También agrega un retraso aleatorio de entre 200ms y 500ms a la respuesta.
Si queremos cambiar la respuesta, simplemente establecemos el suffix
del segundo argumento en un valor de cadena no vacío.
Llegados a este punto, te preguntarás, ¿por qué la demora? ¿Por qué no devolvemos la respuesta al instante? Esto se debe a que queremos replicar el mundo real tanto como sea posible. No podemos probar el anzuelo correctamente si lo devolvemos al instante. Claro, podemos reducir la demora a 50-100 ms para realizar pruebas más rápidas, pero no nos preocupemos por eso en este artículo.
Con nuestro simulacro de búsqueda listo, podemos configurarlo para la función de fetch
. Usamos beforeAll
y afterAll
para hacerlo porque esta función no tiene estado, por lo que no necesitamos restablecerla después de una prueba individual.
// runs before any tests start running beforeAll(() => { jest.spyOn(global, "fetch").mockImplementation(fetchMock); }); // runs after all tests have finished afterAll(() => { global.fetch.mockClear(); });
Luego, necesitamos montar el gancho en un componente. ¿Por qué? Porque los ganchos son solo funciones por sí mismos. Solo cuando se usan en componentes pueden responder a useState
, useEffect
, etc.
Entonces, necesitamos crear un TestComponent
que nos ayude a montar nuestro gancho.
// defaultValue is a global variable to avoid changing the object pointer on re-render // we can also deep compare `defaultValue` inside the hook's useEffect const defaultValue = { data: "" }; function TestComponent({ url }) { const [data, isLoading] = useStaleRefresh(url, defaultValue); if (isLoading) { return <div>loading</div>; } return <div>{data.data}</div>; }
Este es un componente simple que representa los datos o muestra un mensaje de texto "Cargando" si los datos se están cargando (recuperando).
Una vez que tengamos el componente de prueba, debemos montarlo en el DOM. Usamos beforeEach
y afterEach
para montar y desmontar nuestro componente para cada prueba porque queremos comenzar con un DOM nuevo antes de cada prueba.
let container = null; beforeEach(() => { // set up a DOM element as a render target container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { // cleanup on exiting unmountComponentAtNode(container); container.remove(); container = null; });
Tenga en cuenta que el container
tiene que ser una variable global, ya que queremos tener acceso a él para las aserciones de prueba.
Con ese conjunto, hagamos nuestra primera prueba en la que representamos una URL url1
, y dado que recuperar la URL llevará algún tiempo (ver fetchMock
), debería representar el texto de "carga" inicialmente.
it("useStaleRefresh hook runs correctly", () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); })
Ejecute la prueba usando yarn test
y funciona como se esperaba. Aquí está el código completo en GitHub.
Ahora, probemos cuándo este texto de loading
cambia a los datos de respuesta obtenidos, url1
.
¿Como hacemos eso? Si observa fetchMock
, verá que esperamos entre 200 y 500 milisegundos. ¿Y si ponemos un sleep
en la prueba que espera 500 milisegundos? Cubrirá todos los posibles tiempos de espera. Probemos eso.
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } it("useStaleRefresh hook runs correctly", async () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); await sleep(500); expect(container.textContent).toBe("url1"); });
La prueba pasa, pero también vemos un error (código).
PASS src/useStaleRefresh.test.js ✓ useStaleRefresh hook runs correctly (519ms) console.error node_modules/react-dom/cjs/react-dom.development.js:88 Warning: An update to TestComponent inside a test was not wrapped in act(...).
Esto se debe a que la actualización de estado en el gancho useStaleRefresh
ocurre fuera de act(). Para asegurarse de que las actualizaciones de DOM se procesen a tiempo, React recomienda que use act()
cada vez que pueda ocurrir una nueva representación o una actualización de la interfaz de usuario. Por lo tanto, debemos terminar nuestro sueño con act
, ya que este es el momento en que ocurre la actualización del estado. Después de hacerlo, el error desaparece.
import { act } from "react-dom/test-utils"; // ... await act(() => sleep(500));
Ahora, ejecútalo de nuevo (código en GitHub). Como era de esperar, pasa sin errores.
Probemos la siguiente situación en la que primero cambiamos la URL a url2
, luego verificamos la pantalla de loading
, luego esperamos la respuesta de búsqueda y finalmente verificamos el texto url2
. Como ahora sabemos cómo esperar correctamente los cambios asíncronos, esto debería ser fácil.

act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toContain("loading"); await act(() => sleep(500)); expect(container.textContent).toBe("url2");
Ejecute esta prueba, y también pasa. Ahora, también podemos probar el caso donde los datos de respuesta cambian y el caché entra en juego.
Notará que tenemos un suffix
de argumento adicional en nuestra función fetchMock . Esto es para cambiar los datos de respuesta. Así que actualizamos nuestro simulacro de recuperación para usar el suffix
.
global.fetch.mockImplementation((url) => fetchMock(url, "__"));
Ahora, podemos probar el caso en el que la URL se establece en url1
nuevamente. Primero carga url1
y luego url1__
. Podemos hacer lo mismo para url2
y no debería haber sorpresas.
it("useStaleRefresh hook runs correctly", async () => { // ... // new response global.fetch.mockImplementation((url) => fetchMock(url, "__")); // set url to url1 again act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("url1"); await act(() => sleep(500)); expect(container.textContent).toBe("url1__"); // set url to url2 again act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toBe("url2"); await act(() => sleep(500)); expect(container.textContent).toBe("url2__"); });
Toda esta prueba nos da la confianza de que el gancho realmente funciona como se esperaba (código). ¡Viva! Ahora, echemos un vistazo rápido a la optimización usando métodos auxiliares.
Optimización de las pruebas mediante el uso de métodos auxiliares
Hasta ahora, hemos visto cómo probar completamente nuestro gancho. El enfoque no es perfecto, pero funciona. Y, sin embargo, ¿podemos hacerlo mejor?
Si. Tenga en cuenta que estamos esperando 500 ms fijos para que se complete cada recuperación, pero cada solicitud toma entre 200 y 500 ms. Entonces, claramente estamos perdiendo el tiempo aquí. Podemos manejar esto mejor simplemente esperando el tiempo que toma cada solicitud.
¿Como hacemos eso? Una técnica simple es ejecutar la aserción hasta que pase o se alcance un tiempo de espera. Vamos a crear una función waitFor
que haga eso.
async function waitFor(cb, timeout = 500) { const step = 10; let timeSpent = 0; let timedOut = false; while (true) { try { await sleep(step); timeSpent += step; cb(); break; } catch {} if (timeSpent >= timeout) { timedOut = true; break; } } if (timedOut) { throw new Error("timeout"); } }
Esta función simplemente ejecuta una devolución de llamada (cb) dentro de un bloque try...catch
cada 10 ms, y si se alcanza el timeout
de espera, arroja un error. Esto nos permite ejecutar una aserción hasta que pase de manera segura (es decir, sin bucles infinitos).
Podemos usarlo en nuestra prueba de la siguiente manera: en lugar de dormir durante 500 ms y luego afirmar, usamos nuestra función waitFor
.
// INSTEAD OF await act(() => sleep(500)); expect(container.textContent).toBe("url1"); // WE DO await act(() => waitFor(() => { expect(container.textContent).toBe("url1"); }) );
Hágalo en todas esas afirmaciones, y podemos ver una diferencia considerable en la rapidez con que se ejecuta nuestra prueba (código).
Ahora, todo esto es genial, pero tal vez no queramos probar el enlace a través de la interfaz de usuario. Tal vez queramos probar un gancho usando sus valores de retorno. ¿Como hacemos eso?
No será difícil porque ya tenemos acceso a los valores de retorno de nuestro gancho. Están justo dentro del componente. Si podemos llevar esas variables al ámbito global, funcionará. Así que hagamos eso.
Dado que probaremos nuestro gancho a través de su valor de retorno y no del DOM renderizado, podemos eliminar el renderizado HTML de nuestro componente y hacer que se renderice como null
. También deberíamos eliminar la desestructuración en el retorno del gancho para hacerlo más genérico. Por lo tanto, tenemos este componente de prueba actualizado.
// global variable let result; function TestComponent({ url }) { result = useStaleRefresh(url, defaultValue); return null; }
Ahora el valor de retorno del gancho se almacena en result
, una variable global. Podemos consultarlo para nuestras afirmaciones.
// INSTEAD OF expect(container.textContent).toContain("loading"); // WE DO expect(result[1]).toBe(true); // INSTEAD OF expect(container.textContent).toBe("url1"); // WE DO expect(result[0].data).toBe("url1");
Después de cambiarlo en todas partes, podemos ver que nuestras pruebas están pasando (código).
En este punto, entendemos la esencia de probar React Hooks. Hay algunas mejoras que aún podemos hacer, como:
- Mover la variable de
result
a un ámbito local - Eliminando la necesidad de crear un componente para cada enlace que queremos probar
Podemos hacerlo creando una función de fábrica que tenga un componente de prueba dentro. También debería representar el gancho en el componente de prueba y darnos acceso a la variable de result
. Veamos cómo podemos hacer eso.
Primero, movemos TestComponent
y result
dentro de la función. También necesitaremos pasar Hook y los argumentos de Hook como argumentos de función para que puedan usarse en nuestro componente de prueba. Usando eso, esto es lo que tenemos. Estamos llamando a esta función renderHook
.
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } act(() => { render(<TestComponent hookArgs={args} />, container); }); return result; }
La razón por la que tenemos result
como un objeto que almacena datos en result.current
es porque queremos que los valores devueltos se actualicen a medida que se ejecuta la prueba. El valor de retorno de nuestro gancho es una matriz, por lo que se habría copiado por valor si lo devolviéramos directamente. Al almacenarlo en un objeto, devolvemos una referencia a ese objeto para que los valores de retorno puedan actualizarse actualizando result.current
.
Ahora, ¿cómo hacemos para actualizar el gancho? Como ya estamos usando un cierre, incluyamos otra función rerender
que pueda hacer eso.
La función renderHook
final se ve así:
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } function rerender(args) { act(() => { render(<TestComponent hookArgs={args} />, container); }); } rerender(args); return { result, rerender }; }
Ahora, podemos usarlo en nuestra prueba. En lugar de usar act
y render
, hacemos lo siguiente:
const { rerender, result } = renderHook(useStaleRefresh, [ "url1", defaultValue, ]);
Luego, podemos afirmar usando result.current
y actualizar el gancho usando rerender
. Aquí hay un ejemplo simple:
rerender(["url2", defaultValue]); expect(result.current[1]).toBe(true); // check isLoading is true
Una vez que lo cambie en todos los lugares, verá que funciona sin ningún problema (código).
¡Brillante! Ahora tenemos una abstracción mucho más limpia para probar ganchos. Todavía podemos hacerlo mejor; por ejemplo, se debe pasar defaultValue
cada vez para volver a rerender
aunque no cambie. Podemos arreglarlo.
Pero no nos andemos con rodeos porque ya contamos con una biblioteca que mejora significativamente esta experiencia.
Ingrese react-hooks-testing-library.
Prueba usando React-hooks-testing-library
React-hooks-testing-library hace todo lo que hemos hablado antes y algo más. Por ejemplo, maneja el montaje y desmontaje de contenedores para que no tenga que hacer eso en su archivo de prueba. Esto nos permite concentrarnos en probar nuestros ganchos sin distraernos.
Viene con una función renderHook
que devuelve rerender
y result
. También devuelve wait
, que es similar a waitFor
, por lo que no tiene que implementarlo usted mismo.
Así es como renderizamos un gancho en React-hooks-testing-library. Observe que el enlace se pasa en forma de devolución de llamada. Esta devolución de llamada se ejecuta cada vez que se vuelve a renderizar el componente de prueba.
const { result, wait, rerender } = renderHook( ({ url }) => useStaleRefresh(url, defaultValue), { initialProps: { url: "url1", }, } );
Luego, podemos probar si el primer renderizado resultó en isLoading
como verdadero y devolver el valor como valor defaultValue
al hacer esto. Exactamente similar a lo que implementamos anteriormente.
expect(result.current[0]).toEqual(defaultValue); expect(result.current[1]).toBe(true);
Para probar las actualizaciones asíncronas, podemos usar el método de wait
que devolvió renderHook
. Viene envuelto con act()
, por lo que no necesitamos envolverlo con act()
.
await wait(() => { expect(result.current[0].data).toEqual("url1"); }); expect(result.current[1]).toBe(false);
Luego, podemos usar rerender
para actualizarlo con nuevos accesorios. Tenga en cuenta que no necesitamos pasar defaultValue
aquí.
rerender({ url: "url2" });
Finalmente, el resto de la prueba procederá de manera similar (código).
Terminando
Mi objetivo era mostrarte cómo probar React Hooks tomando un ejemplo de un gancho asíncrono. Espero que esto te ayude a abordar con confianza las pruebas de cualquier tipo de anzuelo, ya que el mismo enfoque debería aplicarse a la mayoría de ellos.
Le recomendaría que use React-hooks-testing-library ya que está completa, y hasta ahora no he tenido problemas significativos con ella. En caso de que encuentre un problema, ahora sabe cómo abordarlo utilizando las complejidades de los ganchos de prueba descritos en este artículo.