Testing Guide 2026 — Vitest, Playwright, and React Testing Library

Sanjeev SharmaSanjeev Sharma
5 min read

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.getByTestId everywhere — prefer accessible queries like getByRole, getByLabelText
  • Not setting retries in Playwright CI config — flaky tests fail builds unnecessarily

Best Practices

  • Follow the testing pyramid: many unit tests, fewer component tests, few E2E tests
  • Use userEvent from @testing-library/user-event instead of fireEvent for 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.ts in 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-event simulates real user events (keyboard, pointer) more accurately than fireEvent
  • Playwright auto-waits for elements before interacting — no manual waitFor needed for most actions
  • The getByRole query is preferred because it tests accessibility at the same time
  • playwright.config.ts webServer option starts the dev server automatically before tests run
  • E2E test retries: 2 in CI absorbs transient network and timing flakiness without failing the build
  • Coverage thresholds in vitest.config.ts enforce a minimum quality floor on every PR
  • Playwright's trace files record the full test timeline, making flaky test debugging practical

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading