mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 01:02:39 +02:00
438 lines
10 KiB
Markdown
438 lines
10 KiB
Markdown
# Component Testing
|
|
|
|
## Table of Contents
|
|
|
|
1. [Setup & Configuration](#setup--configuration)
|
|
2. [Mounting Components](#mounting-components)
|
|
3. [Props & State Testing](#props--state-testing)
|
|
4. [Events & Interactions](#events--interactions)
|
|
5. [Slots & Children](#slots--children)
|
|
6. [Mocking Dependencies](#mocking-dependencies)
|
|
7. [Framework-Specific Patterns](#framework-specific-patterns)
|
|
|
|
## Setup & Configuration
|
|
|
|
### Installation
|
|
|
|
```bash
|
|
# React
|
|
npm init playwright@latest -- --ct
|
|
```
|
|
|
|
### Configuration
|
|
|
|
```typescript
|
|
// playwright-ct.config.ts
|
|
import { defineConfig, devices } from "@playwright/experimental-ct-react";
|
|
|
|
export default defineConfig({
|
|
testDir: "./tests/components",
|
|
snapshotDir: "./tests/components/__snapshots__",
|
|
|
|
use: {
|
|
ctPort: 3100,
|
|
ctViteConfig: {
|
|
resolve: {
|
|
alias: {
|
|
"@": "/src",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
|
|
projects: [
|
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
|
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
|
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
|
|
],
|
|
});
|
|
```
|
|
|
|
### Project Structure
|
|
|
|
```
|
|
src/
|
|
components/
|
|
Button.tsx
|
|
Modal.tsx
|
|
tests/
|
|
components/
|
|
Button.spec.tsx
|
|
Modal.spec.tsx
|
|
playwright/
|
|
index.html # CT entry point
|
|
index.tsx # CT setup (providers, styles)
|
|
```
|
|
|
|
## Mounting Components
|
|
|
|
### Basic Mount
|
|
|
|
```tsx
|
|
// Button.spec.tsx
|
|
import { test, expect } from "@playwright/experimental-ct-react";
|
|
import { Button } from "@/components/Button";
|
|
|
|
test("renders button with text", async ({ mount }) => {
|
|
const component = await mount(<Button>Click me</Button>);
|
|
|
|
await expect(component).toContainText("Click me");
|
|
await expect(component).toBeVisible();
|
|
});
|
|
```
|
|
|
|
### Mount with Props
|
|
|
|
```tsx
|
|
test("renders with all props", async ({ mount }) => {
|
|
const component = await mount(
|
|
<Button variant="primary" size="large" disabled={false} icon="check">
|
|
Submit
|
|
</Button>,
|
|
);
|
|
|
|
await expect(component).toHaveClass(/primary/);
|
|
await expect(component).toHaveClass(/large/);
|
|
await expect(component.locator("svg")).toBeVisible(); // icon
|
|
});
|
|
```
|
|
|
|
### Mount with Wrapper/Provider
|
|
|
|
```tsx
|
|
// playwright/index.tsx - Global providers
|
|
import { ThemeProvider } from "@/providers/theme";
|
|
import { QueryClientProvider } from "@tanstack/react-query";
|
|
import "@/styles/globals.css";
|
|
|
|
export default function PlaywrightWrapper({ children }) {
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<ThemeProvider>{children}</ThemeProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
```
|
|
|
|
```tsx
|
|
// Or per-test wrapper
|
|
test("with custom provider", async ({ mount }) => {
|
|
const component = await mount(
|
|
<AuthProvider initialUser={{ name: "Test" }}>
|
|
<UserProfile />
|
|
</AuthProvider>,
|
|
);
|
|
|
|
await expect(component.getByText("Test")).toBeVisible();
|
|
});
|
|
```
|
|
|
|
## Props & State Testing
|
|
|
|
### Testing Prop Variations
|
|
|
|
```tsx
|
|
test.describe("Button variants", () => {
|
|
const variants = ["primary", "secondary", "danger", "ghost"] as const;
|
|
|
|
for (const variant of variants) {
|
|
test(`renders ${variant} variant`, async ({ mount }) => {
|
|
const component = await mount(<Button variant={variant}>Button</Button>);
|
|
await expect(component).toHaveClass(new RegExp(variant));
|
|
});
|
|
}
|
|
});
|
|
```
|
|
|
|
### Updating Props
|
|
|
|
```tsx
|
|
test("responds to prop changes", async ({ mount }) => {
|
|
const component = await mount(<Counter initialCount={0} />);
|
|
|
|
await expect(component.getByTestId("count")).toHaveText("0");
|
|
|
|
// Update props
|
|
await component.update(<Counter initialCount={10} />);
|
|
await expect(component.getByTestId("count")).toHaveText("10");
|
|
});
|
|
```
|
|
|
|
### Testing Controlled Components
|
|
|
|
```tsx
|
|
test("controlled input", async ({ mount }) => {
|
|
let externalValue = "";
|
|
|
|
const component = await mount(
|
|
<Input
|
|
value={externalValue}
|
|
onChange={(e) => {
|
|
externalValue = e.target.value;
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await component.locator("input").fill("hello");
|
|
|
|
// For controlled components, update with new value
|
|
await component.update(
|
|
<Input value="hello" onChange={(e) => (externalValue = e.target.value)} />,
|
|
);
|
|
|
|
await expect(component.locator("input")).toHaveValue("hello");
|
|
});
|
|
```
|
|
|
|
### Testing Internal State
|
|
|
|
```tsx
|
|
test("internal state updates", async ({ mount }) => {
|
|
const component = await mount(<Toggle defaultChecked={false} />);
|
|
|
|
// Initial state
|
|
await expect(component.locator('[role="switch"]')).toHaveAttribute(
|
|
"aria-checked",
|
|
"false",
|
|
);
|
|
|
|
// Trigger state change
|
|
await component.click();
|
|
|
|
// Verify state updated
|
|
await expect(component.locator('[role="switch"]')).toHaveAttribute(
|
|
"aria-checked",
|
|
"true",
|
|
);
|
|
});
|
|
```
|
|
|
|
## Events & Interactions
|
|
|
|
### Testing Click Events
|
|
|
|
```tsx
|
|
test("click event fires", async ({ mount }) => {
|
|
let clicked = false;
|
|
|
|
const component = await mount(
|
|
<Button onClick={() => (clicked = true)}>Click</Button>,
|
|
);
|
|
|
|
await component.click();
|
|
|
|
expect(clicked).toBe(true);
|
|
});
|
|
```
|
|
|
|
### Testing Event Payloads
|
|
|
|
```tsx
|
|
test("onChange provides correct value", async ({ mount }) => {
|
|
const values: string[] = [];
|
|
|
|
const component = await mount(
|
|
<Select
|
|
options={["a", "b", "c"]}
|
|
onChange={(value) => values.push(value)}
|
|
/>,
|
|
);
|
|
|
|
await component.getByRole("combobox").click();
|
|
await component.getByRole("option", { name: "b" }).click();
|
|
|
|
expect(values).toEqual(["b"]);
|
|
});
|
|
```
|
|
|
|
### Testing Form Submission
|
|
|
|
```tsx
|
|
test("form submission", async ({ mount }) => {
|
|
let submittedData: FormData | null = null;
|
|
|
|
const component = await mount(
|
|
<LoginForm
|
|
onSubmit={(data) => {
|
|
submittedData = data;
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await component.getByLabel("Email").fill("test@example.com");
|
|
await component.getByLabel("Password").fill("secret123");
|
|
await component.getByRole("button", { name: "Sign in" }).click();
|
|
|
|
expect(submittedData).toEqual({
|
|
email: "test@example.com",
|
|
password: "secret123",
|
|
});
|
|
});
|
|
```
|
|
|
|
### Testing Keyboard Interactions
|
|
|
|
```tsx
|
|
test("keyboard navigation", async ({ mount }) => {
|
|
const component = await mount(
|
|
<Dropdown options={["Apple", "Banana", "Cherry"]} />,
|
|
);
|
|
|
|
// Open dropdown
|
|
await component.getByRole("button").click();
|
|
|
|
// Navigate with keyboard
|
|
await component.press("ArrowDown");
|
|
await component.press("ArrowDown");
|
|
await component.press("Enter");
|
|
|
|
await expect(component.getByRole("button")).toHaveText("Banana");
|
|
});
|
|
```
|
|
|
|
## Slots & Children
|
|
|
|
### Testing Children Content
|
|
|
|
```tsx
|
|
test("renders children", async ({ mount }) => {
|
|
const component = await mount(
|
|
<Card>
|
|
<h2>Title</h2>
|
|
<p>Description</p>
|
|
</Card>,
|
|
);
|
|
|
|
await expect(component.getByRole("heading")).toHaveText("Title");
|
|
await expect(component.getByText("Description")).toBeVisible();
|
|
});
|
|
```
|
|
|
|
### Testing Render Props
|
|
|
|
```tsx
|
|
test("render prop pattern", async ({ mount }) => {
|
|
const component = await mount(
|
|
<DataFetcher url="/api/users">
|
|
{({ data, loading }) =>
|
|
loading ? <span>Loading...</span> : <span>{data.name}</span>
|
|
}
|
|
</DataFetcher>,
|
|
);
|
|
|
|
// Initially loading
|
|
await expect(component.getByText("Loading...")).toBeVisible();
|
|
|
|
// After data loads
|
|
await expect(component.getByText(/User/)).toBeVisible();
|
|
});
|
|
```
|
|
|
|
## Mocking Dependencies
|
|
|
|
### Mocking Imports
|
|
|
|
```tsx
|
|
// playwright/index.tsx - Mock at setup level
|
|
import { beforeMount } from "@playwright/experimental-ct-react/hooks";
|
|
|
|
beforeMount(async ({ hooksConfig }) => {
|
|
// Mock analytics
|
|
window.analytics = {
|
|
track: () => {},
|
|
identify: () => {},
|
|
};
|
|
|
|
// Mock feature flags
|
|
if (hooksConfig?.featureFlags) {
|
|
window.__FEATURE_FLAGS__ = hooksConfig.featureFlags;
|
|
}
|
|
});
|
|
```
|
|
|
|
```tsx
|
|
// Test with mocked config
|
|
test("with feature flag", async ({ mount }) => {
|
|
const component = await mount(<FeatureComponent />, {
|
|
hooksConfig: {
|
|
featureFlags: { newFeature: true },
|
|
},
|
|
});
|
|
|
|
await expect(component.getByText("New Feature")).toBeVisible();
|
|
});
|
|
```
|
|
|
|
### Mocking API Calls
|
|
|
|
```tsx
|
|
test("component with API", async ({ mount, page }) => {
|
|
// Mock API before mounting
|
|
await page.route("**/api/user", (route) => {
|
|
route.fulfill({
|
|
json: { id: 1, name: "Test User" },
|
|
});
|
|
});
|
|
|
|
const component = await mount(<UserProfile userId={1} />);
|
|
|
|
await expect(component.getByText("Test User")).toBeVisible();
|
|
});
|
|
```
|
|
|
|
### Mocking Hooks
|
|
|
|
```tsx
|
|
// Mock custom hook via module mock
|
|
test("with mocked hook", async ({ mount }) => {
|
|
const component = await mount(<Dashboard />, {
|
|
hooksConfig: {
|
|
mockAuth: { user: { name: "Admin" }, isAdmin: true },
|
|
},
|
|
});
|
|
|
|
await expect(component.getByText("Admin Panel")).toBeVisible();
|
|
});
|
|
```
|
|
|
|
## Framework-Specific Patterns
|
|
|
|
### React Testing
|
|
|
|
```tsx
|
|
// React with refs
|
|
test("exposes ref methods", async ({ mount }) => {
|
|
let inputRef: HTMLInputElement | null = null;
|
|
|
|
const component = await mount(<Input ref={(el) => (inputRef = el)} />);
|
|
|
|
await component.locator("input").fill("test");
|
|
expect(inputRef?.value).toBe("test");
|
|
});
|
|
|
|
// React with context
|
|
test("uses context", async ({ mount }) => {
|
|
const component = await mount(
|
|
<UserContext.Provider value={{ name: "Test" }}>
|
|
<UserGreeting />
|
|
</UserContext.Provider>,
|
|
);
|
|
|
|
await expect(component).toContainText("Hello, Test");
|
|
});
|
|
```
|
|
|
|
## Anti-Patterns to Avoid
|
|
|
|
| Anti-Pattern | Problem | Solution |
|
|
| ------------------------------ | ------------------- | --------------------------------- |
|
|
| Testing implementation details | Brittle tests | Test behavior, not internal state |
|
|
| Snapshot testing everything | Maintenance burden | Use for visual regression only |
|
|
| Not isolating components | Hidden dependencies | Mock all external dependencies |
|
|
| Testing framework behavior | Redundant | Focus on your component logic |
|
|
| Skipping accessibility | Misses real issues | Include a11y checks in CT |
|
|
|
|
## Related References
|
|
|
|
- **Accessibility**: See [accessibility.md](accessibility.md) for a11y testing in components
|
|
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for shared test setup
|