"User-centric React component testing. Trigger: When testing React components with RTL."
Install
npx skillscat add joabgonzalez/ai-agents-skills/react-testing-library Install via the SkillsCat registry.
SKILL.md
React Testing Library Skill
Tests components the way users interact with them -- querying by accessible roles and text, not implementation details.
When to Use
- Testing React components from the user's perspective
- Simulating user interactions (clicks, typing, forms)
- Writing tests that survive internal refactors
Don't use for:
- Pure function or service logic (use jest skill)
- E2E multi-page flows (use Playwright or Cypress)
Critical Patterns
render + screen Queries
// CORRECT: use screen for all queries
render(<LoginForm />);
const button = screen.getByRole('button', { name: /submit/i });
// WRONG: destructuring queries from render
const { getByRole } = render(<LoginForm />);userEvent over fireEvent
import userEvent from '@testing-library/user-event';
// CORRECT: realistic user simulation
const user = userEvent.setup();
await user.type(screen.getByRole('textbox', { name: /email/i }), 'ada@test.com');
// WRONG: skips intermediate events
fireEvent.change(input, { target: { value: 'ada@test.com' } });Query Priority
Prefer: getByRole > getByLabelText > getByText > getByTestId.
// CORRECT: role query with accessible name
screen.getByRole('heading', { name: /welcome/i });
// WRONG: test-id as first resort
screen.getByTestId('welcome-heading');Async with findBy and waitFor
// CORRECT: findByRole waits for element to appear
await screen.findByRole('alert', { name: /success/i });
// WRONG: getByRole throws immediately if not in DOM
screen.getByRole('alert', { name: /success/i });Avoid Implementation Details
// CORRECT: assert on visible output
await user.click(screen.getByRole('button', { name: /add to cart/i }));
expect(screen.getByText(/1 item in cart/i)).toBeInTheDocument();
// WRONG: reaching into component internals
expect(wrapper.state('cartCount')).toBe(1);Asserting Absence
Use queryBy* (never getBy*) for negative DOM assertions — getBy* throws if absent, making .not assertions unreliable.
// ✅ CORRECT: queryBy* returns null when absent
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByText(/error/i)).toBeNull();
// After dismissing a modal:
await user.click(screen.getByRole('button', { name: /close/i }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// ❌ WRONG: getBy* throws before .not can evaluate
expect(screen.getByRole('alert')).not.toBeInTheDocument(); // always throwsSee unit-testing skill for the broader strategy of testing both presence and absence.
Decision Tree
- Element present now? ->
getByRole/getByText - Appears after async? ->
findByRole/findByText - Should NOT exist? ->
queryByRole(returns null) - User input? ->
userEvent.setup()thenuser.type(),user.click() - No accessible query? -> Add
aria-label;getByTestIdlast resort - Custom hook? ->
renderHook(() => useMyHook()) - Side effects? ->
waitFor(() => expect(...))
Example
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContactForm } from './ContactForm';
describe('ContactForm', () => {
it('should submit and show success', async () => {
const onSubmit = jest.fn().mockResolvedValue({ ok: true });
const user = userEvent.setup();
render(<ContactForm onSubmit={onSubmit} />);
await user.type(screen.getByRole('textbox', { name: /name/i }), 'Ada');
await user.type(screen.getByRole('textbox', { name: /email/i }), 'ada@test.com');
await user.click(screen.getByRole('button', { name: /send/i }));
expect(onSubmit).toHaveBeenCalledWith({ name: 'Ada', email: 'ada@test.com' });
expect(await screen.findByRole('alert')).toHaveTextContent(/thank you/i);
});
});Edge Cases
- Portals/modals: Use
screenqueries since portals render outside parent DOM - Async state: Wrap assertions in
waitForwhen state updates after await or setTimeout - Act warnings: Ensure async operations complete;
findBy*handles automatically - Providers: Create
renderWithProviderswrapper for context (theme, router, store) - Cleanup: RTL calls
cleanupautomatically with Jest; do not call manually
Checklist
- Queries follow priority: role > label > text > testId
-
userEvent.setup()used instead offireEvent - Async elements use
findBy*orwaitFor, never manual delays - No test inspects internal state, props, or instances
-
renderWithProviderswraps components needing context - Every interactive element has an accessible name