Back to Blog

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.

Viprasol Tech Team
January 14, 2027
14 min read

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

TaskTimelineCost (USD)
Test setup + MSW configuration0.5–1 day$400–$800
Component test suite (per component)0.5–1 day$400–$800
Accessibility audit + axe integration1–2 days$800–$1,600
Full test suite for a feature3–5 days$2,400–$4,000
Retroactive testing of existing codebase2–4 weeks$8,000–$16,000

See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol · Web Development

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.