Skip to main content
paulund

4 min read

#react#testing#vitest

Testing React Components with Vitest and Testing Library

The most common mistake in component testing is asserting on things the user can't see. Testing that a state variable changed, or that a specific function was called with certain arguments, tells you nothing about whether the component actually works. The user doesn't care about internal state. They care about what appears on screen and what happens when they interact with it.

Vitest and React Testing Library push you toward that mindset. Vitest is fast and works natively with Vite's config. Testing Library gives you queries that mirror how a user navigates a page.

Setting Up

Install the dependencies first.

npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom

Then configure Vitest to use jsdom as the test environment. Without jsdom, there's no DOM, and React has nothing to render into.

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
    plugins: [react()],
    test: {
        environment: 'jsdom',
        globals: true,
        setupFiles: ['./src/test/setup.ts'],
    },
})

The setup file imports the Testing Library matchers so you can use assertions like toBeInTheDocument() and toBeDisabled() without importing them in every test file.

// src/test/setup.ts
import '@testing-library/jest-dom/vitest'

Query Priority

Testing Library gives you several ways to find elements. The order you reach for them matters, because the priority reflects how users and assistive technology actually navigate a page.

getByRole is the first choice. It queries by ARIA role, the same signal that screen readers use. Buttons, headings, checkboxes, and inputs all have implicit roles.

getByLabelText is the right pick for form inputs. A label tied to an input is how sighted users and screen readers both identify it.

getByText finds elements by their visible text content. Good for paragraphs, spans, and anywhere the text itself is the identifier.

getByTestId is the escape hatch. Use it only when there's no accessible way to reach an element. It's a test-only concern that leaks into your markup, so treat it as a last resort.

A straightforward form test shows the priority in practice.

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

test('submits the form with the email and password the user typed', async () => {
    const user = userEvent.setup()
    const onSubmit = vi.fn()

    render(<LoginForm onSubmit={onSubmit} />)

    await user.type(screen.getByLabelText('Email'), '[email protected]')
    await user.type(screen.getByLabelText('Password'), 'hunter2')
    await user.click(screen.getByRole('button', { name: 'Log in' }))

    expect(onSubmit).toHaveBeenCalledWith({
        email: '[email protected]',
        password: 'hunter2',
    })
})

Notice that the test finds the email input by its label, not by a class name or element ID. If someone later renames the label to "Email address" without updating the label element, the test breaks and tells you exactly why.

userEvent over fireEvent

Testing Library ships two ways to trigger events. fireEvent dispatches a single DOM event directly. userEvent simulates what a real user does, which is a sequence of events.

Typing into an input with fireEvent.change dispatches only a single change event. Real typing usually involves keyboard and input events, and for text inputs the native change event is typically observed when the value is committed, such as on blur, rather than after each keystroke. Validation logic, input masks, and controlled components often depend on that fuller interaction. userEvent.type is designed to model it more realistically.

Always prefer userEvent. fireEvent is useful when you need to test a very specific DOM event in isolation, but that situation is rare in component tests.

// Prefer this
const user = userEvent.setup()
await user.type(screen.getByLabelText('Search'), 'react testing')
await user.click(screen.getByRole('button', { name: 'Search' }))

// Avoid this unless you have a specific reason
fireEvent.change(screen.getByLabelText('Search'), {
    target: { value: 'react testing' },
})

Testing Async Behaviour

When a component fetches data or updates after a delay, assertions need to wait. The find queries return promises that resolve when the element appears, up to a default timeout.

test('shows the list of results after searching', async () => {
    const user = userEvent.setup()

    render(<SearchResults />)

    await user.type(screen.getByLabelText('Search'), 'vitest')
    await user.click(screen.getByRole('button', { name: 'Search' }))

    const results = await screen.findAllByRole('listitem')
    expect(results).toHaveLength(3)
})

findByRole, findByText, and the rest of the find variants are the async equivalents of getBy. They retry until the element appears or the timeout passes.

What Not to Test

Don't test that useState updated. Don't test that a private helper function returned the right value. Don't test CSS classes unless the class is the only way to convey something meaningful to the user.

Test what changes on screen. If a button press is supposed to show a success message, assert that the success message is visible. If a form validation rule is supposed to disable the submit button, assert that the button is disabled. The implementation behind those outcomes can change completely without your tests caring.

test('disables the submit button while the form is submitting', async () => {
    const user = userEvent.setup()

    render(<ContactForm />)

    await user.click(screen.getByRole('button', { name: 'Send message' }))

    expect(screen.getByRole('button', { name: 'Sending...' })).toBeDisabled()
})

This test doesn't know or care whether the component uses useState, useReducer, or a third-party form library. The behaviour is the contract. The internals are not.

The Rule

Write tests from the user's point of view. Find elements the way a user would find them: by label, by role, by visible text. Interact with them the way a user would: type, click, tab. Assert on what the user would notice: text appearing, buttons disabling, errors showing.

When a test breaks, it should mean something broke for the user. If your tests are brittle to refactors that don't change behaviour, they're testing the wrong things.

Related notes


Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.