Skip to content

Top 34 React Testing Library Interview Questions and Answers [2026]

November 11, 2026 28 min read
Testing Library interview guide by Second Talent
TL;DR: Testing Library interviews in 2026 test whether a candidate queries the DOM the way a user perceives it, awaits every interaction, and can explain an act() warning instead of silencing it. Candidates should know @testing-library/react 16.3.3 (August 27, 2026) on React 19.3, user-event 14.6.8, jest-dom 7, MSW 3 for network mocking, and how the same tests behave under Vitest 5 and Jest 30.

@testing-library/react 16.3.3 fixed a case where dispatching an event from inside an event handler re-entered act(), one of the sources of confusing act warnings in React 19 test suites.

The current release notes are on the React Testing Library GitHub releases page. The questions below are for senior frontend, React and QA automation interviews, and include four find-the-bug rounds on real test code.

Key takeaways
  1. 1waitFor and findBy* give up after 1,000 ms by default and retry every 50 ms, plus on each DOM mutation.
  2. 2React 19 moved act to the react package, and RTL's legacyRoot option works only on React 18 and earlier.
  3. 3MSW 3.0 (September 28, 2026) is ESM-only, needs Node.js 22, and renames onUnhandledRequest to onUnhandledFrame.
  4. 4jest-dom 7.0.0 makes @testing-library/dom a required peer dependency and adds scoped toContainOneBy* and toContainAnyBy* matchers.

Queries and Guiding Principles

1. What is the guiding principle of Testing Library, and how does it change the tests you write?

"The more your tests resemble the way your software is used, the more confidence they can give you."

In practice that means a test renders a component, finds elements by what a user sees or hears (role, accessible name, label, text), interacts the way a user would, and asserts on visible output.

It does not reach into component state, call instance methods or check which hook ran.

The payoff is refactor resistance. A test that finds the submit button by getByRole('button', {name: /save/i}) survives a rewrite from class components to hooks, a CSS module change and a move to a design system button.

A strong candidate adds the second-order benefit: if a query by role cannot find the element, the markup probably has an accessibility problem too, so the test doubles as a cheap a11y check.

2. In what order should you reach for queries?

The queries documentation sets a priority in three groups:

  • Accessible to everyone: getByRole first, then getByLabelText (best for form fields), getByPlaceholderText, getByText for non-interactive content, and getByDisplayValue.
  • Semantic queries: getByAltText and getByTitle. The docs warn that the title attribute is read inconsistently by screen readers.
  • Test IDs: getByTestId, for cases where neither role nor text can identify the element.

A weak answer says "I use test IDs because they are stable."

A strong one explains that placeholder text is a fallback because a placeholder is not a label, and names legitimate uses for test IDs: a canvas chart, a third-party widget with no roles, or a container that only exists for layout.

Setting configure({throwSuggestions: true}) makes the library throw when a better query exists, which is a useful tool in a codebase migrating off test IDs.

3. What is the difference between getBy, queryBy and findBy?

They differ in what happens with zero matches and whether they retry:

  • getBy* returns the element, and throws on zero or more than one match. Use it for elements that must already be there.
  • queryBy* returns null on zero matches, and still throws on more than one. Use it only to assert absence.
  • findBy* returns a promise that retries a getBy* until it passes or times out. Use it for elements that appear after async work.

The *AllBy* variants return arrays and do not throw on multiple matches. Interviewers often ask the follow-up: why does queryBy* throw on multiple matches?

Because a query that silently returned the first of several matches would hide ambiguous markup, and an absence check that passes because the wrong element was found is worse than a failure.

4. Why prefer screen over the queries returned by render?

screen exposes every query bound to document.body, so the test does not need to destructure render's return value and keep that list in sync as queries change.

It reads closer to what a user sees: the whole page, not the component's container. Portals, modals and toasts that render outside the component's container are found by screen and missed by container-bound queries.

render's result still matters for rerender, unmount and container.

The docs note that screen needs a global DOM environment, such as Jest's jsdom test environment or Vitest's environment: 'jsdom'. screen.debug() prints the DOM, and screen.logTestingPlaygroundURL() gives a link that suggests queries for the current markup.

5. How does getByRole compute the accessible name, and what does the hidden option do?

getByRole matches an element's implicit or explicit ARIA role, and the name option matches its accessible name as computed from aria-labelledby, aria-label, an associated <label>, text content or alt, depending on the element.

A button with only an SVG icon has no name unless it has an aria-label, so getByRole('button', {name: 'Close'}) fails, which is the right result.

By default, getByRole excludes elements hidden from the accessibility tree: display: none, visibility: hidden, the hidden attribute and aria-hidden="true". Passing hidden: true includes them.

Other options worth knowing are level for headings, checked, pressed, selected and expanded for state.

A senior candidate also mentions cost: getByRole walks the tree and computes styles, so on DOMs with thousands of nodes it is the slowest query, and scoping it with within keeps suites fast.

6. When do you use within?

within(element) returns the full query set bound to that element.

Use it when the same text or role appears more than once on the page: a table where each row has a "Delete" button, a list of cards each with a "Buy" link, or a dialog that repeats a label from the page behind it.

const row = screen.getByRole('row', {name: /invoice 1042/i})
await user.click(within(row).getByRole('button', {name: /delete/i}))

const dialog = await screen.findByRole('dialog', {name: /confirm delete/i})
expect(within(dialog).getByText(/cannot be undone/i)).toBeInTheDocument()

Without within, candidates fall back to getAllByRole('button')[3], which breaks the moment rows are re-sorted. That index is a good thing to flag in a code review question.

7. How do text matchers work, and what do exact and normalizer change?

Text queries accept a string, a regex or a function. A string matches the full, trimmed text with whitespace collapsed, and is case-sensitive by default. {exact: false} switches to a case-insensitive substring match.

A regex such as /order placed/i is the usual middle ground: case-insensitive and partial, but explicit about it.

The default normalizer trims and collapses whitespace; a custom normalizer can also strip non-breaking spaces or currency formatting.

A function matcher (content, element) => ... handles text split across elements, such as <p>Total: <strong>$40</strong></p>, where getByText('Total: $40') fails because no single element has that exact text content.

Strong candidates reach for a role or label query before writing a function matcher.

User Interaction

8. Why use user-event instead of fireEvent?

fireEvent.click dispatches one click event. user.click from @testing-library/user-event simulates the sequence a browser produces: pointer moves, pointerdown, mousedown, focus change, pointerup, mouseup, click. user.type clicks the field, then fires key events and input events per character, respecting maxLength, disabled state and selection.

That difference catches real bugs. A button covered by an overlay with pointer-events: none on an ancestor fails with user-event and passes with fireEvent.

A form that validates on blur gets its blur from user-event when focus moves. fireEvent still has a place for events user-event does not model, such as scroll, a media element's ended event, or a custom event dispatched by a library.

9. How should user-event be set up in a test?

Call userEvent.setup() before rendering, and await every interaction.

Since v14 every API returns a promise, and setup() creates one instance that shares keyboard and pointer state across calls, so held modifier keys and the current pointer position carry over the way they would for a real user.

import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'

function setup(ui) {
  return {user: userEvent.setup(), ...render(ui)}
}

test('saves the profile', async () => {
  const {user} = setup(<ProfileForm />)
  await user.type(screen.getByLabelText(/display name/i), 'Ada')
  await user.click(screen.getByRole('button', {name: /save/i}))
  expect(await screen.findByText(/profile saved/i)).toBeInTheDocument()
})

Per the user-event options page, delay defaults to 0, which still yields to the event loop between inputs, and writeToClipboard defaults to true for setup() instances because setup stubs navigator.clipboard.

10. How do you use user-event with fake timers?

Pass the runner's advance function as advanceTimers. user-event waits between inputs with setTimeout, so under fake timers that wait never resolves and the test times out.

// Jest
jest.useFakeTimers()
const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime})

// Vitest
vi.useFakeTimers()
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime})

The docs warn against the old workaround of delay: null, which skips the timeout and "may cause unexpected behaviour"; advanceTimers has been the supported option since v14.1.

A strong candidate also restores real timers in afterEach, because a suite that leaks fake timers into the next file produces failures nobody can reproduce in isolation.

11. How do you test keyboard navigation and focus?

Use user.tab(), user.keyboard() and assert with toHaveFocus(). user.keyboard takes a descriptor string: '{Enter}', '{Shift>}{Tab}{/Shift}' for a held modifier, '{ArrowDown}' for menus and listboxes.

const user = userEvent.setup()
render(<Menu />)
await user.click(screen.getByRole('button', {name: /options/i}))
await user.keyboard('{ArrowDown}')
expect(screen.getByRole('menuitem', {name: /rename/i})).toHaveFocus()
await user.keyboard('{Escape}')
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(screen.getByRole('button', {name: /options/i})).toHaveFocus()

The last assertion, focus returning to the trigger, is the one weak candidates forget, and it is where most hand-rolled menus and dialogs fail an accessibility audit. jsdom does not do layout, so tests that depend on scrollIntoView or element visibility by geometry belong in a real browser (question 28).

12. How do you test a file upload?

Use user.upload(input, file) with a File object, and query the input by its label. user-event sets input.files, fires input and change, and by default (applyAccept: true) drops files that do not match the input's accept attribute.

const file = new File(['id,amount\n1,40'], 'payouts.csv', {type: 'text/csv'})
const input = screen.getByLabelText(/upload csv/i)
await user.upload(input, file)
expect(input.files[0]).toBe(file)
expect(screen.getByText('payouts.csv')).toBeInTheDocument()

A follow-up worth asking: how do you test that a PNG is rejected by an input with accept=".csv"? With the default option, user-event filters the file out before the change event, so the component's own rejection message never renders.

The candidate should either pass {applyAccept: false} to setup() to exercise the component's validation or assert that input.files is empty.

Async Utilities and act()

13. How does waitFor work, and what are its defaults?

waitFor(callback) runs the callback once immediately, then again every 50 ms and on every DOM mutation, until it stops throwing or the timeout passes.

The async API docs give the defaults: timeout 1000 ms (from the global asyncUtilTimeout config), interval 50 ms, and a MutationObserver watching subtree, child list, attributes and character data.

Two details trip up candidates. A callback that returns false does not trigger a retry; only a thrown error does, so waitFor(() => mock.mock.calls.length === 1) passes at once.

And on timeout, the error message includes the printed DOM, which is the first place to look when a CI run fails.

14. What should and should not go inside a waitFor callback?

Put one assertion inside, and keep side effects outside. The callback can run dozens of times, so a click or a fetch inside it repeats on every retry.

  • One assertion per waitFor. With several, the test waits for all of them and a failure reports only the first, which slows feedback.
  • No side effects. Interactions go before the waitFor.
  • Not empty. await waitFor(() => {}) passes on the first tick and only hides an act warning.
  • Prefer findBy* when the thing being waited for is an element: await screen.findByRole('alert') replaces await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()).

waitFor stays the right tool for non-DOM conditions, such as a mock having been called with specific arguments.

15. How do you assert that something disappears?

Use waitForElementToBeRemoved for elements that are present and will go away, such as a spinner, and queryBy* with not.toBeInTheDocument() for elements that should never appear.

render(<Orders />)
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'))
expect(screen.getAllByRole('row')).toHaveLength(4)

Per the docs, waitForElementToBeRemoved throws if the element is already gone, or if its argument is null or an empty array, when it is called. That makes it fail loudly when a spinner never rendered.

The callback form re-queries on each check, which avoids holding a stale reference to a node React has already replaced.

16. What does "An update to X inside a test was not wrapped in act(...)" mean, and how do you fix it?

React detected a state update that happened outside an act() scope while the test environment says act is expected.

In a Testing Library test, render, fireEvent, user-event calls and the async utilities already run inside act, so the warning almost always means an update landed after the test stopped waiting: a fetch resolved, a timer fired, or a promise chain settled after the last assertion.

The fix is to wait for the result of that update, not to wrap more code in act:

  • Await the UI the update produces: await screen.findByText(/loaded/i).
  • Under fake timers, advance timers inside act or let user-event advance them.
  • If the update is a side effect the test does not care about, mock the source so it resolves predictably, then wait for its visible result.

Weak answers wrap the whole test in act(async () => ...) or add await waitFor(() => {}). Both silence the warning and leave a test that asserts on a half-updated UI.

17. What is IS_REACT_ACT_ENVIRONMENT, and who sets it?

It is a global flag that tells React whether it runs in a test environment that uses act.

When it is true, React warns about unwrapped updates; when it is not set and code calls act, React warns that "the current testing environment is not configured to support act(...)".

React Testing Library sets it to true in a beforeAll hook when the runner exposes global beforeAll and afterAll, and restores the previous value afterwards.

Its asyncWrapper sets it to false while waitFor runs, because polling the DOM from outside act is the point of waitFor.

A candidate who knows this can explain why a custom test harness without globals, or code that calls act from react outside RTL, suddenly prints environment warnings, and set globalThis.IS_REACT_ACT_ENVIRONMENT = true in the setup file as the fix.

18. Why can waitFor hang under Vitest fake timers but not under Jest's?

DOM Testing Library only advances fake timers itself when it detects Jest's: the check looks for a global jest object and a faked setTimeout.

When it finds them, waitFor loops, calling jest.advanceTimersByTime(interval) inside act between checks.

Under Vitest there is no jest global, so waitFor takes the real-timers path, and its 50 ms setInterval and its overall timeout are themselves faked. If no DOM mutation arrives, the callback never re-runs and the timeout never fires.

Fixes a candidate should offer:

  • vi.useFakeTimers({shouldAdvanceTime: true}), so fake time moves with real time.
  • Advance time explicitly with await vi.advanceTimersByTimeAsync(ms) before asserting, and assert with getBy* instead of polling.
  • Fake only the timers the code under test uses, with the toFake option.

19. Find the bug: a test that passes locally and flakes in CI.

test('shows search results', async () => {
  const user = userEvent.setup()
  render(<Search />)
  user.type(screen.getByRole('searchbox'), 'laptop')
  await waitFor(() => {
    user.click(screen.getByRole('button', {name: /search/i}))
    expect(screen.getByText(/3 results/i)).toBeInTheDocument()
  })
})

Bugs:

  • user.type is not awaited. Typing is async in v14, so the click can run before all six characters are in the field. Locally the machine is fast enough; in CI it is not.
  • A side effect inside waitFor. The click runs on every retry, so the component may fire several searches, and the test passes on whichever response lands first.
  • Unawaited click. The click promise is dropped, so its errors surface as unhandled rejections or act warnings in a later test.

Fix: await user.type(...), await user.click(...), then expect(await screen.findByText(/3 results/i)).toBeInTheDocument(). The eslint-plugin-testing-library rules await-async-events and no-wait-for-side-effects catch both patterns.

React 19 Behaviour

20. What changed for tests when a codebase moved to React 19?

The React 19 upgrade guide lists the testing changes:

  • act moved from react-dom/test-utils to react. The old import logs a deprecation warning, and the codemod is npx codemod@latest react/19/replace-act-import. In RTL tests, import act from @testing-library/react, which wraps React's.
  • All other react-dom/test-utils functions were removed, including Simulate and renderIntoDocument.
  • react-test-renderer is deprecated and switched to concurrent rendering; the guide recommends migrating to Testing Library.
  • Render errors are not re-thrown. Uncaught errors go to window.reportError, and errors caught by a boundary go to console.error.

On the RTL side, React 19 support arrived in 16.1.0 (December 5, 2024), and render always uses createRoot; the legacyRoot option only works when tests run with React 18 or earlier.

21. How do you test an error boundary in React 19?

Render a child that throws inside the boundary, assert on the fallback UI, and handle the logging deliberately.

Since 16.2.0, RTL's render accepts onCaughtError and onRecoverableError, which behave like the createRoot options of the same name (see the RTL API docs).

function Boom() {
  throw new Error('price feed down')
}

test('shows the fallback and reports the error', () => {
  const onCaughtError = vi.fn()
  render(
    <ErrorBoundary fallback={<p role="alert">Prices unavailable</p>}>
      <Boom />
    </ErrorBoundary>,
    {onCaughtError},
  )
  expect(screen.getByRole('alert')).toHaveTextContent('Prices unavailable')
  expect(onCaughtError).toHaveBeenCalledWith(
    expect.objectContaining({message: 'price feed down'}),
    expect.anything(),
  )
})

Passing onCaughtError replaces React's default reporting, so the expected error no longer spams console.error. A strong candidate prefers that over a blanket console.error mock, which also hides unrelated warnings in the same test.

RTL 16.3.2 fixed the TypeScript inference for onCaughtError with React 19 types.

22. How do you test a component that suspends with use()?

Render it inside a Suspense boundary, pass a promise you control, and use findBy* for the resolved content.

The promise must be created outside the component, or by a cache or framework, because use with a promise created during render creates a new promise on each render.

function Plan({planPromise}) {
  const plan = use(planPromise)
  return <h2>{plan.name}</h2>
}

test('shows the plan after loading', async () => {
  const planPromise = Promise.resolve({name: 'Team'})
  render(
    <Suspense fallback={<p>Loading plan</p>}>
      <Plan planPromise={planPromise} />
    </Suspense>,
  )
  expect(await screen.findByRole('heading', {name: 'Team'})).toBeInTheDocument()
})

Do not assert that the fallback appeared with a getBy* right after render unless the test controls when the promise resolves, because whether the fallback is visible at that moment depends on timing.

To test the loading state, use a deferred promise (Promise.withResolvers()), assert the fallback, then resolve it inside act and await the content.

23. How do you test a form that uses useActionState?

Test it the same way as any form: fill fields with user-event, submit with a click on the submit button, and await the visible result.

React 19 runs the action in a transition, so the pending state and the returned state appear asynchronously, and findBy* or toBeDisabled on the pending button is the right assertion.

test('shows a server error', async () => {
  const user = userEvent.setup()
  const subscribe = vi.fn(async () => ({error: 'Email already subscribed'}))
  render(<NewsletterForm action={subscribe} />)

  await user.type(screen.getByLabelText(/email/i), 'ada@example.com')
  await user.click(screen.getByRole('button', {name: /subscribe/i}))

  expect(await screen.findByRole('alert')).toHaveTextContent(/already subscribed/i)
  expect(subscribe).toHaveBeenCalledTimes(1)
})

Inject the action as a prop or mock the module that exports it; a test should not call a real server action.

A candidate who knows the 19.3 changelog may mention that useActionState error messages now say "action state" instead of "form state".

24. How do you share providers across tests without hiding setup?

Write a custom render that passes a wrapper, export it from a test utility module, and let each test pass the values it cares about. A fresh query client, router and store per test prevents state leaking between tests.

export function renderWithProviders(ui, {route = '/', user = testUser} = {}) {
  const queryClient = new QueryClient({
    defaultOptions: {queries: {retry: false}},
  })
  function Wrapper({children}) {
    return (
      <QueryClientProvider client={queryClient}>
        <AuthContext value={user}>
          <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
        </AuthContext>
      </QueryClientProvider>
    )
  }
  return {user: userEvent.setup(), ...render(ui, {wrapper: Wrapper})}
}

Two details separate seniors. Turning off query retries stops a failing request from retrying past the 1,000 ms findBy timeout.

And in React 19, a context object can be rendered directly as a provider (<AuthContext value={...}>), so .Provider is no longer required.

25. When should you use renderHook, and when is it the wrong tool?

renderHook wraps a hook in a test component and exposes result.current, the latest committed return value, plus rerender and unmount.

It suits reusable hooks published as a library or shared across many components, where the hook's API is the product: a useDebouncedValue, a usePagination.

The RTL docs prefer render for most cases because a component test keeps the hook's real usage visible. A hook that exists only to serve one component is better tested through that component.

Two gotchas: initialProps is passed to the callback on the first render only and not to the wrapper, and state updates triggered by calling functions on result.current must be wrapped in act, because there is no user-event call to do it.

Network Mocking and Runner Setup

26. Why mock the network with MSW instead of mocking fetch or the API module?

MSW intercepts requests at the network layer, so the component, its data library and its fetch client all run for real, and the test only replaces the server.

Mocking fetch by hand couples the test to the exact call shape; mocking the API module skips the serialization, headers and error handling where bugs live. The same handlers serve tests, Storybook and local development.

// src/mocks/handlers.js
import {http, HttpResponse} from 'msw'

export const handlers = [
  http.get('/api/orders', () =>
    HttpResponse.json([{id: 1042, total: 40}]),
  ),
]

// test setup file
import {setupServer} from 'msw/node'
import {handlers} from './src/mocks/handlers'

export const server = setupServer(...handlers)
beforeAll(() => server.listen({onUnhandledFrame: 'error'}))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

In MSW 3 the option that fails on unmocked traffic is onUnhandledFrame (default 'warn'), renamed from onUnhandledRequest because it now covers WebSocket connections as well as HTTP requests (see the listen() docs).

Setting it to 'error' stops a test from silently hitting a real endpoint.

27. Find the bug: the error-state test breaks the next test.

// setup file: beforeAll(() => server.listen()); afterAll(() => server.close())

test('shows an error when orders fail', async () => {
  server.use(
    http.get('/api/orders', () => new HttpResponse(null, {status: 500})),
  )
  render(<Orders />)
  expect(await screen.findByRole('alert')).toHaveTextContent(/try again/i)
})

test('lists orders', async () => {
  render(<Orders />)
  expect(await screen.findByText('#1042')).toBeInTheDocument()
})

Bug: the setup file has no afterEach(() => server.resetHandlers()), so the 500 override from server.use stays active and the second test gets an error. Run the second test alone and it passes, which is the signature of leaked state.

Fix: reset handlers after each test, or scope the override with {once: true} on the handler. A strong candidate also mentions the other shared state in this pair: a data library cache.

If Orders uses a query client created at module scope, the cached error or data crosses tests too, which is why question 24 builds a new client per render.

28. How do you set up jest-dom and Testing Library for Vitest compared with Jest?

The libraries are the same; the setup file and globals differ.

  • Jest: testEnvironment: 'jsdom' (install jest-environment-jsdom separately), and import '@testing-library/jest-dom' in a file listed in setupFilesAfterEnv. Jest exposes afterEach globally, so RTL's auto cleanup runs.
  • Vitest: environment: 'jsdom' or 'happy-dom', setupFiles: ['./vitest.setup.ts'], and import '@testing-library/jest-dom/vitest', which extends Vitest's expect and its types.

The Vitest trap is globals. Vitest does not expose afterEach globally unless globals: true is set, and RTL registers its automatic cleanup only when a global afterEach exists.

Without it, components from earlier tests stay mounted, and later tests fail with "Found multiple elements".

The fix is globals: true or an explicit afterEach(cleanup) in the setup file. jest-dom 7 requires Node.js 22 and lists @testing-library/dom as a required peer, so upgrading it in an old toolchain can fail at install.

29. jsdom, happy-dom or a real browser: how do you choose?

jsdom and happy-dom are JavaScript implementations of the DOM that run in Node.

They are fast and cover most component behavior, but they do no layout, so anything that depends on geometry, IntersectionObserver, real CSS, canvas or focus driven by layout either needs a mock or gives false results. happy-dom is usually faster and less complete than jsdom; teams switch per file when a test needs a missing API.

For behavior that depends on a real engine, Vitest Browser Mode runs tests in Chromium, Firefox or WebKit through Playwright or WebdriverIO, and vitest-browser-react provides a render with locators modeled on Testing Library queries.

Vitest 5 made those locators exact by default, so getByText('Item') no longer matches "Item 1".

A strong candidate splits the suite: jsdom for fast logic and rendering tests, a browser for layout, drag and drop and focus traps, and end-to-end tests in Playwright for full flows.

30. Find the bug: asserting that an error message is gone.

test('clears the error after a valid email', async () => {
  const user = userEvent.setup()
  render(<SignupForm />)
  await user.type(screen.getByLabelText(/email/i), 'ada')
  await user.tab()
  expect(screen.getByText(/enter a valid email/i)).toBeInTheDocument()

  await user.clear(screen.getByLabelText(/email/i))
  await user.type(screen.getByLabelText(/email/i), 'ada@example.com')
  await user.tab()
  expect(screen.getByText(/enter a valid email/i)).not.toBeInTheDocument()
})

Bug: the last line uses getByText to check absence. getBy* throws when nothing matches, so the test fails with "Unable to find an element" at the moment the component behaves correctly.

A candidate who "fixes" it by wrapping the line in try/catch has missed the point of the query variants.

Fix: expect(screen.queryByText(/enter a valid email/i)).not.toBeInTheDocument(). If the error clears asynchronously, for example after a debounced validation, use await waitForElementToBeRemoved(...) before the next step instead.

Better still, query the field's accessible description: expect(screen.getByLabelText(/email/i)).not.toHaveAccessibleDescription(), which also tests that the error was wired with aria-describedby.

31. Do snapshot tests belong in a Testing Library suite?

Rarely, and only small ones.

A snapshot of a whole rendered page (asFragment() or container) fails on any markup change, and reviewers approve snapshot updates without reading them, so it tests that the output did not change, not that it is correct.

It also couples the test to class names and wrapper divs, which is what Testing Library tries to avoid.

Reasonable uses: a small inline snapshot of a formatter's output, a serialized error message, or a short ARIA structure where the exact tree is the contract.

A strong candidate replaces large DOM snapshots with targeted assertions such as toHaveAccessibleName, toHaveTextContent and toBeDisabled, and treats any snapshot over a screen of output as a review smell.

What Changed Recently

Aug 13, 2025
jest-dom 6.7.0 adds toBePressed; 6.8.0 and 6.9.0 add toBePartiallyPressed and toAppearBefore
Jan 19, 2026
RTL 16.3.2 fixes onCaughtError type inference for React 19
Jul 20, 2026
jest-dom 7.0.0 requires @testing-library/dom as a peer and Node.js 22, adds toContainOneBy*
Aug 27, 2026
RTL 16.3.3 stops act() re-entering when events dispatch from handlers
Sep 3, 2026
Vitest 5 requires Node.js 22.12, defaults clearMocks to true, makes browser locators exact
Sep 9, 2026
React 19.3 makes <ViewTransition> and Fragment refs stable
Sep 28, 2026
MSW 3.0 goes ESM-only and renames onUnhandledRequest to onUnhandledFrame
Oct 13, 2026
user-event 14.6.8 closes a run of 2026 patch releases on keyboard, tab and pointer fixes

32. What changed in jest-dom 7, and what do the new query matchers add?

jest-dom 7.0.0 shipped on July 20, 2026 with two breaking changes: @testing-library/dom is a required peer dependency, and the minimum Node.js version is 22. Version 7.0.1 then declared Vitest as an optional peer.

The feature in the same release is a family of scoped query matchers, toContainOneBy* and toContainAnyBy*, one per query type (Role, LabelText, Text, TestId and so on).

They answer "does this region contain exactly one, or at least one, matching descendant" as a pass or fail result instead of a thrown query error:

const related = screen.getByRole('region', {name: /related articles/i})
expect(related).toContainOneByRole('heading', {name: 'Related article'})

const results = screen.getByRole('region', {name: /search results/i})
expect(results).toContainAnyByRole('listitem')
expect(results).not.toContainAnyByRole('heading')

Earlier 6.x releases in 2025 added toBePressed, toBePartiallyPressed for toggle buttons, and toAppearBefore and toAppearAfter for document order.

33. What does MSW 3.0 change for a React test suite?

The MSW 3.0.0 release on September 28, 2026 changes setup more than handlers. http, HttpResponse and setupServer from msw/node work as before. The items that break test suites:

  • ESM-only. Vitest handles this out of the box. A Jest suite running as CommonJS needs Jest's ESM mode (--experimental-vm-modules) or must stay on MSW 2.x until it migrates.
  • Node.js 22 minimum and TypeScript 5.9 minimum.
  • onUnhandledRequest is now onUnhandledFrame.
  • No more setTimeout patching. MSW no longer works around fake timers, so a handler that uses delay() under fake timers resolves only when the test advances time.
  • Cookies in Node.js are no longer managed by MSW, and request.headers.get('cookie') returns null; handlers should read the cookies resolver argument.
  • graphql moved to msw/graphql, and the WebSocket connection event is now websocket:connection.

The fake timers change pairs with question 18: a suite that relied on MSW's patch to make delayed responses resolve will start timing out after the upgrade.

34. Which recent React and Vitest changes affect component tests?

React 19.3 (September 9, 2026) made <ViewTransition> and Fragment refs stable and added browser() for client-only rendering.

Two changelog items reach tests: Strict Mode now double-invokes Effects during hydration, matching client-rendered roots, and errors inside a hidden <Activity> no longer escape.

Tests that render with reactStrictMode: true and count effect calls need to expect the double run in both paths.

Vitest 5 (September 3, 2026) requires Node.js 22.12 and Vite 6.4, sets clearMocks to true by default so mock call history resets before each test, fails tests that leave resolves or rejects unawaited, and fakes Temporal along with Date.

Vitest 5.1.0 (November 2, 2026) added --last-failed and fake timer support for node:timers. A suite that set a mock's return value in beforeAll and expected call counts to accumulate across tests breaks under the new default.

Signs of a Strong Answer

  • They query by role and accessible name first, and treat a query that cannot find an element as a possible accessibility bug.
  • They await every user-event call and every findBy*, and keep side effects out of waitFor.
  • They explain an act warning as an update that landed after the test stopped waiting, and fix it by awaiting the visible result.
  • They know RTL's auto cleanup and act environment depend on runner globals, and check that first when a Vitest suite shows duplicate elements.
  • They mock the network with MSW, fail on unhandled traffic, and reset handlers and caches between tests.
  • They can say which tests belong in jsdom, which in a real browser, and which in end-to-end tests.

Hiring React Engineers Who Test

Frontend engineers who write tests that survive refactors, debug act warnings instead of muting them and own a CI suite under a minute are rarer than React resumes suggest.

Second Talent helps US startups hire vetted AI-Native Engineers from Asia, including React developers and front-end developers, screened with questions like these.

For a dedicated testing role, see the test automation engineer role guide, and for the runner layer, our Vitest, unit testing and Playwright interview guides.

Rates run $23 to $39 USD an hour, billed monthly, with matches in 24 hours and a 90-day one-time replacement guarantee. Compare rates on the cost to hire a React developer page, or tell us what you need.

Get developers who ship with AI agents.

Starts at $23/Hour.

Start Hiring

$0 cost until you hire