Testing Guide 2026 — Vitest, Playwright, and React Testing Library
Advertisement
Introduction
Why This Matters
Vitest has replaced Jest as the go-to unit test runner for modern projects in 2026 — it uses the same Vite config, runs tests in parallel natively, and is 5–10x faster than Jest with ts-jest. Playwright dominates E2E testing with cross-browser support and native auto-wait.
Vitest Setup
npm install -D vitest @vitest/ui @vitest/coverage-v8// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
})Unit Testing with Vitest
// src/lib/utils.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { formatCurrency, paginate, slugify } from './utils'
describe('formatCurrency', () => {
it('formats USD correctly', () => {
expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56')
})
it('handles zero', () => {
expect(formatCurrency(0, 'USD')).toBe('$0.00')
})
it('handles negative values', () => {
expect(formatCurrency(-99.99, 'USD')).toBe('-$99.99')
})
})
describe('paginate', () => {
it('returns correct slice and metadata', () => {
const items = Array.from({ length: 50 }, (_, i) => i)
const result = paginate(items, { page: 2, limit: 10 })
expect(result.data).toHaveLength(10)
expect(result.data[0]).toBe(10)
expect(result.meta.total).toBe(50)
expect(result.meta.pages).toBe(5)
})
})
// Testing with mocks
describe('sendWelcomeEmail', () => {
const mockEmailClient = { send: vi.fn() }
beforeEach(() => {
mockEmailClient.send.mockReset()
})
it('sends email with correct recipient', async () => {
mockEmailClient.send.mockResolvedValueOnce({ id: 'msg_123' })
await sendWelcomeEmail('alice@example.com', mockEmailClient)
expect(mockEmailClient.send).toHaveBeenCalledOnce()
expect(mockEmailClient.send).toHaveBeenCalledWith(
expect.objectContaining({ to: 'alice@example.com' })
)
})
})React Component Testing
// vitest.config.ts for React
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})// src/test/setup.ts
import '@testing-library/jest-dom'// src/components/SearchInput.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { SearchInput } from './SearchInput'
describe('SearchInput', () => {
it('calls onSearch when user types', async () => {
const user = userEvent.setup()
const onSearch = vi.fn()
render(<SearchInput onSearch={onSearch} placeholder="Search posts" />)
const input = screen.getByPlaceholderText('Search posts')
await user.type(input, 'react hooks')
await waitFor(() => {
expect(onSearch).toHaveBeenCalledWith('react hooks')
})
})
it('shows clear button when input has value', async () => {
const user = userEvent.setup()
render(<SearchInput onSearch={vi.fn()} />)
const input = screen.getByRole('searchbox')
expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument()
await user.type(input, 'test')
expect(screen.getByRole('button', { name: /clear/i })).toBeInTheDocument()
})
it('clears input when clear button is clicked', async () => {
const user = userEvent.setup()
const onSearch = vi.fn()
render(<SearchInput onSearch={onSearch} />)
await user.type(screen.getByRole('searchbox'), 'test')
await user.click(screen.getByRole('button', { name: /clear/i }))
expect(screen.getByRole('searchbox')).toHaveValue('')
expect(onSearch).toHaveBeenLastCalledWith('')
})
})Playwright E2E Tests
npm install -D @playwright/test
npx playwright install// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html'], ['github']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('user can sign in with credentials', async ({ page }) => {
await page.goto('/auth/login')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'password123')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.getByText('Welcome')).toBeVisible()
})
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/auth/login')
await page.fill('[name="email"]', 'wrong@example.com')
await page.fill('[name="password"]', 'wrongpassword')
await page.click('button[type="submit"]')
await expect(page.getByText('Invalid email or password')).toBeVisible()
await expect(page).toHaveURL('/auth/login')
})
})Common Mistakes
- Testing implementation details (internal state, method calls) instead of user-observable behavior
- Not resetting mocks between tests — leads to test order dependency
- Writing E2E tests for edge cases that unit tests cover better — E2E should test critical user journeys
- Using
screen.getByTestIdeverywhere — prefer accessible queries likegetByRole,getByLabelText - Not setting
retriesin Playwright CI config — flaky tests fail builds unnecessarily
Best Practices
- Follow the testing pyramid: many unit tests, fewer component tests, few E2E tests
- Use
userEventfrom@testing-library/user-eventinstead offireEventfor realistic interaction simulation - Mock external services at the network level with Playwright's
page.route()for reliable E2E tests - Run coverage with thresholds in CI to prevent regressions in test coverage
- Co-locate test files with the modules they test (
utils.ts+utils.test.tsin the same folder)
Key Takeaways
- Vitest uses your Vite config and is 5–10x faster than Jest with ts-jest for TypeScript projects
@testing-library/user-eventsimulates real user events (keyboard, pointer) more accurately thanfireEvent- Playwright auto-waits for elements before interacting — no manual
waitForneeded for most actions - The
getByRolequery is preferred because it tests accessibility at the same time playwright.config.tswebServeroption starts the dev server automatically before tests run- E2E test
retries: 2in CI absorbs transient network and timing flakiness without failing the build - Coverage thresholds in
vitest.config.tsenforce a minimum quality floor on every PR - Playwright's
tracefiles record the full test timeline, making flaky test debugging practical
Advertisement
Related reading
React Testing with Vitest and React Testing Library — Complete Guide5 min readVitest Unit Testing with TypeScript — Complete Guide 20265 min readJest vs Vitest in 2026 — Which Testing Framework Should You Use?5 min readNode.js Testing in 2026 — Vitest, TestContainers, and Testing Without Mocking Everything9 min read