Pruebas de comportamiento asíncrono
Prueba estados de carga, manejo de errores, obtención de datos, límites de Suspense y operaciones temporizadas.
Busca en todas las páginas de la documentación
Prueba estados de carga, manejo de errores, obtención de datos, límites de Suspense y operaciones temporizadas.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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:
server.use() para sobrescrituras de handlers por testfindByRole y waitFor para transiciones asíncronasfindBy combinan getBy + waitFor - reintentan hasta que el elemento aparece o se agota el tiempo de esperawaitFor hace polling de su callback a intervalos (50 ms por defecto) hasta que pasa o se agota el tiempo de espera (1000 ms por defecto)fetch a nivel de red - el código de tu componente hace llamadas fetch reales que MSW capturaserver.use() añade handlers de solicitud que tienen prioridad sobre los predeterminados - llama a server.resetHandlers() en afterEach para eliminar las sobrescriturasfindBy en act() para que se apliquen las actualizaciones de state de ReactProbando 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);
});
});// 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 },
]);
});waitFor con efectos secundarios - El callback de waitFor se ejecuta varias veces. No pongas clics de userEvent u otros efectos secundarios dentro. Solución: Solo pon aserciones dentro de waitFor.
Temporizadores simulados que rompen waitFor - vi.useFakeTimers() puede congelar el intervalo de polling de waitFor. Solución: Avanza los temporizadores dentro de act() antes de waitFor, o usa vi.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 a server.resetHandlers() en afterEach.
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: Usa findBy o waitFor para todas las aserciones asíncronas.
Varios bloques waitFor que podrían ser uno - Cada waitFor vuelve a hacer polling de forma independiente, añadiendo tiempo innecesario al test. Solución: Combina aserciones relacionadas en un solo waitFor:
// 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();
});| 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 |
findBy es un atajo de getBy + waitFor - espera a que un elemento aparezca en el DOM.waitFor reintenta cualquier callback de aserción hasta que pasa o se agota el tiempo de espera.findBy cuando esperes un elemento; usa waitFor para aserciones que no son de elementos.await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});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.
http.get(), http.post(), etc.setupServer(...handlers).server.use() en tests individuales para sobrescribir respuestas.server.resetHandlers() en afterEach.vi.useFakeTimers();
render(<AutoSave onSave={onSave} />);
await act(async () => {
vi.advanceTimersByTime(30000);
});
expect(onSave).toHaveBeenCalledTimes(1);
vi.useRealTimers();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 }).
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();Usa un solo waitFor con todas las aserciones en lugar de varias llamadas waitFor separadas:
await waitFor(() => {
expect(a).toBeInTheDocument();
expect(b).toBeInTheDocument();
});http.get("/api/products", () => {
return HttpResponse.json<Product[]>([
{ id: 1, name: "Widget", price: 9.99 },
]);
});Ambos usan 1000 ms por defecto. Puedes personalizarlo:
await screen.findByText(/data/i, {}, { timeout: 3000 });Sin ello, las sobrescrituras de handlers añadidas con server.use() se filtran entre tests y provocan comportamiento inesperado en tests posteriores.
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();Revisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥