Pruebas de comportamiento asíncrono
Prueba estados de carga, manejo de errores, obtención de datos, límites de Suspense y operaciones temporizadas.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
import { render, screen, waitFor } from "@testing-library/react";
// Wait for an element to appear
const heading = await screen.findByText(/welcome/i); // waits up to 1s
// Wait for a condition
await waitFor(() => {
expect(screen.getByText(/loaded/i)).toBeInTheDocument();
});
// Wait for an element to disappear
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
// Custom timeout
const result = await screen.findByText(/data/i, {}, { timeout: 3000 });
// Fake timers for setTimeout/setInterval
vi.useFakeTimers();
render(<Countdown seconds={10} />);
act(() => vi.advanceTimersByTime(5000));
expect(screen.getByText("5 seconds left")).toBeInTheDocument();
vi.useRealTimers();Cuándo usarlo: Cuando pruebes cualquier componente que obtenga datos, muestre spinners de carga, maneje errores o use temporizadores.
Ejemplo funcional
// src/components/data-table.tsx
"use client";
import { useEffect, useState } from "react";
interface Product {
id: number;
name: string;
price: number;
}
export function DataTable() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadProducts() {
try {
const res = await fetch("/api/products");
if (!res.ok) throw new Error("Failed to load products");
const data: Product[] = await res.json();
setProducts(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}
loadProducts();
}, []);
if (loading) {
return (
<div role="status" aria-label="Loading">
<p>Loading products...</p>
</div>
);
}
if (error) {
return <p role="alert">Error: {error}</p>;
}
if (products.length === 0) {
return <p>No products found.</p>;
}
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{products.map((product) => (
<tr key={product.id}>
<td>{product.name}</td>
<td>${product.price.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
);
}// src/components/data-table.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { DataTable } from "./data-table";
const products = [
{ id: 1, name: "Widget", price: 9.99 },
{ id: 2, name: "Gadget", price: 24.5 },
{ id: 3, name: "Doohickey", price: 4.0 },
];
const server = setupServer(
http.get("/api/products", () => {
return HttpResponse.json(products);
})
);
beforeEach(() => server.listen());
afterEach(() => {
server.resetHandlers();
server.close();
});
describe("DataTable", () => {
it("shows loading state initially", () => {
render(<DataTable />);
expect(screen.getByRole("status", { name: /loading/i })).toBeInTheDocument();
});
it("renders products on success", async () => {
render(<DataTable />);
// Wait for loading to finish
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
// Verify table content
expect(screen.getByText("Widget")).toBeInTheDocument();
expect(screen.getByText("$9.99")).toBeInTheDocument();
expect(screen.getByText("Gadget")).toBeInTheDocument();
expect(screen.getByText("$24.50")).toBeInTheDocument();
// Verify row count
const rows = screen.getAllByRole("row");
expect(rows).toHaveLength(4); // 1 header + 3 data rows
});
it("shows error message on failure", async () => {
server.use(
http.get("/api/products", () => {
return HttpResponse.json(null, { status: 500 });
})
);
render(<DataTable />);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to load products");
});
it("shows empty state when no products", async () => {
server.use(
http.get("/api/products", () => {
return HttpResponse.json([]);
})
);
render(<DataTable />);
expect(await screen.findByText(/no products found/i)).toBeInTheDocument();
});
it("shows loading then transitions to data", async () => {
render(<DataTable />);
// Loading is visible
expect(screen.getByText(/loading products/i)).toBeInTheDocument();
// Data appears and loading disappears
await screen.findByText("Widget");
expect(screen.queryByText(/loading products/i)).not.toBeInTheDocument();
});
});Lo que demuestra esto:
- Configuración del servidor MSW para simulación realista de API
server.use()para sobrescrituras de handlers por test- Prueba de estados de carga, éxito, error y vacío
findByRoleywaitForpara transiciones asíncronas- Verificación de que el estado de carga desaparece cuando llegan los datos
Profundización
Cómo funciona
- Las consultas
findBycombinangetBy+waitFor— reintentan hasta que el elemento aparece o se agota el tiempo de espera waitForhace polling de su callback a intervalos (50 ms por defecto) hasta que pasa o se agota el tiempo de espera (1000 ms por defecto)- MSW intercepta llamadas
fetcha nivel de red — el código de tu componente hace llamadasfetchreales que MSW captura server.use()añade handlers de solicitud que tienen prioridad sobre los predeterminados — llama aserver.resetHandlers()enafterEachpara eliminar las sobrescrituras- Testing Library envuelve automáticamente
findByenact()para que se apliquen las actualizaciones de state de React
Variaciones
Probando límites de Suspense:
// src/components/user-data.tsx
import { Suspense } from "react";
async function fetchUser(id: number) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// This component suspends while data loads
function UserName({ userPromise }: { userPromise: Promise<{ name: string }> }) {
const user = use(userPromise);
return <span>{user.name}</span>;
}
export function UserCard({ userId }: { userId: number }) {
const userPromise = fetchUser(userId);
return (
<Suspense fallback={<p>Loading user...</p>}>
<UserName userPromise={userPromise} />
</Suspense>
);
}it("shows fallback then user name", async () => {
server.use(
http.get("/api/users/1", async () => {
await delay(100); // simulate network latency
return HttpResponse.json({ name: "Alice" });
})
);
render(<UserCard userId={1} />);
expect(screen.getByText("Loading user...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});Temporizadores simulados para setTimeout/setInterval:
// src/components/auto-save.tsx
"use client";
import { useEffect, useState } from "react";
export function AutoSave({ onSave }: { onSave: () => Promise<void> }) {
const [status, setStatus] = useState("idle");
useEffect(() => {
const interval = setInterval(async () => {
setStatus("saving");
await onSave();
setStatus("saved");
}, 30000);
return () => clearInterval(interval);
}, [onSave]);
return <p>{status === "saving" ? "Saving..." : status === "saved" ? "Saved" : ""}</p>;
}import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
describe("AutoSave", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("saves every 30 seconds", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<AutoSave onSave={onSave} />);
// Advance past first interval
await act(async () => {
vi.advanceTimersByTime(30000);
});
expect(onSave).toHaveBeenCalledTimes(1);
// Advance another interval
await act(async () => {
vi.advanceTimersByTime(30000);
});
expect(onSave).toHaveBeenCalledTimes(2);
});
});Notas de TypeScript
// Type the MSW handler response for safety
import { http, HttpResponse } from "msw";
interface Product {
id: number;
name: string;
price: number;
}
http.get("/api/products", () => {
return HttpResponse.json<Product[]>([
{ id: 1, name: "Widget", price: 9.99 },
]);
});Errores comunes
-
waitForcon efectos secundarios — El callback dewaitForse ejecuta varias veces. No pongas clics deuserEventu otros efectos secundarios dentro. Solución: Solo pon aserciones dentro dewaitFor. -
Temporizadores simulados que rompen
waitFor—vi.useFakeTimers()puede congelar el intervalo de polling dewaitFor. Solución: Avanza los temporizadores dentro deact()antes dewaitFor, o usavi.useFakeTimers({ shouldAdvanceTime: true }). -
No limpiar el servidor MSW — Olvidar
server.resetHandlers()hace que las sobrescrituras de handlers se filtren entre tests. Solución: Llama siempre aserver.resetHandlers()enafterEach. -
Probar actualizaciones de state asíncronas sin esperar — Las aserciones síncronas después de
render()ven el state inicial, no el resuelto. Solución: UsafindByowaitForpara todas las aserciones asíncronas. -
Varios bloques
waitForque podrían ser uno — CadawaitForvuelve a hacer polling de forma independiente, añadiendo tiempo innecesario al test. Solución: Combina aserciones relacionadas en un solowaitFor:// Lento: dos llamadas waitFor separadas await waitFor(() => expect(a).toBeInTheDocument()); await waitFor(() => expect(b).toBeInTheDocument()); // Rápido: un waitFor con ambas aserciones await waitFor(() => { expect(a).toBeInTheDocument(); expect(b).toBeInTheDocument(); });
Alternativas
| Alternativa | Usar cuando | No usar cuando |
|---|---|---|
| MSW | Quieres simulación realista a nivel de red que funciona con cualquier librería fetch | Necesitas simular operaciones asíncronas que no son HTTP |
vi.stubGlobal("fetch") | Simulación rápida y puntual de fetch sin configurar MSW | Tienes muchos endpoints de API que simular (MSW es más mantenible) |
| Playwright | Necesitas probar flujos de UI asíncronos en un navegador real | Los tests asíncronos unitarios rápidos son suficientes |
| Utilidades de testing de React Suspense | Pruebas de streaming del lado del servidor o RSC | Tus componentes usan obtención de datos basada en useEffect |
FAQs
¿Cuál es la diferencia entre findBy y waitFor?
findByes un atajo degetBy+waitFor— espera a que un elemento aparezca en el DOM.waitForreintenta cualquier callback de aserción hasta que pasa o se agota el tiempo de espera.- Usa
findBycuando esperes un elemento; usawaitForpara aserciones que no son de elementos.
¿Cómo espero a que desaparezca un spinner de carga?
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});Trampa: ¿Por qué nunca debo poner efectos secundarios dentro de waitFor?
El callback de waitFor se ejecuta varias veces (polling). Poner userEvent.click() u otros efectos secundarios dentro provocaría que esas acciones se disparen repetidamente. Solo pon aserciones dentro de waitFor.
¿Cómo uso MSW para simular respuestas de API en tests?
- Define handlers con
http.get(),http.post(), etc. - Crea un servidor con
setupServer(...handlers). - Usa
server.use()en tests individuales para sobrescribir respuestas. - Llama a
server.resetHandlers()enafterEach.
¿Cómo pruebo un componente que usa setTimeout o setInterval?
vi.useFakeTimers();
render(<AutoSave onSave={onSave} />);
await act(async () => {
vi.advanceTimersByTime(30000);
});
expect(onSave).toHaveBeenCalledTimes(1);
vi.useRealTimers();Trampa: ¿Por qué vi.useFakeTimers() congela waitFor?
Los temporizadores simulados impiden que avance el intervalo de polling de waitFor. Solución: avanza los temporizadores dentro de act() antes de usar waitFor, o usa vi.useFakeTimers({ shouldAdvanceTime: true }).
¿Cómo pruebo un límite de Suspense con un fallback de carga?
Renderiza el componente y afirma el fallback primero; luego espera el contenido resuelto:
render(<UserCard userId={1} />);
expect(screen.getByText("Loading user...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();¿Cómo combino varias aserciones asíncronas de forma eficiente?
Usa un solo waitFor con todas las aserciones en lugar de varias llamadas waitFor separadas:
await waitFor(() => {
expect(a).toBeInTheDocument();
expect(b).toBeInTheDocument();
});¿Cómo tipar respuestas de handlers de MSW en TypeScript?
http.get("/api/products", () => {
return HttpResponse.json<Product[]>([
{ id: 1, name: "Widget", price: 9.99 },
]);
});¿Cuál es el tiempo de espera por defecto de findBy y waitFor?
Ambos usan 1000 ms por defecto. Puedes personalizarlo:
await screen.findByText(/data/i, {}, { timeout: 3000 });¿Por qué debo llamar a server.resetHandlers() en afterEach?
Sin ello, las sobrescrituras de handlers añadidas con server.use() se filtran entre tests y provocan comportamiento inesperado en tests posteriores.
¿Cómo pruebo la transición de carga a datos?
Afirma que la carga es visible inmediatamente después del render; luego usa findBy para esperar los datos y afirma que la carga desapareció:
render(<DataTable />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await screen.findByText("Widget");
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();Relacionado
- Simulación en tests — configuración de MSW y simulación de fetch
- Pruebas de componentes React — patrones de renderizado de componentes
- Pruebas de hooks personalizados — hooks asíncronos con renderHook
- Probar Server Components y Actions — Server Components asíncronos