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 }) => (
describe('LoginForm', () => { it('shows validation errors for empty fields', async () => { render(
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(
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(
// 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(
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
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 gaterun: 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
export function createProject(overrides: Partial
// 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

