Vitest and React Testing Library: Setup and Patterns

For Vite-based React applications, Vitest has become the default test runner not because Jest disappeared overnight, but because the developer experience lines up with how modern frontends are already built. Vitest reuses Vite’s transform pipeline, which means TypeScript, JSX, aliases, and the same plugins you rely on in dev apply to tests without a parallel configuration universe. Native ESM support removes a class of “works in Jest with a hack” problems. The API is deliberately Jest-compatibledescribe, it, expect, vi.fn, mocks, timers—so migration is usually incremental rather than a rewrite.

That speed and compatibility only pay off if the assertions you write are aimed at the right boundary. React Testing Library complements Vitest by encouraging tests that render components into a document and query the DOM the way assistive technology and users do. Together they form the backbone of the bottom two layers of the testing pyramid: fast feedback on real component behavior.

Configuring Vitest with React and jsdom

Most React apps need a DOM implementation in Node. Vitest’s environment: 'jsdom' option wires that in per worker. A typical vite.config.ts merges the app’s plugins with a test block:

/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./src/test/setup.ts"],
    coverage: {
      provider: "v8",
      thresholds: {
        branches: 70,
        functions: 70,
        lines: 70,
        statements: 70,
      },
    },
  },
});

globals: true lets you skip importing describe and it in every file; whether you enable it is a team style choice—explicit imports make examples more portable, globals reduce noise in large suites.

Custom matchers from @testing-library/jest-dom belong in a single setup file so expectations like toBeInTheDocument() and toBeDisabled() are available everywhere:

import "@testing-library/jest-dom";

Install the usual testing stack alongside Vitest:

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

If you use TypeScript path aliases in Vite, reuse them in tests; the point of this setup is that the same module resolution story applies to app code and test code, which cuts down on “import works in the app but not in tests” drift.

Patterns that mirror real usage

The following example exercises a login form for validation, success, and loading behavior. Notice the combination of role and name queries, userEvent, and assertions on outcomes users would see—error copy, callback arguments, disabled state during an async submit.

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

describe('LoginForm', () => {
  it('shows error when submitted with empty fields', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={vi.fn()} />)

    await user.click(screen.getByRole('button', { name: /sign in/i }))

    expect(screen.getByText(/email is required/i)).toBeInTheDocument()
  })

  it('calls onSubmit with credentials on valid submission', async () => {
    const user = userEvent.setup()
    const mockSubmit = vi.fn()
    render(<LoginForm onSubmit={mockSubmit} />)

    await user.type(screen.getByLabelText(/email/i), 'test@example.com')
    await user.type(screen.getByLabelText(/password/i), 'password123')
    await user.click(screen.getByRole('button', { name: /sign in/i }))

    expect(mockSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    })
  })

  it('disables submit button during loading', async () => {
    const user = userEvent.setup()
    const slowSubmit = vi.fn(() => new Promise((resolve) => setTimeout(resolve, 1000)))
    render(<LoginForm onSubmit={slowSubmit} />)

    await user.type(screen.getByLabelText(/email/i), 'test@example.com')
    await user.type(screen.getByLabelText(/password/i), 'password123')
    await user.click(screen.getByRole('button', { name: /sign in/i }))

    expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled()
  })
})

This style of test survives refactors that change internal state shape or split child components, as long as the outward behavior and accessible labels remain consistent—which is exactly the contract you want to enforce.

Async work: findBy, waitFor, and act

React 18 and 19 make asynchronous rendering and effects ordinary. Tests should await queries that may appear after microtasks or network mocks resolve. findBy* queries wrap waitFor and return a promise; they read well for “eventually this text shows up.” Use waitFor when you need to assert a combination of conditions or intermediate states, but avoid turning it into a retry loop around unrelated expectations—that produces flaky tests.

act is still relevant when you trigger updates outside React Testing Library helpers, but in most RTL flows the library already wraps updates correctly. Reach for explicit act when integrating lower-level utilities.

Timers, modules, and TypeScript seams

Components that debounce input, poll for updates, or auto-dismiss toasts are notorious for flaky tests. Fake timers let you advance time deterministically instead of sprinkling setTimeout sleeps through the suite. After advancing time, still ask what the user would see—not merely that a callback fired.

When you must mock a module—analytics, maps, or a Server Action import—prefer vi.mock hoisted at the top with narrow factories that return typed stubs. Replace boundaries, not every transitive dependency, or you will test the runner’s imagination more than your app. Explicit return types on mock factories catch drift when production signatures change.

Organize tests consistently: colocated *.test.tsx or a parallel __tests__ tree, but pick one convention per package. Large suites benefit from shared render helpers that wrap common providers so each file does not paste twenty lines of boilerplate—keep those helpers small so failures stay easy to localize.

Snapshots: use sparingly but deliberately

Large snapshot dumps of entire component trees usually age poorly; they encode implementation noise. When snapshots help, they tend to be small and stable: serialized error messages, structured props passed to a headless child, or the output of a formatting helper. If a snapshot failure does not immediately suggest whether the change is a regression or an intentional UI tweak, the snapshot is too big.

Coverage thresholds as a team agreement

The coverage.thresholds block in Vitest configuration turns an aspiration into a CI gate. The numbers should reflect what the team is willing to enforce, not fantasy metrics. If thresholds are set too high, developers bypass them with meaningless tests; if too low, they offer no signal. Pair thresholds with review expectations: when coverage drops, the pull request should explain whether the uncovered code is trivial boilerplate or a risky branch that still needs a behavioral test.

Vitest’s speed makes it realistic to run the full unit and component suite on every push. That frequency is what converts testing from a pre-release ritual into continuous design feedback—exactly what advanced React teams expect in 2026.