Modern Web Development: A Complete 2026 Guide
A current, practical guide to modern web development: React 19, Next.js 16, RSC, modern CSS, Core Web Vitals (INP), accessibility, and AI.
Read Modern Web Development: A Complete 2026 ...A current, practical guide to testing React apps in 2026: Vitest, React Testing Library, MSW, Playwright, Server Components, a11y, and CI.
If your React testing setup still leans on Jest, Enzyme, or shallow rendering, it's time for a refresh. The ecosystem has consolidated around a faster, more user-centric stack: Vitest for the test runner, React Testing Library for components, MSW for network mocking, and Playwright for end-to-end coverage. Just as important as the tools is the strategy: knowing what to test, at which layer, and what to leave alone.
This guide walks through a modern, pragmatic testing approach for React applications, including the wrinkles introduced by React Server Components and the Next.js App Router.
The classic testing pyramid puts a huge base of unit tests under a thin layer of integration and a sliver of end-to-end tests. For component-based frontends, that shape is wrong.
Kent C. Dodds popularized the testing trophy, which reweights the layers and adds static analysis at the base:
The reasoning is simple: in modern React, the interesting bugs live in how things compose, not in isolated functions. A useAuth hook can pass every unit test and still break when wrapped in a router and a theme provider. Integration tests catch those composition failures while staying fast and stable.
Rule of thumb: write tests that resemble how your software is used. The more your tests mirror real usage, the more confidence they give you.
| Layer | What it covers | Tooling | Speed | When to reach for it |
|---|---|---|---|---|
| Static | Types, lint, dead code | TypeScript, ESLint | Instant | Always on |
| Unit | Pure logic, hooks, reducers, Zod schemas | Vitest | Milliseconds | Branchy logic with many inputs |
| Integration | Composed UI + mocked network | Vitest + RTL + MSW | Fast | The default for most component tests |
| E2E | Full user journeys in a real browser | Playwright | Slow | Auth, checkout, async Server Components |
| Accessibility | WCAG violations | vitest-axe, @axe-core/playwright | Fast / medium | Per-component and per-page |
The mental model: unit-test the gnarly logic, integration-test the UI, and reserve E2E for the handful of flows where a failure costs you revenue or trust.
Vitest has become the default for Vite and ESM projects, and the migration cost is low because its API is nearly identical to Jest's (describe, it, expect, vi.fn()).
Reasons teams are switching:
babel-jest gymnastics; your test config matches your app config.vite.config.ts (or vitest.config.ts) drives both build and test.Jest is still a reasonable choice for legacy or Create React App codebases that haven't moved to Vite, but for greenfield work in 2026, Vitest is the path of least resistance.
A minimal setup:
npm install -D vitest @testing-library/react \
@testing-library/jest-dom @testing-library/user-event jsdom
// 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',
},
})
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => cleanup())
RTL's whole philosophy is to test behavior, not implementation. Follow these habits and your tests will survive refactors.
getByRole('button', { name: /submit/i }) over class names or test IDs. It mirrors how users (and screen readers) find elements.getByTestId for last resort — when no semantic query fits.user-event, not fireEvent. user-event simulates real key and pointer sequences (focus, hover, typing) far more faithfully.import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
test('shows an error when the password is empty', async () => {
const user = userEvent.setup()
render(<LoginForm />)
await user.type(screen.getByRole('textbox', { name: /email/i }), 'a@b.com')
await user.click(screen.getByRole('button', { name: /log in/i }))
expect(
await screen.findByText(/password is required/i)
).toBeInTheDocument()
})
Note findByText: the async find* queries retry until the element appears, which is exactly what you want for state updates and effects. Reach for waitFor only when you're asserting on something other than an element's presence.
Stop mocking fetch. When you stub the fetch function, you couple tests to implementation details and lose confidence that your real request/response handling works.
The better boundary is the network itself. Mock Service Worker (MSW) intercepts requests at the network layer, so your components, hooks, and data-fetching libraries run their real code paths against controllable responses.
// src/test/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/user', () =>
HttpResponse.json({ id: 1, name: 'Ada Lovelace' })
),
]
// src/test/setup.ts (additions)
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
import { afterAll, afterEach, beforeAll } from 'vitest'
const server = setupServer(...handlers)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
The payoff: the same handlers are reusable everywhere — Vitest integration tests, Playwright browser runs, and Storybook stories all share one mock definition. Override a handler inside a single test with server.use(...) to exercise error and edge-case branches.
Custom hooks can't be called outside a component, so use RTL's renderHook to exercise them and act-aware utilities to flush updates.
import { renderHook, waitFor } from '@testing-library/react'
import { useUser } from './useUser'
test('loads the current user', async () => {
const { result } = renderHook(() => useUser())
expect(result.current.loading).toBe(true)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.user?.name).toBe('Ada Lovelace')
})
For data-fetching hooks built on TanStack Query or SWR, render them inside their provider and let MSW supply the responses, rather than mocking the library. Use fake timers (vi.useFakeTimers()) for debounce, polling, and retry logic so tests stay deterministic and fast.
The App Router and React Server Components (RSCs) change the testing calculus, because RSCs run only on the server and can't render in jsdom.
The current state of play:
The most maintainable pattern is to keep data fetching out of the component. Pull the async work into a plain function you can unit-test, leaving the component a thin wrapper:
// lib/getDashboard.ts — unit-testable
export async function getDashboard(userId: string) {
const res = await fetch(`${API}/dashboard/${userId}`)
if (!res.ok) throw new Error('Failed to load dashboard')
return res.json()
}
// app/dashboard/page.tsx — thin wrapper, cover via E2E
export default async function DashboardPage() {
const data = await getDashboard(await getUserId())
return <Dashboard data={data} />
}
Then let Playwright drive the rendered page in a real browser, where Server Components run in their natural environment. This split — Vitest for logic and synchronous UI, Playwright for async server-rendered flows — is the pragmatic answer for App Router apps in 2026.
Playwright has become the default E2E framework. It drives Chromium, Firefox, and WebKit from one API, runs out-of-process for parallel browser contexts, and ships auto-waiting that kills most flakiness.
Keep E2E suites small and focused on critical, cross-cutting journeys: sign-up and login, checkout and payment, and anything involving async Server Components Vitest can't render.
import { test, expect } from '@playwright/test'
test('a user can sign in and reach the dashboard', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Password').fill('correct-horse')
await page.getByRole('button', { name: 'Log in' }).click()
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
})
Playwright also has experimental component testing if you want browser-real component runs, but for most teams Vitest + jsdom covers the component tier and Playwright owns the journeys.
Automated checks catch a meaningful share of WCAG issues (commonly cited around half) and fit naturally into both tiers via axe-core.
Per component, in Vitest with vitest-axe:
import { render } from '@testing-library/react'
import { axe } from 'vitest-axe'
import { Card } from './Card'
test('Card has no accessibility violations', async () => {
const { container } = render(<Card title="Hello" />)
expect(await axe(container)).toHaveNoViolations()
})
Per page, in Playwright with @axe-core/playwright:
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test('home page is accessible', async ({ page }) => {
await page.goto('/')
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toEqual([])
})
Automation won't catch everything — focus order, meaningful alt text, and keyboard traps still need human review — but wiring axe into CI locks in the regressions you can detect automatically. Naming files *.a11y.test.tsx keeps these checks as a distinct CI step.
Your pipeline should fail fast and report clearly. A typical sequence:
tsc --noEmit and eslint run first; they're cheapest.vitest run --coverage. Vitest's parallelism keeps this quick.playwright test against a built preview of the app. Cache browser binaries and shard across runners for larger suites.# .github/workflows/test.yml (excerpt)
- run: npm run typecheck
- run: npm run lint
- run: npx vitest run --coverage
- run: npx playwright install --with-deps
- run: npx playwright test
Track coverage as a trend, not a gate to game. A blanket "100% coverage" mandate pushes people to test trivial code. Aim for high coverage on business logic and accept that some glue code isn't worth a test.
A pragmatic checklist to keep your suite valuable instead of brittle.
Do test:
Don't bother testing:
The goal isn't a number on a coverage report. It's confidence that you can ship and refactor without breaking what users rely on. A lean suite of behavior-focused integration tests, a thin layer of E2E for the money paths, and static analysis underneath everything will get you there.
Web On Dev designs, builds, and ships production software. Get a free consultation and a transparent quote.
Continue reading with these related articles that you might find interesting.
A current, practical guide to modern web development: React 19, Next.js 16, RSC, modern CSS, Core Web Vitals (INP), accessibility, and AI.
Read Modern Web Development: A Complete 2026 ...Master React performance in 2026: the React Compiler, Server Components, Suspense streaming, list virtualization, and optimizing INP and Core Web Vitals.
Read React Performance Optimization Guide (20...A current Next.js guide for 2026: App Router, Server Components, Server Actions, the new caching model, Partial Prerendering, and deployment.
Read The Complete Next.js Guide for 2026 (App...Let's discuss how we can help you achieve your digital goals with our expert team.