Testing Async Behavior
Test loading states, error handling, data fetching, Suspense boundaries, and timed operations.
Search across all documentation pages
Test loading states, error handling, data fetching, Suspense boundaries, and timed operations.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card -- copy-paste ready.
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();When to reach for this: When testing any component that fetches data, shows loading spinners, handles errors, or uses timers.
// 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();
});
});What this demonstrates:
server.use() for per-test handler overridesfindByRole and waitFor for async transitionsfindBy queries are a combination of getBy + waitFor -- they retry until the element appears or timeoutwaitFor polls its callback at intervals (default 50ms) until it passes or the timeout expires (default 1000ms)fetch calls at the network level -- your component code makes real fetch calls that MSW catchesserver.use() adds request handlers that take priority over the default ones -- call server.resetHandlers() in afterEach to remove overridesfindBy in act() so React state updates are flushedTesting Suspense boundaries:
// 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();
});Fake timers for 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 with side effects -- The callback in waitFor runs multiple times. Do not put userEvent clicks or other side effects inside it. Fix: Only put assertions inside waitFor.
Fake timers breaking waitFor -- vi.useFakeTimers() can freeze waitFor's polling interval. Fix: Either advance timers inside act() before waitFor, or use vi.useFakeTimers({ shouldAdvanceTime: true }).
Not cleaning up MSW server -- Forgetting server.resetHandlers() causes handler overrides to leak between tests. Fix: Always call server.resetHandlers() in afterEach.
Testing async state updates without waiting -- Synchronous assertions after render() see the initial state, not the resolved state. Fix: Use findBy or waitFor for all async assertions.
Multiple waitFor blocks that could be one -- Each waitFor re-polls independently, adding unnecessary test time. Fix: Combine related assertions in a single waitFor:
// Slow: two separate waitFor calls
await waitFor(() => expect(a).toBeInTheDocument());
await waitFor(() => expect(b).toBeInTheDocument());
// Fast: one waitFor with both assertions
await waitFor(() => {
expect(a).toBeInTheDocument();
expect(b).toBeInTheDocument();
});| Alternative | Use When | Don't Use When |
|---|---|---|
| MSW | You want realistic network-level mocking that works with any fetch library | You need to mock non-HTTP async operations |
vi.stubGlobal("fetch") | Quick one-off fetch mocking without MSW setup | You have many API endpoints to mock (MSW is more maintainable) |
| Playwright | You need to test async UI flows in a real browser | Fast unit-level async tests are sufficient |
| React Suspense testing utilities | You test server-side streaming or RSC | Your components use useEffect-based fetching |
findBy is shorthand for getBy + waitFor -- it waits for an element to appear in the DOM.waitFor retries any assertion callback until it passes or times out.findBy when waiting for an element; use waitFor for non-element assertions.await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});The callback in waitFor runs multiple times (polling). Putting userEvent.click() or other side effects inside it would trigger those actions repeatedly. Only put assertions inside waitFor.
http.get(), http.post(), etc.setupServer(...handlers).server.use() in individual tests to override responses.server.resetHandlers() in afterEach.vi.useFakeTimers();
render(<AutoSave onSave={onSave} />);
await act(async () => {
vi.advanceTimersByTime(30000);
});
expect(onSave).toHaveBeenCalledTimes(1);
vi.useRealTimers();Fake timers prevent waitFor's polling interval from advancing. Fix by either advancing timers inside act() before using waitFor, or use vi.useFakeTimers({ shouldAdvanceTime: true }).
Render the component and assert the fallback first, then wait for the resolved content:
render(<UserCard userId={1} />);
expect(screen.getByText("Loading user...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();Use a single waitFor with all assertions instead of multiple separate waitFor calls:
await waitFor(() => {
expect(a).toBeInTheDocument();
expect(b).toBeInTheDocument();
});http.get("/api/products", () => {
return HttpResponse.json<Product[]>([
{ id: 1, name: "Widget", price: 9.99 },
]);
});Both default to 1000ms. You can customize it:
await screen.findByText(/data/i, {}, { timeout: 3000 });Without it, handler overrides added via server.use() leak between tests, causing unexpected behavior in subsequent tests.
Assert loading is visible immediately after render, then use findBy to wait for data and assert loading is gone:
render(<DataTable />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await screen.findByText("Widget");
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥