En el capítulo anterior probaste componentes a través de su interfaz: renderizar y mirar la
pantalla. Pero buena parte de la lógica de una app de React no vive en el JSX, sino en un custom
hook: un useFavoritos, un usePaginacion, un useFiltro. Esa lógica también se prueba, y tienes
dos caminos: a través de un componente que use el hook, o aislando el hook con renderHook.
renderHook: probar un hook sin componente#
renderHook monta un hook por su cuenta, sin que tengas que escribir un componente de adorno
para usarlo. Te devuelve un objeto { result }, y result.current es lo que el hook devuelve
ahora mismo: su estado y sus funciones.
Fíjate en el patrón: renderHook(() => useContador(3)) monta el hook, result.current.valor lee su
estado, y para cambiarlo llamas a su función dentro de act(...).
act: por qué las transiciones se envuelven#
Aquí está la pieza nueva. Cuando llamas a result.current.incrementar(), estás disparando una
actualización de estado de React. Y las actualizaciones de React no son instantáneas: se
procesan en lote. act(...) envuelve ese cambio y espera a que React lo aplique del todo (incluidos
los efectos) antes de devolverte el control.
¿Y qué pasa si te saltas act? Dos cosas, y ninguna buena. Primero, React te avisa por consola:
“An update to TestComponent inside a test was not wrapped in act(…)”. Y segundo, lo importante:
result.current puede quedarse con el estado viejo cuando afirmas, porque el cambio aún no se
había procesado. Tu expect correría sobre el valor anterior y el test fallaría en falso (o
pasaría por casualidad, que es peor). Envolver en act sincroniza tu aserción con el estado ya
asentado.
El otro camino: a través del componente#
El mismo useContador podría probarse sin renderHook: montando un componente que lo use y
actuando sobre él como en el capítulo anterior.
Aquí no escribes act a mano: render y user-event ya lo aplican por dentro. Por eso, probando
a través del componente, casi nunca lo verás. Lo escribes tú solo cuando disparas una transición a
mano (result.current.algo()) en un test de renderHook.
¿Cuándo cada uno?#
No es que uno sea correcto y el otro no: resuelven cosas distintas.
- A través del componente cuando el hook es trivial o está pegado a una sola UI. Es lo más cercano al uso real (el principio del capítulo anterior) y, de paso, comprueba el cableado: que el componente usa el hook bien.
- Con
renderHookcuando el hook es una pieza reutilizable con lógica propia (unuseFavoritosque usan varios componentes). Pruebas su contrato una vez, directo y completo, sin atarlo a ningún componente concreto: si el hook funciona, funciona en cualquiera que lo monte.
La regla práctica: en la duda, a través del componente (más realista). Saca renderHook cuando el
hook se sostiene solo y quieres cubrir su lógica sin el ruido de una UI alrededor.
Pruébalo#
Toca el useContador de arriba: quita el act de uno de los tests que incrementan y mira la consola.
Verás el aviso de React sobre act y, según el caso, una aserción que ya no cuadra. Vuelve a
ponerlo. Esa es la señal de por qué las transiciones van envueltas.
Comprueba lo que sabes#
Pregunta 1 de 5
¿Qué te da renderHook que no tendrías probando solo componentes?
Tu turno#
Prueba el hook useFavoritos con renderHook. No toques el hook: comprueba su contrato (estado
inicial y transiciones), envolviendo cada cambio en act. Cuando lo tengas (o si te atascas),
despliega las soluciones y fíjate en el salto de un tier al siguiente.
Ejercicio · en esta página
Prueba el hook useFavoritos con renderHook
Tienes useFavoritos, un custom hook del Team Builder que gestiona la lista de héroes favoritos (alternar, esFavorito, limpiar). No tocas el hook: pruébalo aislado con renderHook. Comprueba el estado inicial y las transiciones, envolviendo cada cambio en act, y afirma sobre lo que el hook devuelve.
Paso 1: El contrato básico
- Con renderHook, compruebas que el hook arranca con la lista de favoritos vacía.
- Pruebas una transición: al marcar un héroe (dentro de act), aparece en favoritos y esFavorito lo confirma.
Paso 2: El toggle y varios favoritos
- Pruebas el toggle de IDA Y VUELTA: alternar el mismo héroe dos veces lo deja fuera.
- Compruebas que se pueden guardar varios favoritos a la vez.
- Cada transición va en su propio act.
Paso 3: El contrato completo, por su comportamiento
- Pruebas también limpiar (vaciar la lista) y la interacción entre estados: quitar un favorito NO debe tocar a los demás.
- Organizas los tests por acción (alternar / limpiar) para que se lean como el contrato del hook.
- Afirmas siempre sobre lo que el hook devuelve (favoritos, esFavorito), nunca sobre su useState interno.
Ver soluciones
// Tier OK: el contrato básico del hook con renderHook: estado inicial y una transición.
import { describe, it, expect } from "vitest";
// renderHook monta el hook aislado; act envuelve las actualizaciones de estado.
import { renderHook, act } from "@testing-library/react";
import { useFavoritos } from "./useFavoritos";
describe("useFavoritos", () => {
it("arranca con la lista vacía", () => {
// result.current es lo que el hook devuelve ahora mismo.
const { result } = renderHook(() => useFavoritos());
// Estado inicial: ningún favorito.
expect(result.current.favoritos).toEqual([]);
});
it("añade un héroe al marcarlo", () => {
const { result } = renderHook(() => useFavoritos());
// La transición va DENTRO de act: así result.current refleja el nuevo estado al afirmar.
act(() => result.current.alternar("Genji"));
// La lista contiene el héroe y esFavorito lo confirma.
expect(result.current.favoritos).toEqual(["Genji"]);
expect(result.current.esFavorito("Genji")).toBe(true);
});
}); Por qué este nivel
- Prueba el contrato básico con renderHook: el estado inicial (vacío) y una transición (marcar un favorito). La actualización va dentro de act, que es lo que hace que result.current refleje el nuevo estado al afirmar.
- Su límite: solo añade. No comprueba quitar (el toggle de vuelta), ni varios favoritos, ni limpiar; ahí es donde un hook de toggle suele esconder bugs.
// Tier mejor: el toggle de ida y vuelta y varios favoritos a la vez.
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useFavoritos } from "./useFavoritos";
describe("useFavoritos", () => {
it("arranca con la lista vacía", () => {
const { result } = renderHook(() => useFavoritos());
expect(result.current.favoritos).toEqual([]);
});
it("alterna un héroe: lo añade y, al repetir, lo quita", () => {
const { result } = renderHook(() => useFavoritos());
// Primer alternar: lo añade.
act(() => result.current.alternar("Genji"));
expect(result.current.esFavorito("Genji")).toBe(true);
// Segundo alternar sobre el mismo: lo quita (el toggle es reversible).
act(() => result.current.alternar("Genji"));
expect(result.current.esFavorito("Genji")).toBe(false);
expect(result.current.favoritos).toEqual([]);
});
it("guarda varios favoritos a la vez", () => {
const { result } = renderHook(() => useFavoritos());
// Cada transición en su propio act.
act(() => result.current.alternar("Genji"));
act(() => result.current.alternar("Mercy"));
expect(result.current.favoritos).toEqual(["Genji", "Mercy"]);
});
}); Por qué es mejor que el anterior
- Cubre el toggle de IDA Y VUELTA (añadir y quitar el mismo héroe) y varios favoritos a la vez. Un test que solo añade no detecta un alternar que no sabe quitar.
- Cada transición en su propio act, y las aserciones sobre el contrato observable del hook (favoritos, esFavorito), no sobre cómo lo guarda por dentro.
// Tier excelente: el contrato completo (alternar, limpiar, la interacción entre estados),
// organizado por acción y afirmando siempre sobre lo que el hook DEVUELVE, no sobre su useState.
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useFavoritos } from "./useFavoritos";
describe("useFavoritos", () => {
it("arranca vacío y nada es favorito", () => {
const { result } = renderHook(() => useFavoritos());
expect(result.current.favoritos).toEqual([]);
expect(result.current.esFavorito("Genji")).toBe(false);
});
describe("alternar", () => {
it("añade y quita el mismo héroe (ida y vuelta)", () => {
const { result } = renderHook(() => useFavoritos());
act(() => result.current.alternar("Genji"));
expect(result.current.esFavorito("Genji")).toBe(true);
act(() => result.current.alternar("Genji"));
expect(result.current.esFavorito("Genji")).toBe(false);
});
it("al quitar uno, deja intactos los demás", () => {
const { result } = renderHook(() => useFavoritos());
act(() => result.current.alternar("Genji"));
act(() => result.current.alternar("Mercy"));
// Quitar Genji no debe tocar a Mercy.
act(() => result.current.alternar("Genji"));
expect(result.current.favoritos).toEqual(["Mercy"]);
});
});
describe("limpiar", () => {
it("vacía la lista de un golpe", () => {
const { result } = renderHook(() => useFavoritos());
act(() => result.current.alternar("Genji"));
act(() => result.current.alternar("Mercy"));
act(() => result.current.limpiar());
expect(result.current.favoritos).toEqual([]);
});
});
}); Por qué es mejor que el anterior
- Prueba el contrato completo: alternar, limpiar y la interacción entre estados (quitar uno deja intactos los demás). Ese "solo toca el alternado" es justo el bug que un test de un único favorito no ve.
- Organiza los tests por acción en describes que se leen como la documentación del hook.
- Afirma siempre sobre lo que el hook DEVUELVE: si mañana cambias el array por un Set pero el contrato no cambia, estos tests siguen verdes.