React components are checked through the user's eyes, not by internal structure. Testing Library queries elements by role and accessible name, by text and label - the way a human and assistive technology find them - and deliberately gives no convenient way to search by className or DOM path. To run it you need jsdom as the environment, @testing-library/react, jest-dom for matchers and user-event for interaction.
A typical test describes a form's behavior. The user enters an invalid email, clicks the button, and an error message appears on screen. Queries follow a priority: first role plus accessible name (the 'create account' button), then the field label (getByLabelText), then visible text and only as a last resort a test id. This order is not a whim: it keeps the test tied to what the user sees and hears.
It is important to use userEvent, not the low-level fireEvent. userEvent models real interaction: focus, key presses, event order - as it happens in the browser. So its calls are asynchronous and are preceded by await, and the object itself is created via userEvent.setup() at the start of the test. This is closer to a real user than a single synthetic fireEvent.
Queries split into three families by purpose. getBy is synchronous and for what is already on screen; findBy is asynchronous and waits for an element to appear - it checks the result after a request or a rerender; queryBy* returns null and serves to check absence. Confusing them is a common source of both false failures and missed waits: findBy where you need to wait, and queryBy where you check that something is not there.
Custom hooks deserve a separate conversation. When a hook holds reusable logic, testing it through a toy wrapper component is awkward and indirect. Testing Library provides renderHook, which runs the hook directly and returns its result. A trivial glue hook is still simpler to check through the component that uses it, but non-trivial logic is more sensibly tested at the source.
renderHook has its own rules. result.current is a reference to the latest committed value, so you re-read it after each update rather than capturing it into a variable in advance: an old reference shows stale state. State updates are wrapped in act(), new props are passed via rerender, and dependencies like context and providers are passed with the wrapper option. An async hook is awaited via waitFor.
The form and the hook together cover frontend tests: a component is checked through visible behavior, reusable logic through renderHook. Both approaches avoid implementation details: it does not matter which useState is inside or how the internal handler is named - what matters is that the user sees the error and the hook returns the right value for the given inputs and updates.
The typical failures are common too. Capturing result.current into a variable and checking a stale value; ignoring the warning about an update not wrapped in act(), behind which a real race hides; checking an implementation detail - a specific call of an internal setter instead of the hook's observable result. Stick to the contract: what the component shows and what the hook returns, not how it is arranged inside.
npm i -D jsdom @testing-library/react \
@testing-library/jest-dom @testing-library/user-event
# test/setup.ts: import '@testing-library/jest-dom/vitest'
# vitest.config.ts -> test:
# environment: 'jsdom'
# setupFiles: ['./test/setup.ts']test('shows an error for an invalid email', async () => {
const user = userEvent.setup()
render(<Signup />)
await user.type(screen.getByLabelText(/email/i), 'wrong')
await user.click(screen.getByRole('button', { name: /create account/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Enter a valid email',
)
})test('useDebouncedValue returns the value after the delay', () => {
vi.useFakeTimers()
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 300),
{ initialProps: { value: 'a' } },
)
rerender({ value: 'ab' })
act(() => vi.advanceTimersByTime(300))
expect(result.current).toBe('ab') // re-read, do not capture
})