Testing in 2026: Vitest, Playwright, React Testing Library Complete Guide
Advertisement
Testing in 2026: Fast, Reliable, Automated
Untested code is technical debt. In 2026, the testing stack has converged: Vitest for unit/integration, React Testing Library for components, Playwright for E2E.
- Setup
- Unit Testing with Vitest
- React Component Testing
- Testing with Next.js: Server Components
- API Route Testing
- Playwright E2E Tests
- CI/CD Integration
Setup
# Vitest + React Testing Library
npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom
npm install -D @testing-library/user-event jsdom
# Playwright (E2E)
npm install -D @playwright/test
npx playwright install
// 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: './tests/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
})
// tests/setup.ts
import '@testing-library/jest-dom'
Unit Testing with Vitest
// lib/utils.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { formatDate, calculateDiscount, slugify } from '../lib/utils'
describe('formatDate', () => {
it('formats a date correctly', () => {
const date = new Date('2026-03-26')
expect(formatDate(date)).toBe('March 26, 2026')
})
it('handles invalid date', () => {
expect(() => formatDate(new Date('invalid'))).toThrow('Invalid date')
})
})
describe('calculateDiscount', () => {
it.each([
[100, 10, 90], // price, discount%, expected
[200, 25, 150],
[50, 0, 50],
[100, 100, 0],
])('price=%d discount=%d%% → %d', (price, discount, expected) => {
expect(calculateDiscount(price, discount)).toBe(expected)
})
it('throws for negative discount', () => {
expect(() => calculateDiscount(100, -10)).toThrow()
})
})
// Mocking
describe('fetchUser service', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns user data on success', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: true,
json: async () => ({ id: '1', name: 'Test User', email: 'test@test.com' }),
} as Response)
const user = await fetchUser('1')
expect(user).toMatchObject({ id: '1', name: 'Test User' })
expect(fetch).toHaveBeenCalledWith('/api/users/1')
})
})
React Component Testing
// components/SearchBar.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { SearchBar } from './SearchBar'
describe('SearchBar', () => {
const user = userEvent.setup()
it('renders with placeholder', () => {
render(<SearchBar onSearch={vi.fn()} />)
expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
})
it('calls onSearch when form is submitted', async () => {
const mockSearch = vi.fn()
render(<SearchBar onSearch={mockSearch} />)
await user.type(screen.getByRole('searchbox'), 'typescript')
await user.keyboard('{Enter}')
expect(mockSearch).toHaveBeenCalledWith('typescript')
})
it('debounces search input', async () => {
vi.useFakeTimers()
const mockSearch = vi.fn()
render(<SearchBar onSearch={mockSearch} debounce={300} />)
await user.type(screen.getByRole('searchbox'), 'react')
// Not called immediately
expect(mockSearch).not.toHaveBeenCalled()
// Called after debounce
vi.advanceTimersByTime(300)
await waitFor(() => expect(mockSearch).toHaveBeenCalledWith('react'))
vi.useRealTimers()
})
it('shows loading state during search', async () => {
const slowSearch = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1000)))
render(<SearchBar onSearch={slowSearch} />)
await user.type(screen.getByRole('searchbox'), 'test')
await user.keyboard('{Enter}')
expect(screen.getByRole('progressbar')).toBeInTheDocument()
expect(screen.getByRole('searchbox')).toBeDisabled()
})
})
Testing with Next.js: Server Components
// app/posts/__tests__/page.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
// Mock the database
vi.mock('@/lib/db', () => ({
prisma: {
post: {
findMany: vi.fn().mockResolvedValue([
{ id: '1', title: 'TypeScript Guide', slug: 'typescript', createdAt: new Date() },
{ id: '2', title: 'React 19 Features', slug: 'react-19', createdAt: new Date() },
]),
},
},
}))
import PostsPage from '../page'
describe('PostsPage', () => {
it('renders posts from database', async () => {
render(await PostsPage())
expect(screen.getByText('TypeScript Guide')).toBeInTheDocument()
expect(screen.getByText('React 19 Features')).toBeInTheDocument()
})
})
API Route Testing
// app/api/users/__tests__/route.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { GET, POST } from '../route'
import { NextRequest } from 'next/server'
vi.mock('@/lib/db', () => ({
prisma: {
user: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockImplementation(({ data }) => ({ id: '1', ...data })),
count: vi.fn().mockResolvedValue(0),
},
},
}))
describe('GET /api/users', () => {
it('returns users with pagination', async () => {
const req = new NextRequest('http://localhost:3000/api/users?page=1&limit=10')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('users')
expect(data).toHaveProperty('pagination')
})
})
describe('POST /api/users', () => {
it('creates user with valid data', async () => {
const req = new NextRequest('http://localhost:3000/api/users', {
method: 'POST',
body: JSON.stringify({ email: 'new@test.com', name: 'New User', password: 'pass1234' }),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(req)
expect(response.status).toBe(201)
})
})
Playwright E2E Tests
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('user can sign in with email and password', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'password123')
await page.click('[type="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.getByText('Welcome back')).toBeVisible()
})
test('shows error on invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', 'wrong@test.com')
await page.fill('[name="password"]', 'wrongpassword')
await page.click('[type="submit"]')
await expect(page.getByText('Invalid credentials')).toBeVisible()
await expect(page).toHaveURL('/login')
})
})
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
reporter: 'html',
use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry' },
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'Mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
CI/CD Integration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run test:coverage
- uses: codecov/codecov-action@v4
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
Test coverage target: 80% for unit, 100% of critical user journeys with Playwright. This combination catches ~95% of bugs before production.
Advertisement