Common Testing Library Mistakes and How to Avoid Them
React Testing Library was built on a simple idea: test your components the way your users actually interact with them. Over time, the API has evolved, and so have best practices. Yet many tests in the wild still follow outdated patterns. Here’s a look at the most common mistakes and what you should do instead.
Relying on ESLint Plugins
The two official ESLint plugins can catch most of the issues described below before you even run your tests. They don’t just enforce style—they flag anti-patterns that lead to brittle tests.
eslint-plugin-testing-library— catches incorrect query usage and structural problems.eslint-plugin-jest-dom— ensures you’re using the right matchers from jest-dom.
If you use Create React App, eslint-plugin-testing-library is already bundled.
Naming and Cleanup Habits
You’ll often see the variable returned from render called wrapper. That’s an Enzyme habit that doesn’t apply here. The return value doesn’t “wrap” your component; it simply gives you access to a few utilities. Prefer destructuring exactly what you need, or name the object view.
Similarly, calling cleanup manually is no longer necessary. For most major testing frameworks, cleanup runs automatically after each test.
Query with screen, Not Destructured Returns
When DOM Testing Library v6.11.0 introduced screen, it removed the need to keep destructured queries in sync with the rest of your test. Instead of doing this:
// ❌
const wrapper = render(<Example prop="1" />)
wrapper.rerender(<Example prop="2" />)
// ✅
const { rerender } = render(<Example prop="1" />)
rerender(<Example prop="2" />)
You can just do this:
// ❌
const { getByRole } = render(<Example />)
const errorMessageNode = getByRole('alert')
// ✅
render(<Example />)
const errorMessageNode = screen.getByRole('alert')
And then query like so:
import { render, screen } from '@testing-library/react'
The main benefit is that you can type screen. and rely on autocomplete for the correct method. You can also use screen.debug directly. The only time you should hold onto the render return is if you’re setting container or baseElement—though in most cases, you probably shouldn’t be using those options at all.
Pick the Right Query and Assertion
A high-impact mistake is using the wrong query. The Testing Library maintain a "Which Query Should I Use" guide specifically to prevent this. Query the DOM the way your users do: by visible text and roles, not by implementation details.
This is where container.querySelector goes wrong. It’s low-level, hard to read, and frequently breaks:
// ❌
const { container } = render(<Example />)
const button = container.querySelector('.btn-primary')
expect(button).toHaveTextContent(/click me/i)
// ✅
render(<Example />)
screen.getByRole('button', { name: /click me/i })
The same logic applies to querying by test IDs everywhere. Instead of using a test ID to check if a label exists, query by its actual text:
// ❌
screen.getByTestId('submit-button')
// ✅
screen.getByRole('button', { name: /submit/i })
If your UI is localized, test against the default locale. When content writers change wording to something like “Email” from “Username,” a failing test is a legitimate signal that your implementation needs to catch up—it’s a quick fix and it gives you confidence that translations render as expected.
For most queries, the *ByRole variants are the best option. They support an accessible name option:
// assuming we've got this DOM structure to work with
// <button><span>Hello</span> <span>World</span></button>
screen.getByText(/hello world/i)
// ❌ fails with the following error:
// Unable to find an element with the text: /hello world/i. This could be
// because the text is broken up by multiple elements. In this case, you can
// provide a function for your text matcher to make your matcher more flexible.
screen.getByRole('button', { name: /hello world/i })
// ✅ works!
This works even when text is split across child elements. If the role-based query fails, it doesn’t just show you the DOM—it lists all available roles so you can correct your selector:
// assuming we've got this DOM structure to work with
// <button><span>Hello</span> <span>World</span></button>
screen.getByRole('blah')
That failure produces something like this:
TestingLibraryElementError: Unable to find an accessible element with the role "blah"
Here are the accessible roles:
button:
Name "Hello World":
<button />
--------------------------------------------------
<body>
<div>
<button>
<span>
Hello
</span>
<span>
World
</span>
</button>
</div>
</body>
Notice that you don’t need to add role="button" to a standard <button>; it already has an implicit role. Don’t sprinkle ARIA attributes around without cause. They only belong where the native HTML semantics aren’t up to the task—like building a custom autocomplete. In those cases, follow WAI-ARIA patterns. And remember: inputs need their type attribute to be meaningful.
When it comes to assertions, don’t write hand-rolled checks. Use jest-dom for richer matchers:
const button = screen.getByRole('button', { name: /disabled button/i })
// ❌
expect(button.disabled).toBe(true)
// error message:
// expect(received).toBe(expected) // Object.is equality
//
// Expected: true
// Received: false
// ✅
expect(button).toBeDisabled()
// error message:
// Received element is not disabled:
// <button />
A matcher like toBeDisabled reports the full element state, whereas something like toBeTruthy tells you nothing helpful.
act and fireEvent Misuse
Wrapping calls in act unnecessarily is often a reaction to warning spam:
// ❌
act(() => {
render(<Example />)
})
const input = screen.getByRole('textbox', { name: /choose a fruit/i })
act(() => {
fireEvent.keyDown(input, { key: 'ArrowDown' })
})
// ✅
render(<Example />)
const input = screen.getByRole('textbox', { name: /choose a fruit/i })
fireEvent.keyDown(input, { key: 'ArrowDown' })
But render and fireEvent are already wrapped in act. Seeing an act warning is usually a signal that something real is wrong, so don’t try to suppress it. Look instead for the root cause—often an update happening outside of your test’s control.
A better move is to match user behavior with @testing-library/user-event rather than raw fireEvent:
// ❌
fireEvent.change(input, { target: { value: 'hello world' } })
// ✅
userEvent.type(input, 'hello world')
fireEvent.change only dispatches one event, while userEvent.type fires keyDown, keyPress, and keyUp for each character. This gives you more realistic coverage of the interaction and works better with libraries that don’t listen only for a change event.
The query* Trap
The query* variants exist for exactly one purpose: asserting that something isn’t there. Unlike get* or find*, they return null instead of throwing when nothing matches:
// ❌
expect(screen.queryByRole('alert')).toBeInTheDocument()
// ✅
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
That, plus the helper message from queryByText, ends up like this:
// ❌
import { render, screen, cleanup } from '@testing-library/react'
afterEach(cleanup)
// ✅
import { render, screen } from '@testing-library/react'
get* and find* will throw a helpful error and print the DOM when they fail. query* just gives you null, which forces you to rely on the generic “null isn’t in the document” message. Reserve query* solely for confirming removal.
Pick find* Over Manual waitFor Calls
// ❌
const submitButton = await waitFor(() =>
screen.getByRole('button', { name: /submit/i }),
)
// ✅
const submitButton = await screen.findByRole('button', { name: /submit/i })
These two approaches are essentially equivalent under the hood — find* queries already rely on waitFor. The real difference is in clarity and diagnostics. The find* version is more concise, and if the element never appears, the resulting error message is far more helpful for tracking down what went wrong. Whenever you need to query for something that might not be in the DOM immediately, prefer a find* query.
Never Pass an Empty Callback to waitFor
// ❌
await waitFor(() => {})
expect(window.fetch).toHaveBeenCalledWith('foo')
expect(window.fetch).toHaveBeenCalledTimes(1)
// ✅
await waitFor(() => expect(window.fetch).toHaveBeenCalledWith('foo'))
expect(window.fetch).toHaveBeenCalledTimes(1)
The entire point of waitFor is to pause until a specific condition becomes true. If you hand it an empty callback, your test may pass today only because your mock setup happens to resolve after a single event loop tick. That kind of test is brittle — any refactor to the asynchronous logic can easily break it. Always put a concrete, meaningful assertion inside the waitFor callback.
Limit waitFor Callbacks to a Single Assertion
// ❌
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('foo')
expect(window.fetch).toHaveBeenCalledTimes(1)
})
// ✅
await waitFor(() => expect(window.fetch).toHaveBeenCalledWith('foo'))
expect(window.fetch).toHaveBeenCalledTimes(1)
Suppose your earlier example makes two separate window.fetch calls. If you pack multiple assertions into one waitFor callback and one of them fails, you will not see the failure until the timeout expires. Restricting the callback to a single assertion lets you wait for the UI to reach the exact state you care about, while still failing as quickly as possible when that state never arrives.
Keep Side-Effects Out of waitFor
// ❌
await waitFor(() => {
fireEvent.keyDown(input, { key: 'ArrowDown' })
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})
// ✅
fireEvent.keyDown(input, { key: 'ArrowDown' })
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})
Because waitFor is designed for situations where the gap between an action and a resulting state is non-deterministic, its callback can be invoked an unpredictable number of times — both on a fixed interval and in response to DOM mutations. Any side-effect you put inside that callback risks running multiple times. For the same reason, snapshot assertions do not belong inside waitFor. If you need a snapshot, first wait for a specific, stable assertion to pass, and only then take the snapshot.
Treating get* Queries as Assertions
// ❌
screen.getByRole('alert', { name: /error/i })
// ✅
expect(screen.getByRole('alert', { name: /error/i })).toBeInTheDocument()
This one is not a serious problem — more of a stylistic note. When a get* query fails to find an element, it throws a highly descriptive error that prints the entire DOM structure, which is excellent for debugging. In practice, that means an explicit assertion after the query can never actually fail, because the query throws first. Many developers therefore drop the assertion entirely, which is perfectly acceptable. That said, keeping the assertion in serves a useful documentation purpose: it signals to future readers that the query is an intentional existence check, not leftover code from a refactor.



