← All articles

Frontend Testing Strategies in 2026: Vitest, Playwright and a CI Pipeline That Ships with Confidence

Testing is not a phase β€” it is a culture. The testing pyramid is a guideline, not gospel. Modern frontend demands a strategy that catches regressions fast, runs in CI under five minutes, and gives your team genuine confidence to deploy on Friday. This guide covers the testing architecture, tools, and patterns used at UI Designer.

Frontend testing pyramid with Vitest unit tests, integration tests and Playwright E2E tests in a CI pipeline

Testing isn't a phase β€” it's a culture. The testing pyramid (70% unit, 20% integration, 10% E2E) is a guideline, not gospel. Modern frontend demands a testing strategy that catches regressions fast, runs in CI in under 5 minutes, and gives confidence to deploy on Friday.

This guide covers the testing strategy we use at UI Designer: tools, patterns, coverage targets, and CI integration.

The Testing Strategy Triangle

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚   E2E (Playwright)    β”‚  ← 10-15%  Critical user journeys
                    β”‚   10-20 tests            β”‚
            β”Œβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”
            β”‚       β”‚ Integration (RTL)      β”‚       β”‚  ← 20-30%  Component interactions
            β”‚       β”‚ 30-50 tests            β”‚       β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”
    β”‚       β”‚       β”‚ Unit (Vitest)                β”‚       β”‚  ← 60-70%  Pure logic, hooks, utils
    β”‚       β”‚       β”‚ 150+ tests                   β”‚       β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜

**Key principle:** Test behavior, not implementation. Test what the user sees, not how the code works.

Tool Stack (2026)

| Layer | Tool | Why | |-------|------|-----| | **Test Runner** | Vitest | Native ESM, Vite-native, Jest-compatible API, fast | | **React Testing** | React Testing Library | User-centric, accessible queries, no implementation details | | **Component Dev** | Storybook 8 + Vite | Visual development, testing-strategies-2026" class="internal-link">visual regression, docs | | **E2E** | Playwright | Multi-browser, parallel, trace viewer, codegen | | **testing-strategies-2026" class="internal-link">Visual Regression** | Chromatic / Percy | Pixel-perfect, PR comments, baseline management | | **wcag-2026" class="internal-link">Accessibility** | axe-core + jest-axe | Automated a11y in unit + CI | | **Coverage** | c8 (V8 built-in) | Fast, accurate, LCOV/HTML/JSON | | **Mutation** | Stryker | testing-strategies-2026" class="internal-link">Mutation testing for critical logic |

Unit Testing: Pure Logic & Hooks (70%)

What to Test

  • Utility functions (date, currency, validation, formatting)
  • Custom hooks (`useDebounce`, `useLocalStorage`, `useMediaQuery`)
  • State machines, reducers, selectors
  • Schema validation (Zod schemas)
  • API client error handling, retries, transforms
  • Date/number formatting, i18n helpers

What NOT to Test

  • Framework internals (React, Next.js, router)
  • Third-party library internals
  • Implementation details (private methods, internal state)
  • Trivial getters/setters
  • Styles (visual regression covers this)

Vitest Configuration

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', setupFiles: ['./test/setup.ts'], include: ['src/**/*.test.{ts,tsx}'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], thresholds: { lines: 80, functions: 80, branches: 70, statements: 80, }, exclude: [ 'node_modules/**', 'src/**/*.d.ts', 'src/**/*.stories.tsx', 'src/main.tsx', 'src/vite-env.d.ts', ], }, // Parallel execution pool: 'threads', poolOptions: { threads: { singleThread: false } }, }, resolve: { alias: { '@': path.resolve(__dirname, 'src') }, }, });

// test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';

// Auto-cleanup between tests afterEach(() => cleanup());

// Mock global objects Object.defineProperty(window, 'matchMedia', { writable: true, value: vi.fn().mockImplementation(query => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), };

// Mock ResizeObserver global.ResizeObserver = vi.fn().mockImplementation(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(), }));

Unit Test Patterns

// hooks/__tests__/useDebounce.test.ts
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from '../useDebounce';

describe('useDebounce', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers());

it('debounces value changes', () => { const { result } = renderHook(() => useDebounce('initial', 300));

act(() => { result.current[1]('updated'); }); expect(result.current[0]).toBe('initial'); // Still initial

act(() => { vi.advanceTimersByTime(300); }); expect(result.current[0]).toBe('updated'); // Now updated });

it('cancels previous timeout on rapid changes', () => { const { result, rerender } = renderHook( ({ value }) => useDebounce(value, 300), { initialProps: { value: 'a' } } );

rerender({ value: 'b' }); rerender({ value: 'c' });

act(() => { vi.advanceTimersByTime(300); }); expect(result.current[0]).toBe('c'); // Only latest value }); });

// utils/__tests__/currency.test.ts
import { formatCurrency, parseCurrency } from '../currency';

describe('formatCurrency', () => { it.each([ [1000, 'INR', 'β‚Ή1,000.00'], [1000.5, 'INR', 'β‚Ή1,000.50'], [0, 'USD', '$0.00'], [1234567.89, 'EUR', '€1,234,567.89'], ])('formats %d %s as %s', (amount, currency, expected) => { expect(formatCurrency(amount, currency)).toBe(expected); });

it('handles negative amounts', () => { expect(formatCurrency(-500, 'INR')).toBe('-β‚Ή500.00'); }); });

// hooks/__tests__/useLocalStorage.test.tsx
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from '../useLocalStorage';

describe('useLocalStorage', () => { beforeEach(() => { localStorage.clear(); jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {}); });

it('reads initial value from localStorage', () => { localStorage.setItem('theme', 'dark'); const { result } = renderHook(() => useLocalStorage('theme', 'light')); expect(result.current[0]).toBe('dark'); });

it('updates localStorage on value change', () => { const { result } = renderHook(() => useLocalStorage('theme', 'light'));

act(() => { result.current[1]('dark'); });

expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'dark'); expect(result.current[0]).toBe('dark'); });

it('handles JSON serialization', () => { const { result, rerender } = renderHook( ({ initial }) => useLocalStorage('user', initial), { initialProps: { initial: { name: 'John' } } } );

act(() => { result.current[1]({ name: 'Jane' }); });

expect(localStorage.setItem).toHaveBeenCalledWith( 'user', JSON.stringify({ name: 'Jane' }) ); }); });

Integration Testing: Component Behavior (20-30%)

What to Test

  • Component rendering with props
  • User interactions (click, type, select)
  • Form submission, validation, error states
  • Conditional rendering (loading, error, empty states)
  • Router integration (navigation, params)
  • Context providers (theme, auth, i18n)
  • API mocking (MSW for integration tests)

React Testing Library Patterns

// components/__tests__/LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { LoginForm } from '../LoginForm';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MSW } from 'msw';

// MSW handler for API mocking const handlers = [ rest.post('/api/auth/login', (req, res, ctx) => { return res(ctx.json({ token: 'mock-token', user: { name: 'John' } })); }), ];

const server = setupServer(...handlers);

beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());

const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} );

describe('LoginForm', () => { it('shows validation errors for empty fields', async () => { render(, { wrapper });

fireEvent.click(screen.getByRole('button', { name: /sign in/i }));

await waitFor(() => { expect(screen.getByText(/email is required/i)).toBeInTheDocument(); expect(screen.getByText(/password is required/i)).toBeInTheDocument(); }); });

it('submits form and redirects on success', async () => { render(, { wrapper });

fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'john@example.com' } }); fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'password123' } }); fireEvent.click(screen.getByRole('button', { name: /sign in/i }));

await waitFor(() => { expect(screen.getByText(/welcome, john/i)).toBeInTheDocument(); }); });

it('shows error message on failed login', async () => { server.use( rest.post('/api/auth/login', (req, res, ctx) => { return res(ctx.status(401), ctx.json({ message: 'Invalid credentials' })); }) );

render(, { wrapper }); // ... fill form, submit // assert error message shown }); });

// components/__tests__/DataTable.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { DataTable } from '../DataTable';
import { userEvent } from '@testing-library/user-event';

const mockData = [ { id: '1', name: 'Project Alpha', status: 'active' }, { id: '2', name: 'Project Beta', status: 'completed' }, { id: '3', name: 'Project Gamma', status: 'archived' }, ];

describe('DataTable', () => { it('renders data and supports sorting', async () => { const user = userEvent.setup(); render();

expect(screen.getByText('Project Alpha')).toBeInTheDocument(); expect(screen.getByText('Project Beta')).toBeInTheDocument();

// Click sortable header await user.click(screen.getByRole('columnheader', { name: /name/i }));

await waitFor(() => { // First row should now be "Project Alpha" (alphabetically first) expect(screen.getByRole('row').textContent).toContain('Project Alpha'); }); });

it('filters data by search term', async () => { const user = userEvent.setup(); render();

await user.type(screen.getByPlaceholderText(/search/i), 'beta');

await waitFor(() => { expect(screen.getByText('Project Beta')).toBeInTheDocument(); expect(screen.queryByText('Project Alpha')).not.toBeInTheDocument(); }); });

it('handles empty state', () => { render(); expect(screen.getByText(/no data/i)).toBeInTheDocument(); }); });

E2E Testing: Critical User Journeys (10-15%)

What to Test

  • Authentication flow (login, register, password reset, MFA)
  • Critical business flows (checkout, subscription, onboarding)
  • Multi-step forms (wizards, checkout, onboarding)
  • Payment flows (Stripe, Razorpay integration)
  • Permission/authorization boundaries
  • Cross-browser/device critical paths

Playwright Configuration

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, reporter: [ ['html', { outputFolder: 'playwright-report' }], ['github'], // For CI annotations ], use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } }, { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } }, ], webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 120000, }, });

Page Object Model

// e2e/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';

export class LoginPage { readonly page: Page; readonly emailInput: Locator; readonly passwordInput: Locator; readonly submitButton: Locator; readonly errorMessage: Locator;

constructor(page: Page) { this.page = page; this.emailInput = page.getByLabel(/email/i); this.passwordInput = page.getByLabel(/password/i); this.submitButton = page.getByRole('button', { name: /sign in/i }); this.errorMessage = page.getByRole('alert'); }

async goto() { await this.page.goto('/login'); await expect(this.page).toHaveURL(/\/login/); }

async login(email: string, password: string) { await this.emailInput.fill(email); await this.passwordInput.fill(password); await this.submitButton.click(); }

async expectError(message: string) { await expect(this.errorMessage).toContainText(message); }

async expectRedirect(path: string) { await expect(this.page).toHaveURL(new RegExp(path)); } }

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test.describe('Authentication', () => { let loginPage: LoginPage;

test.beforeEach(async ({ page }) => { loginPage = new LoginPage(page); await loginPage.goto(); });

test('successful login redirects to dashboard', async () => { await loginPage.login('john@example.com', 'password123'); await loginPage.expectRedirect('/dashboard'); await expect(loginPage.page.getByText('Welcome, John')).toBeVisible(); });

test('shows error for invalid credentials', async () => { await loginPage.login('wrong@example.com', 'wrong'); await loginPage.expectError('Invalid credentials'); });

test('shows validation errors for empty fields', async () => { await loginPage.page.click('button:has-text("Sign in")'); await expect(loginPage.page.getByText('Email is required')).toBeVisible(); await expect(loginPage.page.getByText('Password is required')).toBeVisible(); });

test('password reset flow works', async () => { await loginPage.page.click('text=Forgot password?'); await expect(loginPage.page).toHaveURL(/\/forgot-password/);

await loginPage.page.fill('[name="email"]', 'john@example.com'); await loginPage.page.click('button:has-text("Send reset link")');

await expect(loginPage.page.getByText('Check your email')).toBeVisible(); }); });

// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Checkout Flow', () => { test.beforeEach(async ({ page }) => { // Login via API to skip UI login await page.route('**/api/auth/me', route => { route.fulfill({ json: { user: { id: '1', name: 'Test User' } } }); }); await page.goto('/cart'); });

test('complete checkout with Stripe test card', async ({ page }) => { // Add item to cart await page.goto('/products/test-product'); await page.click('button:has-text("Add to cart")');

// Go to checkout await page.click('a:has-text("Checkout")');

// Fill shipping await page.fill('[name="email"]', 'test@example.com'); await page.fill('[name="name"]', 'Test User'); await page.fill('[name="address"]', '123 Test St'); await page.fill('[name="city"]', 'Test City'); await page.fill('[name="postal_code"]', '12345'); await page.click('button:has-text("Continue to payment")');

// Stripe Elements iframe const cardFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]'); await cardFrame.locator('[name="cardnumber"]').fill('4242424242424242'); await cardFrame.locator('[name="exp-date"]').fill('12/30'); await cardFrame.locator('[name="cvc"]').fill('123'); await cardFrame.locator('[name="postal"]').fill('12345');

// Submit await page.click('button:has-text("Pay")');

// Success await expect(page.getByText(/order confirmed/i)).toBeVisible(); await expect(page.getByText(/order #/i)).toBeVisible(); }); });

Visual Regression Testing

Chromatic Integration

# .github/workflows/chromatic.yml
name: Chromatic
on: [push, pull_request]
jobs:
  chromatic:
    runs-on: ubuntu-latest
    steps:

with: { fetch-depth: 0 }

with: { node-version: 20, cache: 'npm' }

with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} buildScriptName: build:storybook autoAcceptChanges: main exitOnceUploaded: true

  • uses: actions/checkout@v4
  • uses: actions/setup-node@v4
  • run: npm ci
  • uses: chromaui/action@v1
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta = { title: 'Components/Button', component: Button, parameters: { chromatic: { viewports: [320, 768, 1200] }, }, tags: ['autodocs'], argTypes: { variant: { control: 'select', options: ['primary', 'secondary', 'outline', 'ghost'] }, size: { control: 'select', options: ['sm', 'md', 'lg'] }, }, };

export default meta; type Story = StoryObj;

export const Primary: Story = { args: { variant: 'primary', children: 'Primary' } }; export const Secondary: Story = { args: { variant: 'secondary', children: 'Secondary' } }; export const Loading: Story = { args: { loading: true, children: 'Loading' } }; export const Disabled: Story = { args: { disabled: true, children: 'Disabled' } };

Accessibility Testing (Automated)

// test/a11y.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from '@/components/ui/Button';

expect.extend(toHaveNoViolations);

describe('wcag-2026" class="internal-link">Accessibility', () => { it('Button has no a11y violations', async () => { const { container } = render(); const results = await axe(container); expect(results).toHaveNoViolations(); });

it('Button with loading state announces correctly', async () => { const { container } = render(); const results = await axe(container); expect(results).toHaveNoViolations(); }); });

# .github/workflows/ci.yml - Accessibility gate

run: npm run test:a11y # Or integrated in test suite

  • name: Accessibility Tests

CI/CD Pipeline

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:

with: { node-version: 20, cache: 'npm' }

uses: codecov/codecov-action@v4 with: { files: ./coverage/lcov.info }

  • uses: actions/checkout@v4
  • uses: actions/setup-node@v4
  • run: npm ci
  • run: npm run lint
  • run: npm run typecheck
  • run: npm run test -- --run --reporter=verbose
  • run: npm run test:coverage
  • name: Upload coverage

e2e: runs-on: ubuntu-latest needs: test steps:

with: { node-version: 20, cache: 'npm' }

if: failure() with: name: playwright-report path: playwright-report/ retention-days: 7

  • uses: actions/checkout@v4
  • uses: actions/setup-node@v4
  • run: npm ci
  • run: npx playwright install --with-deps chromium
  • run: npm run build
  • run: npm run preview -- --port 3000 &
  • run: npx playwright test
  • uses: actions/upload-artifact@v4

chromatic: runs-on: ubuntu-latest needs: test steps:

with: { fetch-depth: 0 }

with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} buildScriptName: build:storybook

  • uses: actions/checkout@v4
  • uses: chromaui/action@v1

Coverage Targets (Enforced in CI)

// package.json
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:a11y": "vitest run --testNamePattern=a11y",
    "test:e2e": "playwright test",
    "test:update": "npx playwright test --update-snapshots"
  }
}
// vitest.config.ts - Coverage thresholds
coverage: {
  thresholds: {
    lines: 80,
    functions: 80,
    branches: 70,
    statements: 80,
  },
}

Mutation Testing (Critical Logic Only)

# Install Stryker
npm install -D @stryker-mutator/core @stryker-mutator/vitest-runner
// stryker.conf.json
{
  "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
  "mutator": "typescript",
  "testRunner": "vitest",
  "pattern": ["src/utils/**/*.ts", "src/hooks/**/*.ts"],
  "thresholds": { "high": 80, "low": 60, "break": 50 },
  "reporters": ["html", "clear-text", "progress"],
  "concurrency": 4
}

Run: `npx stryker run` β€” Only on critical utils (currency, validation, auth helpers).

Test Data Management

// test/factories.ts
import { faker } from '@faker-js/faker';

export function createUser(overrides: Partial = {}): User { return { id: faker.string.uuid(), name: faker.person.fullName(), email: faker.internet.email(), avatar: faker.image.avatar(), role: faker.helpers.arrayElement(['user', 'admin']), createdAt: faker.date.past().toISOString(), ...overrides, }; }

export function createProject(overrides: Partial = {}): Project { return { id: faker.string.uuid(), name: faker.company.buzzPhrase(), description: faker.lorem.paragraph(), status: faker.helpers.arrayElement(['active', 'completed', 'archived']), ownerId: faker.string.uuid(), createdAt: faker.date.past().toISOString(), ...overrides, }; }

// Usage in tests
import { createUser, createProject } from '@/test/factories';

it('assigns project to user', () => { const user = createUser({ role: 'admin' }); const project = createProject({ ownerId: user.id });

expect(project.ownerId).toBe(user.id); });

The UI Designer Testing Standard

Every project ships with:

  • βœ… Unit tests for all hooks, utils, schemas (80% coverage)
  • βœ… Integration tests for all forms, tables, modals (20-30% of tests)
  • βœ… E2E for auth, checkout, critical flows (5-10 scenarios)
  • βœ… Visual regression for all components (Chromatic)
  • βœ… Accessibility in CI (axe-core, zero violations)
  • βœ… Coverage gates in CI (80/80/70/80)
  • βœ… Mutation testing on critical utils
  • βœ… Storybook with Chromatic for visual review

Ready for a Testing Audit?

Book a **Testing Strategy Audit** β€” we'll audit your current test suite, identify gaps, set up CI with coverage gates, and implement a testing culture your team will actually maintain.

ui-designer.in/testing-audit