Frontend Testing Implementation Stack
1. Jest Vitest Unit Testing Setup
| Feature | Jest | Vitest | Use Case |
|---|---|---|---|
| Configuration | jest.config.js | vitest.config.ts | Test environment setup |
| Speed | Standard | Fast (Vite) | Large test suites |
| Watch Mode | --watch flag | Built-in HMR | Development workflow |
| Mocking | jest.mock() | vi.mock() | Module mocking |
| Snapshot Testing | toMatchSnapshot() | toMatchSnapshot() | UI regression |
| Coverage | Built-in Istanbul | c8 or Istanbul | Code coverage reports |
Example: Jest and Vitest comprehensive unit testing setup
// Jest Configuration (jest.config.js)
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleNameMapper: {
"\\.(css|less|scss|sass)$": "identity-obj-proxy",
"^@/(.*)$": "<rootDir>/src/$1",
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/main.tsx',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
// Vitest Configuration (vitest.config.ts)
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.ts',
css: true,
coverage: {
provider: 'c8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/setupTests.ts'],
},
},
resolve: {
alias: {
'@': '/src',
},
},
});
// Setup Tests (src/setupTests.ts)
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
// Basic Component Test
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('handles click events', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
const button = screen.getByRole('button');
await userEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
// Async Test
import { waitFor } from '@testing-library/react';
describe('UserList', () => {
it('fetches and displays users', async () => {
render(<UserList />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
});
// Mock Functions
import { vi } from 'vitest';
const mockFetch = vi.fn();
global.fetch = mockFetch;
mockFetch.mockResolvedValueOnce({
json: async () => ({ name: 'John' }),
});
// Snapshot Testing
it('matches snapshot', () => {
const { container } = render(<Card title="Test" />);
expect(container.firstChild).toMatchSnapshot();
});
2. React Testing Library User Events
| Method | Purpose | Async | Use Case |
|---|---|---|---|
| userEvent.click() | Simulate click | Yes | Buttons, links |
| userEvent.type() | Simulate typing | Yes | Input fields |
| userEvent.clear() | Clear input | Yes | Reset forms |
| userEvent.selectOptions() | Select dropdown | Yes | Select elements |
| userEvent.upload() | File upload | Yes | File inputs |
| userEvent.hover() | Hover element | Yes | Tooltips, dropdowns |
Example: React Testing Library comprehensive user interaction tests
// Install
npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom
// Form Testing
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits form with user input', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /login/i });
await user.type(emailInput, 'test@example.com');
await user.type(passwordInput, 'password123');
await user.click(submitButton);
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
it('shows validation errors', async () => {
const user = userEvent.setup();
render(<LoginForm />);
const submitButton = screen.getByRole('button', { name: /login/i });
await user.click(submitButton);
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
});
// Query Methods
// getBy* - Throws error if not found (use for elements that should exist)
const button = screen.getByRole('button', { name: /submit/i });
// queryBy* - Returns null if not found (use for elements that shouldn't exist)
const error = screen.queryByText(/error/i);
expect(error).not.toBeInTheDocument();
// findBy* - Async, waits for element (use for elements that appear after async operations)
const message = await screen.findByText(/success/i);
// Select Dropdown Testing
it('selects option from dropdown', async () => {
const user = userEvent.setup();
render(<CountrySelector />);
const select = screen.getByRole('combobox');
await user.selectOptions(select, 'usa');
expect(screen.getByRole('option', { name: 'USA' }).selected).toBe(true);
});
// File Upload Testing
it('uploads file', async () => {
const user = userEvent.setup();
const file = new File(['hello'], 'hello.png', { type: 'image/png' });
render(<FileUpload />);
const input = screen.getByLabelText(/upload file/i);
await user.upload(input, file);
expect(input.files[0]).toBe(file);
expect(input.files).toHaveLength(1);
});
// Checkbox and Radio Testing
it('toggles checkbox', async () => {
const user = userEvent.setup();
render(<TermsCheckbox />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
});
// Hover and Focus Testing
it('shows tooltip on hover', async () => {
const user = userEvent.setup();
render(<TooltipButton />);
const button = screen.getByRole('button');
await user.hover(button);
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
await user.unhover(button);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
// Keyboard Navigation
it('navigates with keyboard', async () => {
const user = userEvent.setup();
render(<TabNavigation />);
const firstTab = screen.getByRole('tab', { name: /first/i });
firstTab.focus();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('tab', { name: /second/i })).toHaveFocus();
await user.keyboard('{Enter}');
expect(screen.getByRole('tabpanel')).toHaveTextContent('Second content');
});
// Custom Queries
import { within } from '@testing-library/react';
it('finds nested elements', () => {
render(<UserCard />);
const card = screen.getByRole('article');
const heading = within(card).getByRole('heading');
expect(heading).toHaveTextContent('John Doe');
});
3. Cypress Playwright E2E Automation
| Feature | Cypress | Playwright | Best For |
|---|---|---|---|
| Browser Support | Chrome, Firefox, Edge | All + Safari WebKit | Cross-browser testing |
| Execution | In-browser | Node.js | Speed and reliability |
| Auto-waiting | Built-in | Built-in | Flaky test reduction |
| Debugging | Time-travel UI | Inspector, trace viewer | Test development |
| Parallelization | Paid (Dashboard) | Free (built-in) | CI/CD speed |
| Mobile Testing | Viewport only | Device emulation | Mobile workflows |
Example: Cypress and Playwright E2E testing
// Cypress Installation and Setup
npm install --save-dev cypress
// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: false,
screenshotOnRunFailure: true,
},
});
// Cypress Test (cypress/e2e/login.cy.ts)
describe('Login Flow', () => {
beforeEach(() => {
cy.visit('/login');
});
it('logs in successfully', () => {
cy.get('[data-testid="email-input"]').type('test@example.com');
cy.get('[data-testid="password-input"]').type('password123');
cy.get('[data-testid="login-button"]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome back').should('be.visible');
});
it('shows error for invalid credentials', () => {
cy.get('[data-testid="email-input"]').type('wrong@example.com');
cy.get('[data-testid="password-input"]').type('wrong');
cy.get('[data-testid="login-button"]').click();
cy.contains('Invalid credentials').should('be.visible');
});
});
// Cypress Custom Commands (cypress/support/commands.ts)
Cypress.Commands.add('login', (email, password) => {
cy.session([email, password], () => {
cy.visit('/login');
cy.get('[data-testid="email-input"]').type(email);
cy.get('[data-testid="password-input"]').type(password);
cy.get('[data-testid="login-button"]').click();
cy.url().should('include', '/dashboard');
});
});
// Use custom command
it('accesses protected page', () => {
cy.login('test@example.com', 'password123');
cy.visit('/profile');
cy.contains('Profile').should('be.visible');
});
// Playwright Installation and Setup
npm install --save-dev @playwright/test
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
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: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
// Playwright Test (tests/login.spec.ts)
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('logs in successfully', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="login-button"]');
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.locator('text=Welcome back')).toBeVisible();
});
test('handles network errors', async ({ page }) => {
await page.route('**/api/login', route => route.abort());
await page.goto('/login');
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="login-button"]');
await expect(page.locator('text=Network error')).toBeVisible();
});
});
// Playwright Fixtures (Custom Setup)
import { test as base } from '@playwright/test';
type MyFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<MyFixtures>({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="login-button"]');
await page.waitForURL('**/dashboard');
await use(page);
},
});
// Use fixture
test('accesses profile', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/profile');
await expect(authenticatedPage.locator('h1')).toContainText('Profile');
});
4. Storybook Component Testing
| Feature | Purpose | Benefit | Use Case |
|---|---|---|---|
| Component Isolation | Develop in isolation | Focus on single component | UI development |
| Stories | Component states | Document all variants | Design system |
| Controls | Interactive props | Live editing | Testing variations |
| Actions | Event logging | Track interactions | Event handlers |
| Docs | Auto-generated docs | Component documentation | Team collaboration |
| Addons | Extend functionality | A11y, testing, design | Enhanced workflow |
Example: Storybook setup and component stories
// Install Storybook
npx storybook@latest init
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;
// Button Component (src/components/Button.tsx)
import React from 'react';
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
children,
onClick,
disabled,
}) => {
return (
<button
className={`btn btn-${variant} btn-${size}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
};
// Button Stories (src/components/Button.stories.tsx)
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
},
args: {
onClick: fn(),
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
},
};
export const Secondary: Story = {
args: {
variant: 'secondary',
children: 'Secondary Button',
},
};
export const Danger: Story = {
args: {
variant: 'danger',
children: 'Danger Button',
},
};
export const Small: Story = {
args: {
size: 'sm',
children: 'Small Button',
},
};
export const Large: Story = {
args: {
size: 'lg',
children: 'Large Button',
},
};
export const Disabled: Story = {
args: {
disabled: true,
children: 'Disabled Button',
},
};
// Interaction Testing
import { within, userEvent } from '@storybook/test';
export const WithInteractions: Story = {
args: {
children: 'Click me',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
},
};
// Complex Story with Decorators
export const WithContext: Story = {
args: {
children: 'Themed Button',
},
decorators: [
(Story) => (
<div style={{ padding: '3rem', background: '#f0f0f0' }}>
<Story />
</div>
),
],
};
// Form Story (src/components/LoginForm.stories.tsx)
import { LoginForm } from './LoginForm';
const meta: Meta<typeof LoginForm> = {
title: 'Forms/LoginForm',
component: LoginForm,
};
export default meta;
export const Default: Story = {};
export const WithError: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /login/i }));
// Verify error messages appear
await canvas.findByText(/email is required/i);
},
};
export const SuccessfulLogin: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
canvas.getByLabelText(/email/i),
'test@example.com'
);
await userEvent.type(
canvas.getByLabelText(/password/i),
'password123'
);
await userEvent.click(canvas.getByRole('button', { name: /login/i }));
},
};
5. Istanbul Coverage.js Test Coverage
| Metric | Definition | Threshold | Importance |
|---|---|---|---|
| Line Coverage | % of lines executed | 80-90% | Basic coverage metric |
| Branch Coverage | % of if/else branches | 75-85% | Logic coverage |
| Function Coverage | % of functions called | 80-90% | API coverage |
| Statement Coverage | % of statements executed | 80-90% | Code execution |
| Uncovered Lines | Lines not executed | Minimize | Gap identification |
| Coverage Reports | HTML/JSON/LCOV | CI integration | Tracking trends |
Example: Istanbul coverage configuration and reporting
// Jest with Coverage (package.json)
{
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage",
"test:coverage:watch": "jest --coverage --watchAll"
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts",
"!src/**/*.stories.tsx",
"!src/main.tsx",
"!src/vite-env.d.ts"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
},
"src/components/": {
"branches": 90,
"functions": 90,
"lines": 90,
"statements": 90
}
},
"coverageReporters": [
"text",
"text-summary",
"html",
"lcov",
"json"
],
"coveragePathIgnorePatterns": [
"/node_modules/",
"/dist/",
"/coverage/"
]
}
}
// Vitest with c8 Coverage (vitest.config.ts)
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'c8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/setupTests.ts',
'**/*.d.ts',
'**/*.config.ts',
'**/mockData',
'**/*.stories.tsx',
],
all: true,
lines: 80,
functions: 80,
branches: 80,
statements: 80,
perFile: true,
},
},
});
// GitHub Actions CI with Coverage (..github/workflows/test.yml)
name: Test Coverage
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
flags: unittests
name: codecov-umbrella
- name: Comment PR with coverage
uses: romeovs/lcov-reporter-action@v0.3.1
with:
lcov-file: ./coverage/lcov.info
github-token: ${{ secrets.GITHUB_TOKEN }}
// Custom Coverage Script (scripts/coverage-check.js)
const fs = require('fs');
const path = require('path');
const coverageSummary = JSON.parse(
fs.readFileSync(path.join(__dirname, '../coverage/coverage-summary.json'), 'utf8')
);
const thresholds = {
lines: 80,
statements: 80,
functions: 80,
branches: 80,
};
let failed = false;
Object.entries(thresholds).forEach(([metric, threshold]) => {
const coverage = coverageSummary.total[metric].pct;
console.log(`${metric}: ${coverage}% (threshold: ${threshold}%)`);
if (coverage < threshold) {
console.error(`❌ ${metric} coverage ${coverage}% is below threshold ${threshold}%`);
failed = true;
} else {
console.log(`✅ ${metric} coverage passed`);
}
});
if (failed) {
process.exit(1);
}
// Exclude files from coverage
/* istanbul ignore next */
function debugOnlyFunction() {
console.log('Debug info');
}
// Ignore specific lines
/* istanbul ignore next */
if (process.env.NODE_ENV === 'development') {
console.log('Dev mode');
}
// Ignore branches
/* istanbul ignore else */
if (condition) {
doSomething();
}
// Package.json scripts for coverage gates
{
"scripts": {
"test:coverage": "vitest run --coverage",
"test:coverage:check": "npm run test:coverage && node scripts/coverage-check.js",
"precommit": "npm run test:coverage:check"
}
}
6. Visual Regression Testing Chromatic
| Tool | Method | Platform | Use Case |
|---|---|---|---|
| Chromatic | Cloud-based snapshots | Storybook integration | UI review workflow |
| Percy | Visual diffs | CI/CD integration | Pull request reviews |
| BackstopJS | Screenshot comparison | Headless browser | Local testing |
| Playwright Screenshots | Built-in snapshots | Test suite | E2E visual testing |
| Jest Image Snapshot | Image comparison | Unit tests | Component snapshots |
| Applitools | AI-powered diffs | Cross-browser | Enterprise testing |
Example: Visual regression testing setup
// Chromatic Setup
npm install --save-dev chromatic
// package.json
{
"scripts": {
"chromatic": "chromatic --project-token=<your-project-token>"
}
}
// .github/workflows/chromatic.yml
name: Chromatic
on: push
jobs:
visual-regression:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install dependencies
run: npm ci
- name: Publish to Chromatic
uses: chromaui/action@v1
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
autoAcceptChanges: main
// Playwright Visual Regression
import { test, expect } from '@playwright/test';
test('homepage visual regression', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png');
});
test('button states visual regression', async ({ page }) => {
await page.goto('/components/button');
const button = page.locator('[data-testid="primary-button"]');
await expect(button).toHaveScreenshot('button-default.png');
await button.hover();
await expect(button).toHaveScreenshot('button-hover.png');
await button.focus();
await expect(button).toHaveScreenshot('button-focus.png');
});
// Playwright Config for Visual Tests
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixels: 100,
threshold: 0.2,
},
},
use: {
screenshot: 'only-on-failure',
},
});
// Update screenshots command
// npx playwright test --update-snapshots
// BackstopJS Configuration (backstop.json)
{
"id": "frontend_visual_test",
"viewports": [
{
"label": "desktop",
"width": 1280,
"height": 720
},
{
"label": "tablet",
"width": 768,
"height": 1024
},
{
"label": "mobile",
"width": 375,
"height": 667
}
],
"scenarios": [
{
"label": "Homepage",
"url": "http://localhost:3000",
"referenceUrl": "",
"readyEvent": "",
"readySelector": "",
"delay": 500,
"hideSelectors": [],
"removeSelectors": [],
"hoverSelector": "",
"clickSelector": "",
"postInteractionWait": 0,
"selectors": ["document"],
"selectorExpansion": true,
"misMatchThreshold": 0.1
},
{
"label": "Button Hover",
"url": "http://localhost:3000/components",
"hoverSelector": ".btn-primary",
"delay": 200
}
],
"paths": {
"bitmaps_reference": "backstop_data/bitmaps_reference",
"bitmaps_test": "backstop_data/bitmaps_test",
"engine_scripts": "backstop_data/engine_scripts",
"html_report": "backstop_data/html_report",
"ci_report": "backstop_data/ci_report"
},
"report": ["browser", "CI"],
"engine": "puppeteer"
}
// BackstopJS Commands
// Reference (baseline): npx backstop reference
// Test: npx backstop test
// Approve changes: npx backstop approve
// Jest Image Snapshot
npm install --save-dev jest-image-snapshot
// setupTests.ts
import { toMatchImageSnapshot } from 'jest-image-snapshot';
expect.extend({ toMatchImageSnapshot });
// Component visual test
import { render } from '@testing-library/react';
import { Button } from './Button';
it('matches visual snapshot', () => {
const { container } = render(<Button>Click me</Button>);
expect(container.firstChild).toMatchImageSnapshot({
customSnapshotsDir: '__image_snapshots__',
customDiffDir: '__image_snapshots__/__diff_output__',
failureThreshold: 0.01,
failureThresholdType: 'percent',
});
});
// Percy Configuration (.percy.yml)
version: 2
static:
files: '**/*.html'
ignore-files: '**/node_modules/**'
snapshot:
widths:
- 375
- 768
- 1280
min-height: 1024
percy-css: |
.animation { animation: none !important; }
.transition { transition: none !important; }
// Percy with Storybook
npx percy storybook http://localhost:6006
Frontend Testing Best Practices Summary
- Unit Testing - Jest/Vitest for fast unit tests, mock dependencies, 80%+ coverage, test pure functions and business logic
- Component Testing - React Testing Library with userEvent for realistic user interactions, avoid implementation details, query by role/label
- E2E Testing - Cypress for quick setup with time-travel debugging, Playwright for multi-browser with built-in parallelization
- Storybook - Develop components in isolation, document all states, interaction testing, visual testing with Chromatic
- Coverage Metrics - Track line, branch, function, statement coverage; set thresholds per directory; integrate with CI/CD
- Visual Regression - Chromatic for Storybook, Playwright screenshots, Percy for PR reviews, catch unintended UI changes
- Testing Strategy - Testing pyramid: many unit tests, some integration tests, few E2E tests; fast feedback, reliable CI