Claude-skill-registry frontend-testing
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.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/frontend-testing-fgarofalo56-suppercharge-microso" ~/.claude/skills/majiayu000-claude-skill-registry-frontend-testing-27ab7f && rm -rf "$T"
manifest:
skills/data/frontend-testing-fgarofalo56-suppercharge-microso/SKILL.mdsource content
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
# 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
// jest.config.js module.exports = { testEnvironment: "jsdom", setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"], moduleNameMapper: { "^@/(.*)$": "<rootDir>/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
// 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
// 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
// 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 ( <button onClick={onClick} disabled={disabled || loading} className={`btn btn-${variant}`} aria-busy={loading} > {loading ? "Loading..." : children} </button> ); } // 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(<Button>Click me</Button>); expect( screen.getByRole("button", { name: /click me/i }), ).toBeInTheDocument(); }); it("calls onClick when clicked", async () => { const handleClick = jest.fn(); const user = userEvent.setup(); render(<Button onClick={handleClick}>Click me</Button>); await user.click(screen.getByRole("button")); expect(handleClick).toHaveBeenCalledTimes(1); }); it("is disabled when disabled prop is true", () => { render(<Button disabled>Click me</Button>); expect(screen.getByRole("button")).toBeDisabled(); }); it("shows loading state", () => { render(<Button loading>Submit</Button>); expect(screen.getByRole("button")).toHaveTextContent("Loading..."); expect(screen.getByRole("button")).toBeDisabled(); expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true"); }); it("applies variant class", () => { render(<Button variant="danger">Delete</Button>); expect(screen.getByRole("button")).toHaveClass("btn-danger"); }); });
Testing Forms
// src/components/LoginForm.tsx import { useState } from "react"; interface LoginFormProps { onSubmit: (email: string, password: string) => Promise<void>; } 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 ( <form onSubmit={handleSubmit} aria-label="Login form"> {error && ( <div role="alert" className="error"> {error} </div> )} <label htmlFor="email">Email</label> <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required aria-describedby={error ? "error-message" : undefined} /> <label htmlFor="password">Password</label> <input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} /> <button type="submit" disabled={loading}> {loading ? "Signing in..." : "Sign In"} </button> </form> ); } // 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(<LoginForm onSubmit={mockOnSubmit} />); 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(<LoginForm onSubmit={mockOnSubmit} />); 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(<LoginForm onSubmit={mockOnSubmit} />); 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(<LoginForm onSubmit={mockOnSubmit} />); 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(<LoginForm onSubmit={mockOnSubmit} />); expect(screen.getByLabelText(/email/i)).toBeRequired(); }); it("requires password field", async () => { render(<LoginForm onSubmit={mockOnSubmit} />); expect(screen.getByLabelText(/password/i)).toBeRequired(); }); });
Testing Async Components
// 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<User | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(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 <div aria-label="Loading">Loading...</div>; if (error) return <div role="alert">{error}</div>; if (!user) return null; return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> </div> ); } // 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(<UserProfile userId="1" />); 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(<UserProfile userId="1" />); 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(<UserProfile userId="invalid" />); await waitFor(() => { expect(screen.getByRole("alert")).toHaveTextContent("User not found"); }); }); });
E2E Testing with Playwright
Setup
npm init playwright@latest
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 ? 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
// 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
// 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
// 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
// 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(<ContactForm />); const results = await axe(container); expect(results).toHaveNoViolations(); }); });
With Playwright
// 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
# .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
- Test Behavior, Not Implementation - Focus on what users see and do
- Use Accessible Queries - getByRole, getByLabelText, getByText
- Avoid Test IDs When Possible - Use semantic queries first
- Mock External Dependencies - API calls, timers, etc.
- Keep Tests Isolated - Each test should be independent
- Test Edge Cases - Error states, empty states, loading
- Run Tests in CI - Catch regressions early
- 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