--- name: frontend-testing description: Comprehensive frontend testing with Playwright, Cypress, Jest, and React Testing Library. Covers E2E testing, component testing, unit testing, visual regression, and accessibility testing. Use for quality assurance, CI/CD integration, and test automation. --- # Frontend Testing Complete testing strategies for React and frontend applications. ## Testing Pyramid ``` /\ / \ E2E Tests (Playwright/Cypress) /----\ - Critical user flows / \ - Cross-browser testing /--------\ Integration Tests / \ - Component interactions /------------\ Unit Tests (Jest/Vitest) - Functions, hooks, utilities ``` ## Unit Testing with Jest/Vitest ### Setup ```bash # Jest npm install -D jest @types/jest ts-jest @testing-library/react @testing-library/jest-dom # Vitest (faster, ESM-native) npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom ``` ### Jest Configuration ```js // jest.config.js module.exports = { testEnvironment: "jsdom", setupFilesAfterEnv: ["/jest.setup.ts"], moduleNameMapper: { "^@/(.*)$": "/src/$1", "\\.(css|less|scss)$": "identity-obj-proxy", }, transform: { "^.+\\.(ts|tsx)$": "ts-jest", }, collectCoverageFrom: [ "src/**/*.{ts,tsx}", "!src/**/*.d.ts", "!src/**/*.stories.tsx", ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80, }, }, }; ``` ### Testing Utilities ```tsx // src/utils/format.ts export function formatCurrency(amount: number, currency = "USD"): string { return new Intl.NumberFormat("en-US", { style: "currency", currency, }).format(amount); } export function formatDate(date: Date | string): string { return new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric", }).format(new Date(date)); } // src/utils/format.test.ts import { formatCurrency, formatDate } from "./format"; describe("formatCurrency", () => { it("formats USD correctly", () => { expect(formatCurrency(1234.56)).toBe("$1,234.56"); }); it("formats EUR correctly", () => { expect(formatCurrency(1234.56, "EUR")).toBe("€1,234.56"); }); it("handles zero", () => { expect(formatCurrency(0)).toBe("$0.00"); }); it("handles negative numbers", () => { expect(formatCurrency(-50)).toBe("-$50.00"); }); }); describe("formatDate", () => { it("formats Date object", () => { const date = new Date("2024-03-15"); expect(formatDate(date)).toBe("March 15, 2024"); }); it("formats ISO string", () => { expect(formatDate("2024-03-15")).toBe("March 15, 2024"); }); }); ``` ### Testing Custom Hooks ```tsx // src/hooks/useCounter.ts import { useState, useCallback } from "react"; export function useCounter(initialValue = 0) { const [count, setCount] = useState(initialValue); const increment = useCallback(() => setCount((c) => c + 1), []); const decrement = useCallback(() => setCount((c) => c - 1), []); const reset = useCallback(() => setCount(initialValue), [initialValue]); return { count, increment, decrement, reset }; } // src/hooks/useCounter.test.ts import { renderHook, act } from "@testing-library/react"; import { useCounter } from "./useCounter"; describe("useCounter", () => { it("initializes with default value", () => { const { result } = renderHook(() => useCounter()); expect(result.current.count).toBe(0); }); it("initializes with custom value", () => { const { result } = renderHook(() => useCounter(10)); expect(result.current.count).toBe(10); }); it("increments count", () => { const { result } = renderHook(() => useCounter()); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); }); it("decrements count", () => { const { result } = renderHook(() => useCounter(5)); act(() => { result.current.decrement(); }); expect(result.current.count).toBe(4); }); it("resets to initial value", () => { const { result } = renderHook(() => useCounter(10)); act(() => { result.current.increment(); result.current.increment(); result.current.reset(); }); expect(result.current.count).toBe(10); }); }); ``` ## Component Testing with React Testing Library ### Basic Component Testing ```tsx // src/components/Button.tsx interface ButtonProps { children: React.ReactNode; onClick?: () => void; disabled?: boolean; variant?: "primary" | "secondary" | "danger"; loading?: boolean; } export function Button({ children, onClick, disabled = false, variant = "primary", loading = false, }: ButtonProps) { return ( ); } // src/components/Button.test.tsx import { render, screen, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Button } from "./Button"; describe("Button", () => { it("renders children", () => { render(); expect( screen.getByRole("button", { name: /click me/i }), ).toBeInTheDocument(); }); it("calls onClick when clicked", async () => { const handleClick = jest.fn(); const user = userEvent.setup(); render(); await user.click(screen.getByRole("button")); expect(handleClick).toHaveBeenCalledTimes(1); }); it("is disabled when disabled prop is true", () => { render(); expect(screen.getByRole("button")).toBeDisabled(); }); it("shows loading state", () => { render(); expect(screen.getByRole("button")).toHaveTextContent("Loading..."); expect(screen.getByRole("button")).toBeDisabled(); expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true"); }); it("applies variant class", () => { render(); expect(screen.getByRole("button")).toHaveClass("btn-danger"); }); }); ``` ### Testing Forms ```tsx // src/components/LoginForm.tsx import { useState } from "react"; interface LoginFormProps { onSubmit: (email: string, password: string) => Promise; } export function LoginForm({ onSubmit }: LoginFormProps) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); setLoading(true); try { await onSubmit(email, password); } catch (err) { setError(err instanceof Error ? err.message : "Login failed"); } finally { setLoading(false); } }; return (
{error && (
{error}
)} setEmail(e.target.value)} required aria-describedby={error ? "error-message" : undefined} /> setPassword(e.target.value)} required minLength={8} />
); } // src/components/LoginForm.test.tsx import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { LoginForm } from "./LoginForm"; describe("LoginForm", () => { const mockOnSubmit = jest.fn(); beforeEach(() => { mockOnSubmit.mockReset(); }); it("renders all form fields", () => { render(); expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect( screen.getByRole("button", { name: /sign in/i }), ).toBeInTheDocument(); }); it("submits form with valid data", async () => { const user = userEvent.setup(); mockOnSubmit.mockResolvedValueOnce(undefined); render(); await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "password123"); await user.click(screen.getByRole("button", { name: /sign in/i })); await waitFor(() => { expect(mockOnSubmit).toHaveBeenCalledWith( "test@example.com", "password123", ); }); }); it("shows loading state during submission", async () => { const user = userEvent.setup(); mockOnSubmit.mockImplementation(() => new Promise(() => {})); // Never resolves render(); await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "password123"); await user.click(screen.getByRole("button", { name: /sign in/i })); expect(screen.getByRole("button")).toHaveTextContent("Signing in..."); expect(screen.getByRole("button")).toBeDisabled(); }); it("displays error message on failure", async () => { const user = userEvent.setup(); mockOnSubmit.mockRejectedValueOnce(new Error("Invalid credentials")); render(); await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "wrongpassword"); await user.click(screen.getByRole("button", { name: /sign in/i })); await waitFor(() => { expect(screen.getByRole("alert")).toHaveTextContent( "Invalid credentials", ); }); }); it("requires email field", async () => { render(); expect(screen.getByLabelText(/email/i)).toBeRequired(); }); it("requires password field", async () => { render(); expect(screen.getByLabelText(/password/i)).toBeRequired(); }); }); ``` ### Testing Async Components ```tsx // src/components/UserProfile.tsx import { useState, useEffect } from "react"; interface User { id: string; name: string; email: string; } export function UserProfile({ userId }: { userId: string }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { async function fetchUser() { try { const res = await fetch(`/api/users/${userId}`); if (!res.ok) throw new Error("User not found"); const data = await res.json(); setUser(data); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load"); } finally { setLoading(false); } } fetchUser(); }, [userId]); if (loading) return
Loading...
; if (error) return
{error}
; if (!user) return null; return (

{user.name}

{user.email}

); } // src/components/UserProfile.test.tsx import { render, screen, waitFor } from "@testing-library/react"; import { UserProfile } from "./UserProfile"; // Mock fetch globally global.fetch = jest.fn(); describe("UserProfile", () => { beforeEach(() => { (fetch as jest.Mock).mockReset(); }); it("shows loading state initially", () => { (fetch as jest.Mock).mockImplementation(() => new Promise(() => {})); render(); expect(screen.getByLabelText("Loading")).toBeInTheDocument(); }); it("displays user data on success", async () => { (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "1", name: "John Doe", email: "john@example.com", }), }); render(); await waitFor(() => { expect(screen.getByText("John Doe")).toBeInTheDocument(); expect(screen.getByText("john@example.com")).toBeInTheDocument(); }); }); it("displays error on failure", async () => { (fetch as jest.Mock).mockResolvedValueOnce({ ok: false, }); render(); await waitFor(() => { expect(screen.getByRole("alert")).toHaveTextContent("User not found"); }); }); }); ``` ## E2E Testing with Playwright ### Setup ```bash npm init playwright@latest ``` ### Configuration ```ts // 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 ? 1 : undefined, reporter: [["html"], ["junit", { outputFile: "test-results/junit.xml" }]], 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, }, }); ``` ### Page Objects Pattern ```ts // e2e/pages/LoginPage.ts import { Page, Locator } 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"); this.passwordInput = page.getByLabel("Password"); this.submitButton = page.getByRole("button", { name: "Sign In" }); this.errorMessage = page.getByRole("alert"); } async goto() { await this.page.goto("/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); } } ``` ### E2E Test Examples ```ts // e2e/auth.spec.ts import { test, expect } from "@playwright/test"; import { LoginPage } from "./pages/LoginPage"; test.describe("Authentication", () => { test("successful login redirects to dashboard", async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login("user@example.com", "validpassword"); await expect(page).toHaveURL("/dashboard"); await expect(page.getByText("Welcome back")).toBeVisible(); }); test("invalid credentials show error", async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login("user@example.com", "wrongpassword"); await loginPage.expectError("Invalid credentials"); await expect(page).toHaveURL("/login"); }); test("logout returns to home page", async ({ page }) => { // Login first const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login("user@example.com", "validpassword"); // Now logout await page.getByRole("button", { name: "Logout" }).click(); await expect(page).toHaveURL("/"); }); }); // e2e/dashboard.spec.ts import { test, expect } from "@playwright/test"; test.describe("Dashboard", () => { test.beforeEach(async ({ page }) => { // Login before each test await page.goto("/login"); await page.getByLabel("Email").fill("user@example.com"); await page.getByLabel("Password").fill("validpassword"); await page.getByRole("button", { name: "Sign In" }).click(); await page.waitForURL("/dashboard"); }); test("displays user stats", async ({ page }) => { await expect(page.getByTestId("total-users")).toBeVisible(); await expect(page.getByTestId("revenue")).toBeVisible(); await expect(page.getByTestId("orders")).toBeVisible(); }); test("filters data by date range", async ({ page }) => { await page.getByRole("button", { name: "Date Range" }).click(); await page.getByRole("option", { name: "Last 7 days" }).click(); // Wait for data to reload await page.waitForResponse("**/api/stats*"); // Verify filter is applied await expect(page.getByText("Last 7 days")).toBeVisible(); }); test("exports data to CSV", async ({ page }) => { const downloadPromise = page.waitForEvent("download"); await page.getByRole("button", { name: "Export CSV" }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toContain(".csv"); }); }); ``` ### Visual Regression Testing ```ts // e2e/visual.spec.ts import { test, expect } from "@playwright/test"; test.describe("Visual Regression", () => { test("homepage matches snapshot", async ({ page }) => { await page.goto("/"); await expect(page).toHaveScreenshot("homepage.png", { fullPage: true, maxDiffPixels: 100, }); }); test("dashboard matches snapshot", async ({ page }) => { // Login first await page.goto("/login"); await page.getByLabel("Email").fill("user@example.com"); await page.getByLabel("Password").fill("password"); await page.getByRole("button", { name: "Sign In" }).click(); await page.waitForURL("/dashboard"); // Wait for all data to load await page.waitForLoadState("networkidle"); await expect(page).toHaveScreenshot("dashboard.png", { maxDiffPixels: 200, }); }); test("components match snapshots", async ({ page }) => { await page.goto("/components"); // Screenshot specific component const button = page.getByTestId("primary-button"); await expect(button).toHaveScreenshot("primary-button.png"); const card = page.getByTestId("user-card"); await expect(card).toHaveScreenshot("user-card.png"); }); }); ``` ## Accessibility Testing ### With jest-axe ```tsx // src/components/Form.test.tsx import { render } from "@testing-library/react"; import { axe, toHaveNoViolations } from "jest-axe"; import { ContactForm } from "./ContactForm"; expect.extend(toHaveNoViolations); describe("ContactForm accessibility", () => { it("has no accessibility violations", async () => { const { container } = render(); const results = await axe(container); expect(results).toHaveNoViolations(); }); }); ``` ### With Playwright ```ts // e2e/accessibility.spec.ts import { test, expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; test.describe("Accessibility", () => { test("homepage has no violations", async ({ page }) => { await page.goto("/"); const accessibilityScanResults = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) .analyze(); expect(accessibilityScanResults.violations).toEqual([]); }); test("form has no violations", async ({ page }) => { await page.goto("/contact"); const accessibilityScanResults = await new AxeBuilder({ page }) .include("#contact-form") .analyze(); expect(accessibilityScanResults.violations).toEqual([]); }); test("keyboard navigation works", async ({ page }) => { await page.goto("/"); // Tab through interactive elements await page.keyboard.press("Tab"); await expect(page.getByRole("link", { name: "Home" })).toBeFocused(); await page.keyboard.press("Tab"); await expect(page.getByRole("link", { name: "About" })).toBeFocused(); // Press Enter to activate await page.keyboard.press("Enter"); await expect(page).toHaveURL("/about"); }); }); ``` ## CI/CD Integration ### GitHub Actions ```yaml # .github/workflows/test.yml name: Tests on: push: branches: [main] pull_request: branches: [main] jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node- cache: "npm" - run: npm ci - run: npm run test:coverage - uses: codecov/codecov-action@v3 with: files: ./coverage/lcov.info e2e-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node- cache: "npm" - run: npm ci - run: npx playwright install --with-deps - run: npm run test:e2e - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/ retention-days: 7 ``` ## Best Practices 1. **Test Behavior, Not Implementation** - Focus on what users see and do 2. **Use Accessible Queries** - getByRole, getByLabelText, getByText 3. **Avoid Test IDs When Possible** - Use semantic queries first 4. **Mock External Dependencies** - API calls, timers, etc. 5. **Keep Tests Isolated** - Each test should be independent 6. **Test Edge Cases** - Error states, empty states, loading 7. **Run Tests in CI** - Catch regressions early 8. **Maintain Test Coverage** - Aim for 80%+ on critical paths ## When to Use - Unit Tests: Functions, hooks, utilities, pure logic - Component Tests: UI components, forms, interactions - Integration Tests: Component combinations, API integration - E2E Tests: Critical user flows, cross-browser testing - Visual Tests: Design consistency, responsive layouts - Accessibility Tests: WCAG compliance, keyboard navigation