React Testing in 2026: Vitest, Testing Library and What to Actually Test
Vitest has overtaken Jest in the React ecosystem. Here's what changed, why Testing Library still dominates, and which tests actually matter in 2026.
The State of React Testing in 2026
The Jest era isn't dead, but it's definitely wounded. Vitest 3.x ships with native ESM support, a browser mode that actually works, and cold-start times that make your old Jest config feel embarrassing by comparison. If you started a new React project in 2025 or later, odds are you already switched. If you haven't, this guide will help you understand why it's worth the migration.
That said, the testing philosophy hasn't changed much. React Testing Library — now at v16 — is still the default answer for component tests, and the core principle holds: test what your user sees and interacts with, not implementation details. Kent C. Dodds wrote about this pattern back in 2019 and it's aged better than almost anything else in frontend.
Where things get murky is coverage obsession. Teams still shoot for 80% or 100% coverage and end up testing snapshot noise and internal state that nobody cares about. In practice, those numbers lie to you. A suite with 95% coverage and zero interaction tests is less valuable than 60% coverage where every test represents a real user flow.
So let's get specific: what tooling to reach for, how to configure it fast, and which tests are actually worth writing in a modern React codebase.
Setting Up Vitest with React
Vitest is Vite-native. That's the whole pitch. If your project already uses Vite, you're adding maybe 50 lines of config. No Babel transforms, no jest.config.js sprawl, no moduleNameMapper hacks for CSS modules. It just works with your existing vite.config.ts.
Here's a minimal setup for a React + TypeScript project:
``bash
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
`
Then inside your vite.config.ts:
`ts
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',
},
})
`
And your setup.ts:
`ts
import '@testing-library/jest-dom'
``
That's it. You're running tests.
Worth noting: Vitest's globals: true means you don't need to import describe, it, expect in every file. Some teams turn this off for explicitness — it's a style call. I keep it on because the noise reduction across 200+ test files is real.
Quick aside: if you're on Create React App (which, honestly, please migrate), you're stuck with Jest unless you eject. Vitest only plays nicely with Vite and other modern bundlers. That's not a flaw — it's a boundary that keeps the tool fast and focused.
One more thing — Vitest's watch mode re-runs only the tests affected by your file changes. In a large monorepo, that difference is 200ms versus 8 seconds. Ask anyone who writes tests regularly which they prefer.
React Testing Library: What You Should Actually Be Testing
The question nobody asks enough: *should* this have a unit test, or does an integration test cover it better? Most React components don't need to be tested in total isolation. They're glue code. They receive props, render JSX, call handlers. Testing them as units often means mocking so much that the test doesn't represent anything real.
Here's a realistic test for a button component — not a contrived one:
``tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from '../Button'
test('calls onClick when clicked and not disabled', async () => {
const user = userEvent.setup()
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Submit</Button>)
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(handleClick).toHaveBeenCalledOnce()
})
test('does not call onClick when disabled', async () => {
const user = userEvent.setup()
const handleClick = vi.fn()
render(<Button onClick={handleClick} disabled>Submit</Button>)
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(handleClick).not.toHaveBeenCalled()
})
``
Notice what's absent: no snapshot, no checking of CSS classes, no spying on internal state. You're testing the contract — what the user experiences.
Honestly, the biggest time-waster in React testing is testing styling and layout. Don't write tests that assert a div has margin-top: 24px. That's not a test — it's a changelog waiting to break your CI. If your design system is built on something like Empire UI, the visual contract is handled at the component library level, not in your app tests.
Focus your Testing Library tests on three things: rendering with correct data, user interactions triggering correct side-effects, and accessibility (roles, labels, focus behavior). Everything else is usually noise.
Testing Custom Hooks
Custom hooks are the one place where pure unit tests shine. They have clear inputs and outputs, and you don't always want to wrap them in a dummy component just to call them. renderHook from Testing Library makes this clean.
``tsx
import { renderHook, act } from '@testing-library/react'
import { useCounter } from '../useCounter'
test('increments count', () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
`
Simple. If your hook takes a context provider, you pass it via the wrapper` option — no hacks required.
Where people get tripped up is async hooks. If your hook fires a fetch on mount, you need waitFor or findBy queries to let effects settle. Skipping that causes flaky tests that pass locally and fail in CI 30% of the time. Use await waitFor(() => expect(...)) and you'll never chase that particular ghost again.
One rule I stick to: if a hook has complex branching logic — more than 3 distinct states — test each branch explicitly. Hooks with 10 conditional paths tested only through components are a debugging nightmare six months later.
Mocking, MSW, and Async Data
Mock Service Worker (MSW) is the right answer for mocking API calls in React tests. Not vi.mock('axios'). Not intercepting fetch manually. MSW v2 works in both Node (for Vitest) and the browser, and it lets you define handlers that behave exactly like a real API — including error states, delays, and partial responses.
``ts
// src/test/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/user/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Ada Lovelace' })
}),
http.get('/api/error', () => {
return new HttpResponse(null, { status: 500 })
}),
]
`
Then in your setup file, you spin up the server:
`ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
``
Now your components fetch against real-looking network responses and your tests aren't coupled to implementation details of whatever HTTP client you're using.
Look, the common mistake with MSW is overriding handlers inside individual tests for error cases and forgetting server.resetHandlers() after. That bleeds state between tests and you end up with failures that only reproduce when tests run in a specific order. Always reset. Always.
Worth noting: MSW also works with React Query, SWR, and any other fetching layer — because it intercepts at the network level, not the module level. That's the whole point.
What NOT to Test (Seriously)
Third-party libraries. You don't need to test that React's useState works. You don't need to verify that a library component renders correctly — that's the library author's job. If you're using components from a system like Empire UI's glassmorphism components, you test *how you use them* — the props you pass, the events you handle — not whether the component itself renders a backdrop-filter of blur(12px).
Snapshot tests as a primary strategy. Snapshots have a place — catching unexpected DOM structure changes in stable, rarely-touched components. But teams that use snapshots as their main test type end up with --updateSnapshot runs that nobody reviews carefully. You update 47 snapshots, something actually broke in snapshot 31, and it ships anyway.
In practice, I've seen codebases with 400 snapshot tests and zero interaction tests. Those are the projects that ship accessible failures — missing labels, broken keyboard navigation, unannounced state changes — because the suite never checked any of that.
The healthiest ratio I've seen: 60-70% integration tests (components with real children, real handlers, real data flow), 20-30% unit tests (pure functions, custom hooks with complex logic), and 5-10% E2E tests for critical user flows. Skip the pure snapshots except as a sanity check on design system atoms.
CI Setup and Speed
Slow tests kill testing culture. If your suite takes 4 minutes, developers skip it locally and fight about failures in CI. Target under 60 seconds for unit + integration tests. Vitest helps here, but you can go further.
Run tests in parallel using Vitest's --pool=forks or --pool=threads flag. For most React test suites, threads is faster because the overhead of spawning processes hurts more than the GIL-equivalent isolation costs. Benchmark both — on a 2024 MacBook Pro M3, I measured a 40% speedup switching from forks to threads on a 300-test suite.
``json
// package.json
"scripts": {
"test": "vitest",
"test:ci": "vitest run --reporter=verbose --pool=threads",
"test:coverage": "vitest run --coverage"
}
`
For coverage, @vitest/coverage-v8 is faster than @vitest/coverage-istanbul` and good enough for most teams. Istanbul gives you slightly more accurate branch coverage tracking — worth it if coverage reports gate your PRs.
One more thing — cache your node_modules in CI and pin Vitest to an exact version in package.json. Minor Vitest releases occasionally shift timing behavior and break flaky test detection. Exact pins mean your CI is reproducible and you're not debugging 'it passes locally' at 11pm.
FAQ
If you're on Vite, yes — the migration is fast and the speed difference is worth it. If you're on Webpack or CRA, weigh the migration cost against the gains; Jest still works fine and isn't going anywhere.
userEvent simulates real browser interactions — it fires all the intermediate events a real click or type would trigger. fireEvent dispatches a single synthetic event. Use userEvent for anything user-facing; fireEvent is mostly for edge cases.
Yes, but not many. E2E tests catch integration failures between your frontend, API, and auth that unit tests can't see. Aim for 5-10 critical paths — login, checkout, main CRUD flows — not a full coverage suite.
It's an arbitrary number that correlates weakly with actual quality. Better target: every user-visible feature has at least one interaction test. Coverage is a floor check, not a quality metric.