Testing Async Behavior
Test loading states, error handling, data fetching, Suspense boundaries, and timed operations.
Recipe
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.
Working Example
// 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:
- MSW server setup for realistic API mocking
server.use()for per-test handler overrides- Testing loading, success, error, and empty states
findByRoleandwaitForfor async transitions- Verifying loading state disappears when data arrives
Deep Dive
How It Works
findByqueries are a combination ofgetBy+waitFor-- they retry until the element appears or timeoutwaitForpolls its callback at intervals (default 50ms) until it passes or the timeout expires (default 1000ms)- MSW intercepts
fetchcalls at the network level -- your component code makes realfetchcalls that MSW catches server.use()adds request handlers that take priority over the default ones -- callserver.resetHandlers()inafterEachto remove overrides- Testing Library auto-wraps
findByinact()so React state updates are flushed
Variations
Testing 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);
});
});TypeScript Notes
// 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 },
]);
});Gotchas
-
waitForwith side effects -- The callback inwaitForruns multiple times. Do not putuserEventclicks or other side effects inside it. Fix: Only put assertions insidewaitFor. -
Fake timers breaking
waitFor--vi.useFakeTimers()can freezewaitFor's polling interval. Fix: Either advance timers insideact()beforewaitFor, or usevi.useFakeTimers({ shouldAdvanceTime: true }). -
Not cleaning up MSW server -- Forgetting
server.resetHandlers()causes handler overrides to leak between tests. Fix: Always callserver.resetHandlers()inafterEach. -
Testing async state updates without waiting -- Synchronous assertions after
render()see the initial state, not the resolved state. Fix: UsefindByorwaitForfor all async assertions. -
Multiple
waitForblocks that could be one -- EachwaitForre-polls independently, adding unnecessary test time. Fix: Combine related assertions in a singlewaitFor:// 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(); });
Alternatives
| 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 |
FAQs
What is the difference between findBy and waitFor?
findByis shorthand forgetBy+waitFor-- it waits for an element to appear in the DOM.waitForretries any assertion callback until it passes or times out.- Use
findBywhen waiting for an element; usewaitForfor non-element assertions.
How do I wait for a loading spinner to disappear?
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});Gotcha: Why should I never put side effects inside waitFor?
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.
How do I use MSW to mock API responses in tests?
- Define handlers with
http.get(),http.post(), etc. - Create a server with
setupServer(...handlers). - Use
server.use()in individual tests to override responses. - Call
server.resetHandlers()inafterEach.
How do I test a component that uses setTimeout or setInterval?
vi.useFakeTimers();
render(<AutoSave onSave={onSave} />);
await act(async () => {
vi.advanceTimersByTime(30000);
});
expect(onSave).toHaveBeenCalledTimes(1);
vi.useRealTimers();Gotcha: Why does vi.useFakeTimers() freeze waitFor?
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 }).
How do I test a Suspense boundary with a loading fallback?
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();How do I combine multiple async assertions efficiently?
Use a single waitFor with all assertions instead of multiple separate waitFor calls:
await waitFor(() => {
expect(a).toBeInTheDocument();
expect(b).toBeInTheDocument();
});How do I type MSW handler responses in TypeScript?
http.get("/api/products", () => {
return HttpResponse.json<Product[]>([
{ id: 1, name: "Widget", price: 9.99 },
]);
});What is the default timeout for findBy and waitFor?
Both default to 1000ms. You can customize it:
await screen.findByText(/data/i, {}, { timeout: 3000 });Why should I call server.resetHandlers() in afterEach?
Without it, handler overrides added via server.use() leak between tests, causing unexpected behavior in subsequent tests.
How do I test the loading-to-data transition?
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();Related
- Mocking in Tests -- MSW setup and fetch mocking
- Testing Components -- component rendering patterns
- Testing Custom Hooks -- async hooks with renderHook
- Testing Server Components & Actions -- async Server Components