Historically, component testing in the React ecosystem relied on libraries like Enzyme, which promoted a shallow rendering methodology and direct inspection of component internals: checking state (wrapper.state()), inspecting the props passed to child nodes, or calling private instance methods.
This approach carried a fundamental architectural flaw — tight coupling between tests and implementation details. When refactoring a component (for example, migrating from a class component to hooks, splitting a monolithic hook into several sub-hooks, or changing the internal DOM tree structure), tests would fail even when the visual behavior and the public contract of the component remained 100% correct. This created the problem of false positives, which undermined the value of automation.
Behavior-Driven Testing lies at the core of the React Testing Library (RTL) philosophy, articulated by Kent C. Dodds:
«The more your tests resemble the way your software is used, the more confidence they can give you.»
In the standards of modern web development and React 19, the interface is tested at the intersection of the DOM tree and the Accessibility Tree (AOM). This guarantees that a passing test validates not only business logic but also the digital accessibility (a11y) of the interface for all categories of users.
In the React Testing Library paradigm, a component is mounted into a real or virtual DOM environment (jsdom or happy-dom). The test runner (Vitest 4+) has no direct access to the internal closure variables of React Fiber, useRef references, or the internal state of useState.
Instead, the test interacts with the interface through two key channels:
@testing-library/user-event library.+-----------------------------------------------------------------------+
| User / Screen Reader |
+-----------------------------------------------------------------------+
│
▼ (Perception via UI & ARIA)
+-----------------------------------------------------------------------+
| Accessibility Tree (AOM) & DOM Tree |
| - role: "button", name: "Checkout" |
| - role: "textbox", name: "Email address" |
| - role: "status", aria-live: "polite" |
+-----------------------------------------------------------------------+
▲
│ (RTL semantic selectors)
+-----------------------------------------------------------------------+
| React Testing Library (screen.getByRole / findBy) |
+-----------------------------------------------------------------------+
▲
│ (Isolation: black-box render)
+-----------------------------------------------------------------------+
| React 19 Component (Fiber / Hooks / Private State) |
+-----------------------------------------------------------------------+When writing tests, it is critical to classify the architectural elements of a component:
| Category | Implementation Details (DON'T TEST) | Public Contract (DO TEST) |
| :--- | :--- | :--- |
| State | Variable names in useState, hook call order | Text that appears in the DOM when state is toggled |
| Handlers | Function names like handleFormSubmit, onClickInternal | The system's reaction to a physical button click |
| Markup | Nested <div> wrappers, Tailwind utility classes | The semantic <button> tag or <form aria-label="..."> |
| Queries | Axios client instance or global.fetch reference | UI change upon receiving an HTTP response or error |
RTL enforces a strict element-selection priority. Lower-priority selectors should only be used when the semantic capabilities of the WAI-ARIA standard have been exhausted.
+-----------------------------------------------------------------------+
| PRIORITY 1: Accessible to Everyone |
| ├── getByRole(role, { name, pressed, expanded, hidden }) |
| ├── getByLabelText(text, { selector }) |
| ├── getByPlaceholderText(text) |
| ├── getByText(text, { selector }) |
| └── getByDisplayValue(value) |
+-----------------------------------------------------------------------+
│
▼
+-----------------------------------------------------------------------+
| PRIORITY 2: Semantic Visual Attributes |
| ├── getByAltText(altText) |
| └── getByTitle(titleText) |
+-----------------------------------------------------------------------+
│
▼
+-----------------------------------------------------------------------+
| PRIORITY 3: Last Resort (Escape Hatch) |
| └── getByTestId('checkout-submit-btn') |
+-----------------------------------------------------------------------+get, query, find#Each selector exists in 6 variants, determining synchronicity and behavior on absence or multiple matches:
| Prefix | 0 matches | 1 match | 2+ matches | Sync | Typical Use |
| :--- | :--- | :--- | :--- | :--- | :--- |
| getBy* | Throws | Returns node | Throws | Sync | Finding an existing element |
| queryBy* | null | Returns node | Throws | Sync | Asserting element absence |
| findBy* | Throws (Timeout) | Returns node | Throws | Promise (async) | Awaiting async node appearance |
| getAllBy* | Throws | Array [node] | Array [node1, ...] | Sync | Selecting a list of elements |
| queryAllBy* | Empty array [] | Array [node] | Array [node1, ...] | Sync | Asserting group absence |
| findAllBy* | Throws (Timeout) | Array [node] | Array [node1, ...] | Promise (async) | Awaiting a list to render |
screen Object and Debug Utilities#Instead of destructuring methods from the render() result, the standard prescribes using the global screen object, bound to document.body:
import { render, screen, logRoles } from '@testing-library/react';
// Render the tree
const { container } = render(<CheckoutForm />);
// Print current DOM state to the test runner console
screen.debug();
// Print available ARIA semantic roles for the given container
logRoles(container);getByRole and Accessible Name Computation#When querying with getByRole, the second argument is an options object with a name field. The Accessible Name is computed by the browser according to the W3C specification: from text content, the aria-label attribute, or an aria-labelledby association.
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState } from 'react';
interface OrderItem {
readonly id: string;
readonly title: string;
readonly price: number;
}
interface OrderProps {
readonly items: readonly OrderItem[];
readonly onCheckout: (total: number) => void;
}
function OrderSummary({ items, onCheckout }: OrderProps) {
const [promoApplied, setPromoApplied] = useState(false);
const total = items.reduce((sum, item) => sum + item.price, 0);
const finalPrice = promoApplied ? total * 0.9 : total;
return (
<section aria-labelledby="order-heading">
<h2 id="order-heading">Order Summary</h2>
<ul aria-label="Item list">
{items.map((item) => (
<li key={item.id}>
<span>{item.title}</span> — <span>{item.price} USD</span>
</li>
))}
</ul>
<label>
<input
type="checkbox"
checked={promoApplied}
onChange={(e) => setPromoApplied(e.target.checked)}
/>
Apply 10% discount
</label>
<div role="status" aria-live="polite">
Total due: {finalPrice} USD
</div>
<button
type="button"
disabled={items.length === 0}
onClick={() => onCheckout(finalPrice)}
>
Confirm and Pay
</button>
</section>
);
}
describe('OrderSummary Component', () => {
const mockItems: readonly OrderItem[] = [
{ id: '1', title: 'React 19 Architecture Guide', price: 49 },
{ id: '2', title: 'Vitest Deep Dive', price: 39 },
];
it('correctly finds elements via the ARIA tree and calculates the total price', async () => {
const user = userEvent.setup();
const handleCheckout = vi.fn();
render(<OrderSummary items={mockItems} onCheckout={handleCheckout} />);
// 1. Find structural headings and lists by role and accessible name
expect(
screen.getByRole('region', { name: 'Order Summary' })
).toBeInTheDocument();
expect(
screen.getByRole('list', { name: 'Item list' })
).toBeInTheDocument();
// 2. Find interactive form elements
const discountCheckbox = screen.getByRole('checkbox', {
name: /apply 10% discount/i,
});
expect(discountCheckbox).not.toBeChecked();
// 3. Verify the dynamic status region
expect(screen.getByRole('status')).toHaveTextContent('Total due: 88 USD');
// 4. Interact via user event
await user.click(discountCheckbox);
expect(discountCheckbox).toBeChecked();
expect(screen.getByRole('status')).toHaveTextContent('Total due: 79.2 USD');
// 5. Activate the checkout button
const submitBtn = screen.getByRole('button', {
name: /confirm and pay/i,
});
await user.click(submitBtn);
expect(handleCheckout).toHaveBeenCalledTimes(1);
expect(handleCheckout).toHaveBeenCalledWith(79.2);
});
});pressed, expanded)#Accordion, dropdown, and toggle-button components must communicate their state to assistive technologies via the aria-expanded, aria-pressed, and aria-selected attributes. RTL allows filtering queries by these attributes.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState, useId } from 'react';
interface CollapsibleProps {
readonly title: string;
readonly children: React.ReactNode;
}
function CollapsibleFilter({ title, children }: CollapsibleProps) {
const [isExpanded, setIsExpanded] = useState(false);
const panelId = useId();
return (
<div className="filter-wrapper">
<button
type="button"
aria-expanded={isExpanded}
aria-controls={panelId}
onClick={() => setIsExpanded((prev) => !prev)}
>
{title}
</button>
{isExpanded && (
<div id={panelId} role="region" aria-label={title}>
{children}
</div>
)}
</div>
);
}
describe('CollapsibleFilter Accessibility States', () => {
it('synchronizes aria-expanded with the physical visibility of the content', async () => {
const user = userEvent.setup();
render(
<CollapsibleFilter title="Product Categories">
<p>Electronics, Books, Software</p>
</CollapsibleFilter>
);
// Initial state: button is collapsed
const toggleButton = screen.getByRole('button', {
name: 'Product Categories',
expanded: false,
});
expect(toggleButton).toBeInTheDocument();
// Content region is absent from the DOM
expect(
screen.queryByRole('region', { name: 'Product Categories' })
).not.toBeInTheDocument();
// Expand the panel
await user.click(toggleButton);
// Verify ARIA state changed to expanded: true
expect(
screen.getByRole('button', {
name: 'Product Categories',
expanded: true,
})
).toBeInTheDocument();
// Verify the region is now visible
expect(
screen.getByRole('region', { name: 'Product Categories' })
).toHaveTextContent('Electronics, Books, Software');
});
});queryBy* vs. waitForElementToBeRemoved#A common beginner mistake is using getBy* to assert a node's disappearance. The getBy* family immediately throws a fatal exception. Use queryBy* for synchronous absence checks, and waitForElementToBeRemoved to track asynchronous disappearance.
import { describe, it, expect } from 'vitest';
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState } from 'react';
function DismissibleBanner({ message }: { readonly message: string }) {
const [visible, setVisible] = useState(true);
if (!visible) return null;
return (
<aside role="alert">
<span>{message}</span>
<button
type="button"
aria-label="Dismiss notification"
onClick={() => setVisible(false)}
>
×
</button>
</aside>
);
}
describe('DismissibleBanner LifeCycle', () => {
it('correctly removes the alert from the DOM when the close button is clicked', async () => {
const user = userEvent.setup();
render(<DismissibleBanner message="Your session expires in 5 minutes" />);
// 1. Confirm the alert is present via getByRole
const alertElement = screen.getByRole('alert');
expect(alertElement).toHaveTextContent('Your session expires in 5 minutes');
const closeBtn = screen.getByRole('button', {
name: 'Dismiss notification',
});
// 2. Initiate dismissal
await user.click(closeBtn);
// 3. Synchronous absence check: queryByRole returns null
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// 4. Alternative for async animations/transitions:
// await waitForElementToBeRemoved(() => screen.queryByRole('alert'));
});
});logRoles#When a test fails with «Unable to find an accessible element with the role...», the logRoles utility lets you print the computed AOM tree to understand how the element is perceived by assistive technologies.
import { describe, it, expect } from 'vitest';
import { render, screen, logRoles } from '@testing-library/react';
import React from 'react';
function UserNavigation() {
return (
<header>
<nav aria-label="Main menu">
<a href="/profile">My Profile</a>
<a href="/settings">Settings</a>
<button type="button" aria-label="Sign out">
<svg aria-hidden="true" focusable="false"><path d="M0 0h24v24H0z" /></svg>
</button>
</nav>
</header>
);
}
describe('UserNavigation Diagnostic', () => {
it('prints the roles tree for precise selector tuning', () => {
const { container } = render(<UserNavigation />);
// Logs the role structure to stdout:
// banner:
// navigation "Main menu":
// link "My Profile"
// link "Settings"
// button "Sign out"
logRoles(container);
// Precise selector based on the log
expect(screen.getByRole('navigation', { name: 'Main menu' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument();
});
});Using container.querySelector('.btn-primary') or container.getElementsByTagName('input') tightly couples the test to presentation styles and markup. If a designer replaces the class with a Tailwind utility or wraps the button in an auxiliary <span>, the test fails even though the component continues to work correctly.
// ❌ ANTIPATTERN: Coupled to a styling CSS class
const button = container.querySelector('.submit-button-active');
expect(button).toBeDefined();
// ✅ SOLUTION: Query by semantic role and accessible name
const button = screen.getByRole('button', { name: /save/i });
expect(button).toBeInTheDocument();The data-testid attribute is invisible to users and screen readers. If a developer accidentally removes the text from a button or breaks keyboard focus, a test using getByTestId will continue to pass, letting a critical accessibility defect through to production.
// ❌ ANTIPATTERN: Hides semantic and accessibility defects
render(<div onClick={handleClick} data-testid="fake-submit">Submit</div>);
const el = screen.getByTestId('fake-submit'); // Test passes, but this is not a button!
// ✅ SOLUTION: Forces the use of accessible HTML
render(<button type="submit">Submit</button>);
const el = screen.getByRole('button', { name: /submit/i });The getBy* family immediately throws a TestingLibraryElementError if the element is not found in the DOM. The construct expect(screen.getByText('Error')).not.toBeInTheDocument() will crash at the getByText call itself, never reaching the expect matcher.
// ❌ ANTIPATTERN: Test fails with an unhandled exception in getBy
expect(screen.getByText('Authorization error')).not.toBeInTheDocument();
// ✅ SOLUTION: queryBy returns null when the node is absent
expect(screen.queryByText('Authorization error')).not.toBeInTheDocument();Attempts to access internal variables via wrapper.state() (an Enzyme habit) or inspecting _reactFiber strip the test of refactoring resilience. Only changes in the rendered DOM should be tested.
// ❌ ANTIPATTERN: Inspects private component state
expect(componentInstance.state.isOpen).toBe(true);
// ✅ SOLUTION: Tests the UI's response to the state change
expect(screen.getByRole('dialog', { name: /edit/i })).toBeVisible();Determine the element's semantic role in the Accessibility Tree: button, textbox, heading, link, dialog, status, listbox.
Determine how the element is announced by a screen reader: text inside the tag, aria-label, an h1-h6 heading, or an associated label with htmlFor.
Use getByRole to assert presence, queryByRole to assert absence, and findByRole for async waiting.
Add options to check interactive state: { expanded: true, pressed: false, hidden: false }.
Only if the node is a purely dynamic canvas or a complex SVG, use data-testid.