React Testing Library Patterns in 2026: user-event, Async Queries, and Accessibility Testing
Master React Testing Library in 2026: user-event v14, async query patterns, accessibility testing, mock strategies for Next.js, and component test architecture that scales.
React Testing Library Patterns in 2026: user-event, Async Queries, and Accessibility Testing
React Testing Library has matured into the de facto standard for React component testing—but using it well requires understanding its philosophy. RTL tests should simulate how users actually interact with your UI, not inspect implementation details. That means querying by accessible roles and labels, interacting through user events rather than directly calling handlers, and testing async behavior through the UI, not by mocking timers.
This post covers the patterns that actually matter: user-event v14 for realistic interactions, async query strategies, accessibility testing embedded into component tests, and mock strategies for Next.js App Router.
Setup
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom vitest jsdom
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/tests/setup.ts"],
coverage: {
reporter: ["text", "html"],
exclude: ["**/*.stories.*", "**/*.config.*"],
},
},
});
// src/tests/setup.ts
import "@testing-library/jest-dom";
import { afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
// Clean up after each test
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// Mock Next.js router globally
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
}),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
user-event v14: The Right Way to Interact
userEvent.setup() creates a session that properly tracks pointer position, modifier keys, and input history—much closer to real browser behavior than fireEvent.
// components/SearchBox/SearchBox.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SearchBox } from "./SearchBox";
import { vi, describe, it, expect, beforeEach } from "vitest";
describe("SearchBox", () => {
// Create user session ONCE per describe block for performance
const user = userEvent.setup();
it("calls onSearch when user types and submits", async () => {
const onSearch = vi.fn();
render(<SearchBox onSearch={onSearch} />);
const input = screen.getByRole("searchbox", { name: /search/i });
// userEvent.type simulates keydown, keypress, keyup, and input events
await user.type(input, "react patterns");
// Check intermediate state
expect(input).toHaveValue("react patterns");
// Submit by pressing Enter
await user.keyboard("{Enter}");
expect(onSearch).toHaveBeenCalledWith("react patterns");
});
it("clears the input when clear button is clicked", async () => {
render(<SearchBox onSearch={vi.fn()} />);
const input = screen.getByRole("searchbox", { name: /search/i });
await user.type(input, "hello");
expect(input).toHaveValue("hello");
// Clear button appears after typing
const clearBtn = screen.getByRole("button", { name: /clear search/i });
await user.click(clearBtn);
expect(input).toHaveValue("");
expect(clearBtn).not.toBeInTheDocument();
});
it("is keyboard-accessible: Tab to input, type, Enter to submit", async () => {
const onSearch = vi.fn();
render(
<div>
<button>Before</button>
<SearchBox onSearch={onSearch} />
<button>After</button>
</div>
);
// Start focused on first button
await user.tab();
expect(screen.getByRole("button", { name: "Before" })).toHaveFocus();
// Tab to the search input
await user.tab();
expect(screen.getByRole("searchbox", { name: /search/i })).toHaveFocus();
await user.keyboard("test query{Enter}");
expect(onSearch).toHaveBeenCalledWith("test query");
});
});
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Async Query Patterns
RTL's findBy* queries are the correct way to test async behavior—they retry until the element appears or a timeout expires.
// components/UserList/UserList.test.tsx
import { render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { rest } from "msw";
import { setupServer } from "msw/node";
import { UserList } from "./UserList";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
// MSW server for realistic fetch mocking
const server = setupServer(
rest.get("/api/users", (_req, res, ctx) => {
return res(
ctx.json([
{ id: "1", name: "Alice Johnson", email: "alice@example.com", role: "admin" },
{ id: "2", name: "Bob Smith", email: "bob@example.com", role: "member" },
])
);
})
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
function renderWithQuery(ui: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, // No retries in tests
});
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
);
}
describe("UserList", () => {
const user = userEvent.setup();
it("shows loading state then renders users", async () => {
renderWithQuery(<UserList />);
// Loading spinner should appear immediately
expect(screen.getByRole("status", { name: /loading/i })).toBeInTheDocument();
// Users appear after fetch — findBy* waits up to 1000ms by default
expect(await screen.findByText("Alice Johnson")).toBeInTheDocument();
expect(screen.getByText("Bob Smith")).toBeInTheDocument();
// Spinner should be gone
expect(screen.queryByRole("status", { name: /loading/i })).not.toBeInTheDocument();
});
it("shows error state when fetch fails", async () => {
server.use(
rest.get("/api/users", (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ error: "Internal server error" }))
)
);
renderWithQuery(<UserList />);
// findByRole waits for the error to appear
const errorAlert = await screen.findByRole("alert");
expect(errorAlert).toHaveTextContent(/failed to load/i);
});
it("filters users by search query", async () => {
renderWithQuery(<UserList />);
// Wait for initial load
await screen.findByText("Alice Johnson");
const searchInput = screen.getByRole("searchbox", { name: /filter users/i });
await user.type(searchInput, "Alice");
// Alice should remain; Bob should disappear
expect(screen.getByText("Alice Johnson")).toBeInTheDocument();
expect(screen.queryByText("Bob Smith")).not.toBeInTheDocument();
});
it("deletes a user when confirmed", async () => {
renderWithQuery(<UserList />);
await screen.findByText("Alice Johnson");
// Click delete on Bob
const deleteBtn = screen.getByRole("button", { name: /delete bob smith/i });
await user.click(deleteBtn);
// Confirmation dialog appears
const dialog = screen.getByRole("dialog", { name: /confirm deletion/i });
expect(dialog).toBeInTheDocument();
// Set up MSW handler for the delete
server.use(
rest.delete("/api/users/2", (_req, res, ctx) => res(ctx.status(204)))
);
// Confirm deletion
await user.click(screen.getByRole("button", { name: /yes, delete/i }));
// Wait for Bob to disappear from the list
await waitForElementToBeRemoved(() => screen.queryByText("Bob Smith"));
expect(screen.getByText("Alice Johnson")).toBeInTheDocument();
});
});
Accessibility Testing Built Into Component Tests
Accessibility should not be an afterthought. RTL's query priorities enforce accessible markup—if you can't query by getByRole, your component might have ARIA issues.
// components/Modal/Modal.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import { Modal } from "./Modal";
expect.extend(toHaveNoViolations);
describe("Modal accessibility", () => {
const user = userEvent.setup();
it("has no axe accessibility violations", async () => {
const { container } = render(
<Modal isOpen title="Confirm Action" onClose={vi.fn()}>
<p>Are you sure?</p>
<button>Cancel</button>
<button>Confirm</button>
</Modal>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("traps focus inside modal when open", async () => {
render(
<div>
<button>Outside</button>
<Modal isOpen title="Test Modal" onClose={vi.fn()}>
<button>First</button>
<button>Second</button>
<button>Close</button>
</Modal>
</div>
);
// Focus should be inside the modal
const modal = screen.getByRole("dialog", { name: "Test Modal" });
expect(modal).toBeInTheDocument();
// Tab through all focusable elements inside the modal
await user.tab();
expect(screen.getByRole("button", { name: "First" })).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "Second" })).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "Close" })).toHaveFocus();
// Tab should cycle back inside the modal, not escape to "Outside"
await user.tab();
expect(screen.getByRole("button", { name: "First" })).toHaveFocus();
expect(screen.queryByRole("button", { name: "Outside" })).not.toHaveFocus();
});
it("closes on Escape key", async () => {
const onClose = vi.fn();
render(
<Modal isOpen title="Test" onClose={onClose}>
<button>Content</button>
</Modal>
);
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("announces as a dialog with correct role and label", () => {
render(
<Modal isOpen title="Delete Account" onClose={vi.fn()}>
Content
</Modal>
);
// Screen readers announce this as: "Delete Account, dialog"
const dialog = screen.getByRole("dialog", { name: "Delete Account" });
expect(dialog).toHaveAttribute("aria-modal", "true");
});
});
🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Form Testing Patterns
// components/CreateProjectForm/CreateProjectForm.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CreateProjectForm } from "./CreateProjectForm";
import { rest } from "msw";
import { setupServer } from "msw/node";
const server = setupServer();
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("CreateProjectForm", () => {
const user = userEvent.setup();
it("validates required fields before submitting", async () => {
render(<CreateProjectForm onSuccess={vi.fn()} />);
// Submit without filling anything
await user.click(screen.getByRole("button", { name: /create project/i }));
// Validation errors appear
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: /project name/i })).toHaveAttribute(
"aria-invalid",
"true"
);
});
it("submits valid form data and shows success", async () => {
const onSuccess = vi.fn();
server.use(
rest.post("/api/projects", async (req, res, ctx) => {
const body = await req.json();
return res(ctx.json({ id: "new-123", ...body }));
})
);
render(<CreateProjectForm onSuccess={onSuccess} />);
await user.type(
screen.getByRole("textbox", { name: /project name/i }),
"My New Project"
);
await user.type(
screen.getByRole("textbox", { name: /description/i }),
"A description"
);
await user.selectOptions(
screen.getByRole("combobox", { name: /visibility/i }),
"private"
);
await user.click(screen.getByRole("button", { name: /create project/i }));
// Loading state while submitting
expect(screen.getByRole("button", { name: /creating/i })).toBeDisabled();
// Success callback called after response
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith({ id: "new-123" }));
});
it("shows server-side validation errors inline", async () => {
server.use(
rest.post("/api/projects", (_req, res, ctx) =>
res(
ctx.status(422),
ctx.json({
errors: { name: "Project name already exists in this workspace" },
})
)
)
);
render(<CreateProjectForm onSuccess={vi.fn()} />);
await user.type(screen.getByRole("textbox", { name: /project name/i }), "Duplicate");
await user.click(screen.getByRole("button", { name: /create project/i }));
expect(await screen.findByText(/project name already exists/i)).toBeInTheDocument();
});
});
Next.js App Router Mocking
// tests/mocks/next-navigation.ts
import { vi } from "vitest";
export const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
};
export const mockSearchParams = new URLSearchParams();
export const mockPathname = "/dashboard";
vi.mock("next/navigation", () => ({
useRouter: () => mockRouter,
usePathname: () => mockPathname,
useSearchParams: () => mockSearchParams,
redirect: vi.fn(),
notFound: vi.fn(),
}));
// For Server Components — test through API layer instead
// Client components: mock hooks as above
// Server components: test the underlying data functions directly
// app/dashboard/page.test.tsx — testing a Server Component
// Test the data-fetching function, not the RSC itself
import { describe, it, expect, vi } from "vitest";
import { getDashboardData } from "@/lib/dashboard/data";
vi.mock("@/lib/db", () => ({
db: {
project: {
count: vi.fn().mockResolvedValue(12),
findMany: vi.fn().mockResolvedValue([]),
},
},
}));
describe("getDashboardData", () => {
it("returns project count and recent projects", async () => {
const data = await getDashboardData({ teamId: "team-1" });
expect(data.projectCount).toBe(12);
expect(Array.isArray(data.recentProjects)).toBe(true);
});
});
Query Priority (RTL Philosophy)
RTL's query methods are listed in order of preference—queries higher in the list are more accessible:
1. getByRole → Best: matches what screen readers announce
2. getByLabelText → Good: form inputs with labels
3. getByPlaceholderText → OK: inputs with placeholder only
4. getByText → OK: visible text content
5. getByDisplayValue → OK: form values
6. getByAltText → OK: images
7. getByTitle → Avoid
8. getByTestId → Last resort: use data-testid sparingly
// ❌ Using data-testid (implementation detail)
screen.getByTestId("submit-button");
// ✅ Using role (what screen readers see)
screen.getByRole("button", { name: /submit/i });
// ❌ Querying by class name
document.querySelector(".error-message");
// ✅ Querying by accessible role
screen.getByRole("alert"); // errors, warnings
Cost and Timeline
| Task | Timeline | Cost (USD) |
|---|---|---|
| Test setup + MSW configuration | 0.5–1 day | $400–$800 |
| Component test suite (per component) | 0.5–1 day | $400–$800 |
| Accessibility audit + axe integration | 1–2 days | $800–$1,600 |
| Full test suite for a feature | 3–5 days | $2,400–$4,000 |
| Retroactive testing of existing codebase | 2–4 weeks | $8,000–$16,000 |
See Also
- Next.js Testing Strategy — E2E with Playwright + unit strategy
- TypeScript Error Handling — Type-safe error patterns to test against
- React Hook Form + Zod — Forms that are easier to test
- React Server Actions — Testing Server Actions and mutations
Working With Viprasol
We add comprehensive test coverage to React and Next.js codebases—from greenfield test architecture through retroactive testing of legacy components. Our team writes tests that catch real bugs, not just increase coverage numbers.
What we deliver:
- RTL test setup with MSW, Vitest, and jest-axe
- Component test suites following RTL best practices
- Accessibility testing integrated into CI
- Coverage reporting with actionable gaps
Explore our web development services or contact us to discuss testing your React codebase.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.