Testing in 2026: Vitest, Playwright, React Testing Library Complete Guide

Sanjeev SharmaSanjeev Sharma
5 min read

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

# 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro