Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
testing-patterns/component-testing.md
1# Component Testing23## Table of Contents451. [Setup & Configuration](#setup--configuration)62. [Mounting Components](#mounting-components)73. [Props & State Testing](#props--state-testing)84. [Events & Interactions](#events--interactions)95. [Slots & Children](#slots--children)106. [Mocking Dependencies](#mocking-dependencies)117. [Framework-Specific Patterns](#framework-specific-patterns)1213## Setup & Configuration1415### Installation1617```bash18# React19npm init playwright@latest -- --ct2021# Vue22npm init playwright@latest -- --ct2324# Svelte25npm init playwright@latest -- --ct2627# Solid28npm init playwright@latest -- --ct29```3031### Configuration3233```typescript34// playwright-ct.config.ts35import { defineConfig, devices } from "@playwright/experimental-ct-react";3637export default defineConfig({38testDir: "./tests/components",39snapshotDir: "./tests/components/__snapshots__",4041use: {42ctPort: 3100,43ctViteConfig: {44resolve: {45alias: {46"@": "/src",47},48},49},50},5152projects: [53{ name: "chromium", use: { ...devices["Desktop Chrome"] } },54{ name: "firefox", use: { ...devices["Desktop Firefox"] } },55{ name: "webkit", use: { ...devices["Desktop Safari"] } },56],57});58```5960### Project Structure6162```63src/64components/65Button.tsx66Modal.tsx67tests/68components/69Button.spec.tsx70Modal.spec.tsx71playwright/72index.html # CT entry point73index.tsx # CT setup (providers, styles)74```7576## Mounting Components7778### Basic Mount7980```tsx81// Button.spec.tsx82import { test, expect } from "@playwright/experimental-ct-react";83import { Button } from "@/components/Button";8485test("renders button with text", async ({ mount }) => {86const component = await mount(<Button>Click me</Button>);8788await expect(component).toContainText("Click me");89await expect(component).toBeVisible();90});91```9293### Mount with Props9495```tsx96test("renders with all props", async ({ mount }) => {97const component = await mount(98<Button variant="primary" size="large" disabled={false} icon="check">99Submit100</Button>,101);102103await expect(component).toHaveClass(/primary/);104await expect(component).toHaveClass(/large/);105await expect(component.locator("svg")).toBeVisible(); // icon106});107```108109### Mount with Wrapper/Provider110111```tsx112// playwright/index.tsx - Global providers113import { ThemeProvider } from "@/providers/theme";114import { QueryClientProvider } from "@tanstack/react-query";115import "@/styles/globals.css";116117export default function PlaywrightWrapper({ children }) {118return (119<QueryClientProvider client={queryClient}>120<ThemeProvider>{children}</ThemeProvider>121</QueryClientProvider>122);123}124```125126```tsx127// Or per-test wrapper128test("with custom provider", async ({ mount }) => {129const component = await mount(130<AuthProvider initialUser={{ name: "Test" }}>131<UserProfile />132</AuthProvider>,133);134135await expect(component.getByText("Test")).toBeVisible();136});137```138139## Props & State Testing140141### Testing Prop Variations142143```tsx144test.describe("Button variants", () => {145const variants = ["primary", "secondary", "danger", "ghost"] as const;146147for (const variant of variants) {148test(`renders ${variant} variant`, async ({ mount }) => {149const component = await mount(<Button variant={variant}>Button</Button>);150await expect(component).toHaveClass(new RegExp(variant));151});152}153});154```155156### Updating Props157158```tsx159test("responds to prop changes", async ({ mount }) => {160const component = await mount(<Counter initialCount={0} />);161162await expect(component.getByTestId("count")).toHaveText("0");163164// Update props165await component.update(<Counter initialCount={10} />);166await expect(component.getByTestId("count")).toHaveText("10");167});168```169170### Testing Controlled Components171172```tsx173test("controlled input", async ({ mount }) => {174let externalValue = "";175176const component = await mount(177<Input178value={externalValue}179onChange={(e) => {180externalValue = e.target.value;181}}182/>,183);184185await component.locator("input").fill("hello");186187// For controlled components, update with new value188await component.update(189<Input value="hello" onChange={(e) => (externalValue = e.target.value)} />,190);191192await expect(component.locator("input")).toHaveValue("hello");193});194```195196### Testing Internal State197198```tsx199test("internal state updates", async ({ mount }) => {200const component = await mount(<Toggle defaultChecked={false} />);201202// Initial state203await expect(component.locator('[role="switch"]')).toHaveAttribute(204"aria-checked",205"false",206);207208// Trigger state change209await component.click();210211// Verify state updated212await expect(component.locator('[role="switch"]')).toHaveAttribute(213"aria-checked",214"true",215);216});217```218219## Events & Interactions220221### Testing Click Events222223```tsx224test("click event fires", async ({ mount }) => {225let clicked = false;226227const component = await mount(228<Button onClick={() => (clicked = true)}>Click</Button>,229);230231await component.click();232233expect(clicked).toBe(true);234});235```236237### Testing Event Payloads238239```tsx240test("onChange provides correct value", async ({ mount }) => {241const values: string[] = [];242243const component = await mount(244<Select245options={["a", "b", "c"]}246onChange={(value) => values.push(value)}247/>,248);249250await component.getByRole("combobox").click();251await component.getByRole("option", { name: "b" }).click();252253expect(values).toEqual(["b"]);254});255```256257### Testing Form Submission258259```tsx260test("form submission", async ({ mount }) => {261let submittedData: FormData | null = null;262263const component = await mount(264<LoginForm265onSubmit={(data) => {266submittedData = data;267}}268/>,269);270271await component.getByLabel("Email").fill("[email protected]");272await component.getByLabel("Password").fill("secret123");273await component.getByRole("button", { name: "Sign in" }).click();274275expect(submittedData).toEqual({276email: "[email protected]",277password: "secret123",278});279});280```281282### Testing Keyboard Interactions283284```tsx285test("keyboard navigation", async ({ mount }) => {286const component = await mount(287<Dropdown options={["Apple", "Banana", "Cherry"]} />,288);289290// Open dropdown291await component.getByRole("button").click();292293// Navigate with keyboard294await component.press("ArrowDown");295await component.press("ArrowDown");296await component.press("Enter");297298await expect(component.getByRole("button")).toHaveText("Banana");299});300```301302## Slots & Children303304### Testing Children Content305306```tsx307test("renders children", async ({ mount }) => {308const component = await mount(309<Card>310<h2>Title</h2>311<p>Description</p>312</Card>,313);314315await expect(component.getByRole("heading")).toHaveText("Title");316await expect(component.getByText("Description")).toBeVisible();317});318```319320### Testing Named Slots (Vue)321322```tsx323// Vue component with slots324test("renders named slots", async ({ mount }) => {325const component = await mount(Modal, {326slots: {327header: "<h2>Modal Title</h2>",328default: "<p>Modal content</p>",329footer: "<button>Close</button>",330},331});332333await expect(component.getByRole("heading")).toHaveText("Modal Title");334await expect(component.getByRole("button")).toHaveText("Close");335});336```337338### Testing Render Props339340```tsx341test("render prop pattern", async ({ mount }) => {342const component = await mount(343<DataFetcher url="/api/users">344{({ data, loading }) =>345loading ? <span>Loading...</span> : <span>{data.name}</span>346}347</DataFetcher>,348);349350// Initially loading351await expect(component.getByText("Loading...")).toBeVisible();352353// After data loads354await expect(component.getByText(/User/)).toBeVisible();355});356```357358## Mocking Dependencies359360### Mocking Imports361362```tsx363// playwright/index.tsx - Mock at setup level364import { beforeMount } from "@playwright/experimental-ct-react/hooks";365366beforeMount(async ({ hooksConfig }) => {367// Mock analytics368window.analytics = {369track: () => {},370identify: () => {},371};372373// Mock feature flags374if (hooksConfig?.featureFlags) {375window.__FEATURE_FLAGS__ = hooksConfig.featureFlags;376}377});378```379380```tsx381// Test with mocked config382test("with feature flag", async ({ mount }) => {383const component = await mount(<FeatureComponent />, {384hooksConfig: {385featureFlags: { newFeature: true },386},387});388389await expect(component.getByText("New Feature")).toBeVisible();390});391```392393### Mocking API Calls394395```tsx396test("component with API", async ({ mount, page }) => {397// Mock API before mounting398await page.route("**/api/user", (route) => {399route.fulfill({400json: { id: 1, name: "Test User" },401});402});403404const component = await mount(<UserProfile userId={1} />);405406await expect(component.getByText("Test User")).toBeVisible();407});408```409410### Mocking Hooks411412```tsx413// Mock custom hook via module mock414test("with mocked hook", async ({ mount }) => {415const component = await mount(<Dashboard />, {416hooksConfig: {417mockAuth: { user: { name: "Admin" }, isAdmin: true },418},419});420421await expect(component.getByText("Admin Panel")).toBeVisible();422});423```424425## Framework-Specific Patterns426427### React Testing428429```tsx430// React with refs431test("exposes ref methods", async ({ mount }) => {432let inputRef: HTMLInputElement | null = null;433434const component = await mount(<Input ref={(el) => (inputRef = el)} />);435436await component.locator("input").fill("test");437expect(inputRef?.value).toBe("test");438});439440// React with context441test("uses context", async ({ mount }) => {442const component = await mount(443<UserContext.Provider value={{ name: "Test" }}>444<UserGreeting />445</UserContext.Provider>,446);447448await expect(component).toContainText("Hello, Test");449});450```451452### Vue Testing453454```tsx455import { test, expect } from "@playwright/experimental-ct-vue";456import MyInput from "@/components/MyInput.vue";457458// With v-model459test("v-model binding", async ({ mount }) => {460let modelValue = "";461const component = await mount(MyInput, {462props: {463modelValue,464"onUpdate:modelValue": (v: string) => (modelValue = v),465},466});467468await component.locator("input").fill("test");469expect(modelValue).toBe("test");470});471```472473### Svelte Testing474475```tsx476import { test, expect } from "@playwright/experimental-ct-svelte";477import Counter from "./Counter.svelte";478479test("Svelte component", async ({ mount }) => {480const component = await mount(Counter, { props: { initialCount: 5 } });481await expect(component.getByTestId("count")).toHaveText("5");482await component.getByRole("button", { name: "+" }).click();483await expect(component.getByTestId("count")).toHaveText("6");484});485```486487## Anti-Patterns to Avoid488489| Anti-Pattern | Problem | Solution |490| ------------------------------ | ------------------- | --------------------------------- |491| Testing implementation details | Brittle tests | Test behavior, not internal state |492| Snapshot testing everything | Maintenance burden | Use for visual regression only |493| Not isolating components | Hidden dependencies | Mock all external dependencies |494| Testing framework behavior | Redundant | Focus on your component logic |495| Skipping accessibility | Misses real issues | Include a11y checks in CT |496497## Related References498499- **Accessibility**: See [accessibility.md](accessibility.md) for a11y testing in components500- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for shared test setup501