Skip to content
frontend-testing logo

Frontend Testing

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.

SKILL.md

Full skill instructions

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), "[email protected]");
    await user.type(screen.getByLabelText(/​password/​i), "password123");
    await user.click(screen.getByRole("button", { name: /​sign in/​i }));

    await waitFor(() => {
      expect(mockOnSubmit).toHaveBeenCalledWith(
        "[email protected]",
        "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), "[email protected]");
    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), "[email protected]");
    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: "[email protected]",
        }),
    });

    render(<UserProfile userId="1" />);

    await waitFor(() => {
      expect(screen.getByText("John Doe")).toBeInTheDocument();
      expect(screen.getByText("[email protected]")).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("[email protected]", "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("[email protected]", "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("[email protected]", "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("[email protected]");
    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("[email protected]");
    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

  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

More skills from majiayu000

xiaohongshu logo
majiayu000/claude-arsenal

xiaohongshu

xiaohongshu

286 148
View
agent-task-conductor logo
majiayu000/claude-skill-registry

agent-task-conductor

Conduct multi-agent task orchestration and workflow coordination.

663 1
View
conductor-setup logo
majiayu000/claude-skill-registry

conductor-setup

Initialize project with Conductor artifacts (product definition,

663 1
View
animation-designer logo
majiayu000/claude-skill-registry

animation-designer

Expert in web animations, transitions, and motion design using Framer Motion and CSS

663 1
View
diagramming logo
majiayu000/claude-skill-registry

diagramming

Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...

663 1
View
h3-pg logo
majiayu000/claude-skill-registry-data

h3-pg

PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.

23 1
View
conductor-development logo
majiayu000/claude-skill-registry

conductor-development

Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...

663 1
View
conductor-status logo
majiayu000/claude-skill-registry

conductor-status

Display project status, active tracks, and next actions

663 1
View
dockerization logo
majiayu000/claude-skill-registry

dockerization

Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.

663 1
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View