Testing React: RTL & MSW
โฑ๏ธ ~4-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ฐ RTL testing = testing a vending machine as a customer (press button, get snack) โ not as an engineer (check internal gears). Test what users see and do, not implementation details.
Implementation testing (fragile)
Query by CSS class, check internal state, mock module internals, assert function call signatures. Breaks on refactor even when behavior is unchanged.
| 1 | querySelector('.btn-primary') |
| 2 | expect(component.state.count) |
| 3 | jest.mock('./useCounter') |
Behavior testing (durable)
Query by ARIA role, simulate real user events, mock at the network boundary (MSW). Survives refactors that don't change observable behavior.
| 1 | getByRole('button') |
| 2 | await user.click(...) |
| 3 | msw: http.get('/api/...') |
The query priority (highest to lowest)
1. getByRole โ ARIA role + accessible name. Always prefer.
2. getByLabelText โ for form inputs with labels.
3. getByPlaceholderText โ when there's no label.
4. getByText โ for non-interactive text content.
5. getByDisplayValue โ for current value of select/input.
6. getByTitle โ last resort for title attribute.
Interactive Sandbox
โ Move something, see it react instantly.Pattern
Key queries
getByRole, getByLabelText, getByText
| 1 | // โ Query by implementation detail โ fragile tests |
| 2 | const { container } = render(<Button />); |
| 3 | const btn = container.querySelector('.btn-primary'); // CSS class |
| 4 | const btn2 = container.querySelector('[data-testid="submit"]'); // test ID |
| 5 | ย |
| 6 | // Problems: |
| 7 | // 1. Refactor CSS class โ test breaks (but feature still works) |
| 8 | // 2. data-testid = production code written for tests (smell) |
| 9 | // 3. Tests what developers see, not what users see |
Challenge
Toggle between bad and good code for all 5 patterns. Understand what each anti-pattern gets wrong.
Why Should I Care?
โ The exact interview question + the bug it kills.Interview questions
Q: Why should you not query by CSS class or test ID?
CSS classes are implementation details. Renaming a class for a design refactor breaks tests, even though the feature still works for users. Test IDs (data-testid) add production attributes that have no semantic meaning โ it's test-pollution in production HTML. ARIA roles test what users and screen readers actually experience: the semantic meaning, not the styling.
Q: What is the difference between getBy, queryBy, and findBy?
| 1 | // getBy* โ throws if not found, throws if multiple found |
| 2 | const btn = screen.getByRole('button'); // synchronous |
| 3 | ย |
| 4 | // queryBy* โ returns null if not found (use for absence checks) |
| 5 | const error = screen.queryByText(/error/i); |
| 6 | expect(error).not.toBeInTheDocument(); // test it's absent |
| 7 | ย |
| 8 | // findBy* โ returns Promise, polls until found (or timeout) |
| 9 | const name = await screen.findByText('Alice'); // async |
| 10 | // Use after async operations (fetch, timer, state update) |
Q: Why does RTL recommend userEvent over fireEvent?
fireEvent dispatches a single browser event. Real users trigger a sequence: typing fires focus + multiple keydown/keypress/keyup/input/change events. Clicking fires mouseover + mouseenter + mousemove + mousedown + focus + mouseup + click. Components that rely on any of these events (e.g., onBlur validation, pointer-based disabled logic) behave incorrectly when tested with fireEvent. userEvent simulates the full sequence.
The Deep Dive
โ Spec refs, engine internals, the minutiae.Testing Library philosophy
Kent C. Dodds' core principle: "The more your tests resemble the way your software is used, the more confidence they can give you." Tests that query by ARIA role, simulate real user events, and mock at the network layer are the most likely to catch real bugs and the least likely to break on refactors.
Testing portals and context-wrapped components
| 1 | // Testing a component that uses React portals |
| 2 | // Portal renders outside the component tree โ screen still finds it |
| 3 | render(<Modal />); |
| 4 | await user.click(screen.getByRole('button', { name: /open/i })); |
| 5 | // Modal content renders in document.body via portal |
| 6 | expect(screen.getByRole('dialog')).toBeInTheDocument(); |
| 7 | ย |
| 8 | // Testing a component that needs context |
| 9 | function renderWithProviders(ui: ReactElement) { |
| 10 | return render( |
| 11 | <QueryClientProvider client={queryClient}> |
| 12 | <ThemeProvider> |
| 13 | {ui} |
| 14 | </ThemeProvider> |
| 15 | </QueryClientProvider> |
| 16 | ); |
| 17 | } |
| 18 | // Use everywhere: renderWithProviders(<UserCard userId="1" />) |
Playwright component testing
Playwright now supports component testing (`@playwright/experimental-ct-react`). Components render in a real browser context โ Chromium, Firefox, or WebKit. This catches browser-specific bugs that jsdom misses (CSS layout, native form behavior, Web APIs). The trade-off: slower than jsdom. Use Playwright component tests for components with complex browser-dependent behavior; use RTL for everything else.
Accessibility testing with @testing-library/jest-dom
| 1 | import '@testing-library/jest-dom'; |
| 2 | ย |
| 3 | // Custom matchers from jest-dom |
| 4 | expect(btn).toBeInTheDocument(); |
| 5 | expect(btn).toBeVisible(); |
| 6 | expect(btn).toBeEnabled(); |
| 7 | expect(btn).toHaveFocus(); |
| 8 | expect(btn).toHaveAccessibleName('Submit Order'); |
| 9 | expect(input).toHaveValue('test@email.com'); |
| 10 | expect(form).toHaveFormValues({ email: 'test@email.com' }); |
| 11 | ย |
| 12 | // Accessibility violation check with jest-axe |
| 13 | import { axe, toHaveNoViolations } from 'jest-axe'; |
| 14 | expect.extend(toHaveNoViolations); |
| 15 | ย |
| 16 | test('has no accessibility violations', async () => { |
| 17 | const { container } = render(<Form />); |
| 18 | const results = await axe(container); |
| 19 | expect(results).toHaveNoViolations(); |
| 20 | }); |
Interview Questions
โ Real questions from real interviews โ with answers.getByRole tests what screen readers and users actually experience โ ARIA semantics โ not implementation details.
getBy throws if missing (sync); queryBy returns null if missing (sync); findBy returns a Promise and waits.
userEvent fires the full browser event sequence; fireEvent fires a single event, missing blur, keydown, pointer events that real components rely on.
MSW intercepts at the network layer โ your component makes real fetch() calls; MSW intercepts them like a real server would.
renderHook mounts the hook in a minimal React component; you call hook methods inside act() and assert on result.current.
A wrapper function that renders any component inside all required Providers โ prevents Provider-missing errors in tests for context-dependent components.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What role does `screen` play in modern Testing Library usage?