mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 17:52:38 +02:00
chore: add playwright cursor skill
This commit is contained in:
parent
25aad38ca4
commit
d52225c18d
57 changed files with 25244 additions and 0 deletions
|
|
@ -0,0 +1,363 @@
|
|||
# Organizing Reusable Test Code
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Pattern Comparison](#pattern-comparison)
|
||||
2. [Selection Flowchart](#selection-flowchart)
|
||||
3. [Page Objects](#page-objects)
|
||||
4. [Custom Fixtures](#custom-fixtures)
|
||||
5. [Helper Functions](#helper-functions)
|
||||
6. [Combined Project Structure](#combined-project-structure)
|
||||
7. [Anti-Patterns](#anti-patterns)
|
||||
|
||||
Use all three patterns together. Most projects benefit from a hybrid approach:
|
||||
|
||||
- **Page objects** for UI interaction (pages/components with 5+ interactions)
|
||||
- **Custom fixtures** for test infrastructure (auth state, database, API clients, anything with lifecycle)
|
||||
- **Helper functions** for stateless utilities (generate data, format values, simple waits)
|
||||
|
||||
If only using one pattern, choose **custom fixtures** — they handle setup/teardown, compose well, and Playwright is built around them.
|
||||
|
||||
## Pattern Comparison
|
||||
|
||||
| Aspect | Page Objects | Custom Fixtures | Helper Functions |
|
||||
|---|---|---|---|
|
||||
| **Purpose** | Encapsulate UI interactions | Provide resources with setup/teardown | Stateless utilities |
|
||||
| **Lifecycle** | Manual (constructor/methods) | Built-in (`use()` with automatic teardown) | None |
|
||||
| **Composability** | Constructor injection or fixture wiring | Depend on other fixtures | Call other functions |
|
||||
| **Best for** | Pages with many reused interactions | Resources needing setup AND teardown | Simple logic with no side effects |
|
||||
|
||||
## Selection Flowchart
|
||||
|
||||
```text
|
||||
What kind of reusable code?
|
||||
|
|
||||
+-- Interacts with browser page/component?
|
||||
| |
|
||||
| +-- Has 5+ interactions (fill, click, navigate, assert)?
|
||||
| | +-- YES: Used in 3+ test files?
|
||||
| | | +-- YES --> PAGE OBJECT
|
||||
| | | +-- NO --> Inline or small helper
|
||||
| | +-- NO --> HELPER FUNCTION
|
||||
| |
|
||||
| +-- Needs setup before AND cleanup after test?
|
||||
| +-- YES --> CUSTOM FIXTURE
|
||||
| +-- NO --> PAGE OBJECT method or HELPER
|
||||
|
|
||||
+-- Manages resource with lifecycle (create/destroy)?
|
||||
| +-- Examples: auth state, DB connection, API client, test user
|
||||
| +-- YES --> CUSTOM FIXTURE (always)
|
||||
|
|
||||
+-- Stateless utility? (no browser, no side effects)
|
||||
| +-- Examples: random email, format date, build URL, parse response
|
||||
| +-- YES --> HELPER FUNCTION
|
||||
|
|
||||
+-- Not sure?
|
||||
+-- Start with HELPER FUNCTION
|
||||
+-- Promote to PAGE OBJECT when interactions grow
|
||||
+-- Promote to FIXTURE when lifecycle needed
|
||||
```
|
||||
|
||||
## Page Objects
|
||||
|
||||
Best for pages/components with 5+ interactions appearing in 3+ test files.
|
||||
|
||||
```typescript
|
||||
// page-objects/booking.page.ts
|
||||
import { type Page, type Locator, expect } from '@playwright/test';
|
||||
|
||||
export class BookingPage {
|
||||
readonly page: Page;
|
||||
readonly dateField: Locator;
|
||||
readonly guestCount: Locator;
|
||||
readonly roomType: Locator;
|
||||
readonly reserveBtn: Locator;
|
||||
readonly totalPrice: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.dateField = page.getByLabel('Check-in date');
|
||||
this.guestCount = page.getByLabel('Guests');
|
||||
this.roomType = page.getByLabel('Room type');
|
||||
this.reserveBtn = page.getByRole('button', { name: 'Reserve' });
|
||||
this.totalPrice = page.getByTestId('total-price');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/booking');
|
||||
}
|
||||
|
||||
async fillDetails(opts: { date: string; guests: number; room: string }) {
|
||||
await this.dateField.fill(opts.date);
|
||||
await this.guestCount.fill(String(opts.guests));
|
||||
await this.roomType.selectOption(opts.room);
|
||||
}
|
||||
|
||||
async reserve() {
|
||||
await this.reserveBtn.click();
|
||||
await this.page.waitForURL('**/confirmation');
|
||||
}
|
||||
|
||||
async expectPrice(amount: string) {
|
||||
await expect(this.totalPrice).toHaveText(amount);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/booking/reservation.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { BookingPage } from '../page-objects/booking.page';
|
||||
|
||||
test('complete reservation with standard room', async ({ page }) => {
|
||||
const booking = new BookingPage(page);
|
||||
await booking.goto();
|
||||
await booking.fillDetails({ date: '2026-03-15', guests: 2, room: 'standard' });
|
||||
await booking.reserve();
|
||||
await expect(page.getByText('Reservation confirmed')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Page object principles:**
|
||||
- One class per logical page/component, not per URL
|
||||
- Constructor takes `Page`
|
||||
- Locators as `readonly` properties in constructor
|
||||
- Methods represent user intent (`reserve`, `fillDetails`), not low-level clicks
|
||||
- Navigation methods (`goto`) belong on the page object
|
||||
|
||||
## Custom Fixtures
|
||||
|
||||
Best for resources needing setup before and teardown after tests — auth state, database connections, API clients, test users.
|
||||
|
||||
```typescript
|
||||
// fixtures/base.fixture.ts
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { BookingPage } from '../page-objects/booking.page';
|
||||
import { generateMember } from '../helpers/data';
|
||||
|
||||
type Fixtures = {
|
||||
bookingPage: BookingPage;
|
||||
member: { email: string; password: string; id: string };
|
||||
loggedInPage: import('@playwright/test').Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
bookingPage: async ({ page }, use) => {
|
||||
await use(new BookingPage(page));
|
||||
},
|
||||
|
||||
member: async ({ request }, use) => {
|
||||
const data = generateMember();
|
||||
const res = await request.post('/api/test/members', { data });
|
||||
const member = await res.json();
|
||||
await use(member);
|
||||
await request.delete(`/api/test/members/${member.id}`);
|
||||
},
|
||||
|
||||
loggedInPage: async ({ page, member }, use) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill(member.email);
|
||||
await page.getByLabel('Password').fill(member.password);
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/dashboard/overview.spec.ts
|
||||
import { test, expect } from '../../fixtures/base.fixture';
|
||||
|
||||
test('member sees dashboard widgets', async ({ loggedInPage }) => {
|
||||
await expect(loggedInPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(loggedInPage.getByTestId('stats-widget')).toBeVisible();
|
||||
});
|
||||
|
||||
test('new member sees welcome prompt', async ({ loggedInPage, member }) => {
|
||||
await expect(loggedInPage.getByText(`Welcome, ${member.email}`)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Fixture principles:**
|
||||
- Use `test.extend()` — never module-level variables
|
||||
- `use()` callback separates setup from teardown
|
||||
- Teardown runs even if test fails
|
||||
- Fixtures compose: one can depend on another
|
||||
- Fixtures are lazy: created only when requested
|
||||
- Wrap page objects in fixtures for lifecycle management
|
||||
|
||||
## Helper Functions
|
||||
|
||||
Best for stateless utilities — generating test data, formatting values, building URLs, parsing responses.
|
||||
|
||||
```typescript
|
||||
// helpers/data.ts
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export function generateEmail(prefix = 'user'): string {
|
||||
return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@test.local`;
|
||||
}
|
||||
|
||||
export function generateMember(overrides: Partial<Member> = {}): Member {
|
||||
return {
|
||||
email: generateEmail(),
|
||||
password: 'SecurePass456!',
|
||||
name: 'Test Member',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface Member {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function formatPrice(cents: number): string {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// helpers/assertions.ts
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
|
||||
export async function expectNotification(page: Page, message: string): Promise<void> {
|
||||
const notification = page.getByRole('alert').filter({ hasText: message });
|
||||
await expect(notification).toBeVisible();
|
||||
await expect(notification).toBeHidden({ timeout: 10000 });
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/settings/account.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { generateEmail } from '../../helpers/data';
|
||||
import { expectNotification } from '../../helpers/assertions';
|
||||
|
||||
test('update account email', async ({ page }) => {
|
||||
const newEmail = generateEmail('updated');
|
||||
await page.goto('/settings/account');
|
||||
await page.getByLabel('Email').fill(newEmail);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await expectNotification(page, 'Account updated');
|
||||
await expect(page.getByLabel('Email')).toHaveValue(newEmail);
|
||||
});
|
||||
```
|
||||
|
||||
**Helper principles:**
|
||||
- Pure functions with no side effects
|
||||
- No browser state — take `page` as parameter if needed
|
||||
- Promote to fixture if setup/teardown needed
|
||||
- Promote to page object if many page interactions grow
|
||||
- Keep small and focused
|
||||
|
||||
## Combined Project Structure
|
||||
|
||||
```text
|
||||
tests/
|
||||
+-- fixtures/
|
||||
| +-- auth.fixture.ts
|
||||
| +-- db.fixture.ts
|
||||
| +-- base.fixture.ts
|
||||
+-- page-objects/
|
||||
| +-- login.page.ts
|
||||
| +-- booking.page.ts
|
||||
| +-- components/
|
||||
| +-- data-table.component.ts
|
||||
+-- helpers/
|
||||
| +-- data.ts
|
||||
| +-- assertions.ts
|
||||
+-- e2e/
|
||||
| +-- auth/
|
||||
| | +-- login.spec.ts
|
||||
| +-- booking/
|
||||
| +-- reservation.spec.ts
|
||||
playwright.config.ts
|
||||
```
|
||||
|
||||
**Layer responsibilities:**
|
||||
|
||||
| Layer | Pattern | Responsibility |
|
||||
|---|---|---|
|
||||
| **Test file** | `test()` | Describes behavior, orchestrates layers |
|
||||
| **Fixtures** | `test.extend()` | Resource lifecycle — setup, provide, teardown |
|
||||
| **Page objects** | Classes | UI interaction — navigation, actions, locators |
|
||||
| **Helpers** | Functions | Utilities — data generation, formatting, assertions |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Page object managing resources
|
||||
|
||||
```typescript
|
||||
// BAD: page object handling API calls and database
|
||||
class LoginPage {
|
||||
async createUser() { /* API call */ }
|
||||
async deleteUser() { /* API call */ }
|
||||
async signIn(email: string, password: string) { /* UI */ }
|
||||
}
|
||||
```
|
||||
|
||||
Resource lifecycle belongs in fixtures where teardown is guaranteed. Keep only `signIn` in the page object.
|
||||
|
||||
### Locator-only page objects
|
||||
|
||||
```typescript
|
||||
// BAD: no methods, just locators
|
||||
class LoginPage {
|
||||
emailInput = this.page.getByLabel('Email');
|
||||
passwordInput = this.page.getByLabel('Password');
|
||||
submitBtn = this.page.getByRole('button', { name: 'Sign in' });
|
||||
constructor(private page: Page) {}
|
||||
}
|
||||
```
|
||||
|
||||
Add intent-revealing methods or skip the page object entirely.
|
||||
|
||||
### Monolithic fixtures
|
||||
|
||||
```typescript
|
||||
// BAD: one fixture doing everything
|
||||
test.extend({
|
||||
everything: async ({ page, request }, use) => {
|
||||
const user = await createUser(request);
|
||||
const products = await seedProducts(request, 50);
|
||||
await setupPayment(request, user.id);
|
||||
await page.goto('/dashboard');
|
||||
await use({ user, products, page });
|
||||
// massive teardown...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Break into small, composable fixtures. Each fixture does one thing.
|
||||
|
||||
### Helpers with side effects
|
||||
|
||||
```typescript
|
||||
// BAD: module-level state
|
||||
let createdUserId: string;
|
||||
|
||||
export async function createTestUser(request: APIRequestContext) {
|
||||
const res = await request.post('/api/users', { data: { email: 'test@example.com' } });
|
||||
const user = await res.json();
|
||||
createdUserId = user.id; // shared across tests!
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
Module-level state leaks between parallel tests. If it has side effects and needs cleanup, make it a fixture.
|
||||
|
||||
### Over-abstracting simple operations
|
||||
|
||||
```typescript
|
||||
// BAD: helper for one-liner
|
||||
export async function clickButton(page: Page, name: string) {
|
||||
await page.getByRole('button', { name }).click();
|
||||
}
|
||||
```
|
||||
|
||||
Only abstract when there is real duplication (3+ usages) or complexity (5+ interactions).
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
# Choosing Test Types: E2E, Component, or API
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Decision Matrix](#decision-matrix)
|
||||
2. [API Tests](#api-tests)
|
||||
3. [Component Tests](#component-tests)
|
||||
4. [E2E Tests](#e2e-tests)
|
||||
5. [Layering Test Types](#layering-test-types)
|
||||
6. [Common Mistakes](#common-mistakes)
|
||||
7. [Related](#related)
|
||||
|
||||
> **When to use**: Deciding which test type to write for a feature. Ask: "What's the cheapest test that gives confidence this works?"
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Scenario | Recommended Type | Rationale |
|
||||
| --------------------------- | ---------------- | --------------------------------------------- |
|
||||
| Login / auth flow | E2E | Cross-page, cookies, redirects, session state |
|
||||
| Form submission | Component | Isolated validation logic, error states |
|
||||
| CRUD operations | API | Data integrity matters more than UI |
|
||||
| Search with results UI | Component + API | API for query logic; component for rendering |
|
||||
| Cross-page navigation | E2E | Routing, history, deep linking |
|
||||
| API error handling | API | Status codes, error shapes, edge cases |
|
||||
| UI error feedback | Component | Toast, banner, inline error rendering |
|
||||
| Accessibility | Component | ARIA roles, keyboard nav per-component |
|
||||
| Responsive layout | Component | Viewport-specific rendering without full app |
|
||||
| API contract validation | API | Response shapes, headers, auth |
|
||||
| WebSocket/real-time | E2E | Requires full browser environment |
|
||||
| Payment / checkout | E2E | Multi-step, third-party iframes |
|
||||
| Onboarding wizard | E2E | Multi-step, state persists across pages |
|
||||
| Widget behavior | Component | Toggle, accordion, date picker, modal |
|
||||
| Permissions / authorization | API | Role-based access is backend logic |
|
||||
|
||||
## API Tests
|
||||
|
||||
**Ideal for**:
|
||||
|
||||
- CRUD operations (create, read, update, delete)
|
||||
- Input validation and error responses (400, 422)
|
||||
- Permission and authorization checks
|
||||
- Data integrity and business rules
|
||||
- API contract verification
|
||||
- Edge cases expensive to reproduce through UI
|
||||
- Test data setup/teardown for E2E tests
|
||||
|
||||
**Avoid for**:
|
||||
|
||||
- Testing how errors display to users
|
||||
- Browser-specific behavior (cookies, redirects)
|
||||
- Visual layout or responsive design
|
||||
- Flows requiring JavaScript execution or DOM interaction
|
||||
- Third-party iframe interactions
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Products API", () => {
|
||||
let token: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const res = await request.post("/api/auth/token", {
|
||||
data: { email: "manager@shop.io", password: "mgr-secret" },
|
||||
});
|
||||
token = (await res.json()).accessToken;
|
||||
});
|
||||
|
||||
test("creates product with valid payload", async ({ request }) => {
|
||||
const res = await request.post("/api/products", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { name: "Widget Pro", sku: "WGT-100", price: 29.99 },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(201);
|
||||
const product = await res.json();
|
||||
expect(product).toMatchObject({ name: "Widget Pro", sku: "WGT-100" });
|
||||
expect(product).toHaveProperty("id");
|
||||
});
|
||||
|
||||
test("rejects duplicate SKU with 409", async ({ request }) => {
|
||||
const res = await request.post("/api/products", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { name: "Duplicate", sku: "WGT-100", price: 19.99 },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(409);
|
||||
expect((await res.json()).message).toContain("already exists");
|
||||
});
|
||||
|
||||
test("returns 422 for missing required fields", async ({ request }) => {
|
||||
const res = await request.post("/api/products", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { name: "Incomplete" },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(422);
|
||||
const err = await res.json();
|
||||
expect(err.errors).toContainEqual(
|
||||
expect.objectContaining({ field: "sku" })
|
||||
);
|
||||
});
|
||||
|
||||
test("staff role cannot delete products", async ({ request }) => {
|
||||
const staffLogin = await request.post("/api/auth/token", {
|
||||
data: { email: "staff@shop.io", password: "staff-pass" },
|
||||
});
|
||||
const staffToken = (await staffLogin.json()).accessToken;
|
||||
|
||||
const res = await request.delete("/api/products/123", {
|
||||
headers: { Authorization: `Bearer ${staffToken}` },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(403);
|
||||
});
|
||||
|
||||
test("lists products with pagination", async ({ request }) => {
|
||||
const res = await request.get("/api/products", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: { page: "1", limit: "20" },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.items).toBeInstanceOf(Array);
|
||||
expect(body.items.length).toBeLessThanOrEqual(20);
|
||||
expect(body).toHaveProperty("totalCount");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Component Tests
|
||||
|
||||
**Ideal for**:
|
||||
|
||||
- Form validation (required fields, format rules, error messages)
|
||||
- Interactive widgets (modals, dropdowns, accordions, date pickers)
|
||||
- Conditional rendering (show/hide, loading states, empty states)
|
||||
- Accessibility per-component (ARIA attributes, keyboard navigation)
|
||||
- Responsive layout at different viewports
|
||||
- Visual states (hover, focus, disabled, selected)
|
||||
|
||||
**Avoid for**:
|
||||
|
||||
- Testing routing or navigation between pages
|
||||
- Flows requiring real cookies, sessions, or server-side state
|
||||
- Data persistence or API contract validation
|
||||
- Third-party iframe interactions
|
||||
- Anything requiring multiple pages or browser contexts
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/experimental-ct-react";
|
||||
import { ContactForm } from "../src/components/ContactForm";
|
||||
|
||||
test.describe("ContactForm component", () => {
|
||||
test("displays validation errors on empty submit", async ({ mount }) => {
|
||||
const component = await mount(<ContactForm onSubmit={() => {}} />);
|
||||
|
||||
await component.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
await expect(component.getByText("Name is required")).toBeVisible();
|
||||
await expect(component.getByText("Email is required")).toBeVisible();
|
||||
});
|
||||
|
||||
test("rejects malformed email", async ({ mount }) => {
|
||||
const component = await mount(<ContactForm onSubmit={() => {}} />);
|
||||
|
||||
await component.getByLabel("Name").fill("Alex");
|
||||
await component.getByLabel("Email").fill("invalid-email");
|
||||
await component.getByLabel("Message").fill("Hello");
|
||||
await component.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
await expect(component.getByText("Enter a valid email")).toBeVisible();
|
||||
});
|
||||
|
||||
test("invokes onSubmit with form data", async ({ mount }) => {
|
||||
const submissions: Array<{ name: string; email: string; message: string }> =
|
||||
[];
|
||||
const component = await mount(
|
||||
<ContactForm onSubmit={(data) => submissions.push(data)} />
|
||||
);
|
||||
|
||||
await component.getByLabel("Name").fill("Alex");
|
||||
await component.getByLabel("Email").fill("alex@company.org");
|
||||
await component.getByLabel("Message").fill("Inquiry about pricing");
|
||||
await component.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
expect(submissions).toHaveLength(1);
|
||||
expect(submissions[0]).toEqual({
|
||||
name: "Alex",
|
||||
email: "alex@company.org",
|
||||
message: "Inquiry about pricing",
|
||||
});
|
||||
});
|
||||
|
||||
test("disables button during submission", async ({ mount }) => {
|
||||
const component = await mount(
|
||||
<ContactForm onSubmit={() => {}} submitting={true} />
|
||||
);
|
||||
|
||||
await expect(
|
||||
component.getByRole("button", { name: "Sending..." })
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("associates labels with inputs for accessibility", async ({ mount }) => {
|
||||
const component = await mount(<ContactForm onSubmit={() => {}} />);
|
||||
|
||||
await expect(
|
||||
component.getByRole("textbox", { name: "Name" })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
component.getByRole("textbox", { name: "Email" })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## E2E Tests
|
||||
|
||||
**Ideal for**:
|
||||
|
||||
- Critical user flows that generate revenue (checkout, signup)
|
||||
- Authentication flows (login, SSO, MFA, password reset)
|
||||
- Multi-page workflows where state carries across navigation
|
||||
- Flows involving third-party iframes (payment widgets)
|
||||
- Smoke tests validating the entire stack
|
||||
- Real-time collaboration requiring multiple browser contexts
|
||||
|
||||
**Avoid for**:
|
||||
|
||||
- Testing every form validation permutation
|
||||
- CRUD operations where UI is a thin wrapper
|
||||
- Verifying individual component states
|
||||
- Testing API response shapes or error codes
|
||||
- Responsive layout at every breakpoint
|
||||
- Edge cases that only affect the backend
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("subscription flow", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.request.post("/api/test/seed-account", {
|
||||
data: { plan: "free", email: "subscriber@demo.io" },
|
||||
});
|
||||
await page.goto("/account/upgrade");
|
||||
});
|
||||
|
||||
test("upgrades to premium plan", async ({ page }) => {
|
||||
await test.step("select plan", async () => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Choose Your Plan" })
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Select Premium" }).click();
|
||||
});
|
||||
|
||||
await test.step("enter billing details", async () => {
|
||||
await page.getByLabel("Cardholder name").fill("Sam Johnson");
|
||||
await page.getByLabel("Billing address").fill("456 Oak Ave");
|
||||
await page.getByLabel("City").fill("Seattle");
|
||||
await page.getByRole("combobox", { name: "State" }).selectOption("WA");
|
||||
await page.getByLabel("Postal code").fill("98101");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
});
|
||||
|
||||
await test.step("complete payment", async () => {
|
||||
const paymentFrame = page.frameLocator('iframe[title="Secure Payment"]');
|
||||
await paymentFrame.getByLabel("Card number").fill("5555555555554444");
|
||||
await paymentFrame.getByLabel("Expiry").fill("09/29");
|
||||
await paymentFrame.getByLabel("CVV").fill("456");
|
||||
await page.getByRole("button", { name: "Subscribe now" }).click();
|
||||
});
|
||||
|
||||
await test.step("verify success", async () => {
|
||||
await page.waitForURL("**/account/subscription/success**");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Welcome to Premium" })
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/Subscription #\d+/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Layering Test Types
|
||||
|
||||
Effective test suites combine all three types. Example for an "inventory management" feature:
|
||||
|
||||
### API Layer (60% of tests)
|
||||
|
||||
Cover every backend logic permutation. Cheap to run and maintain.
|
||||
|
||||
```
|
||||
tests/api/inventory.spec.ts
|
||||
- creates item with valid data (201)
|
||||
- rejects duplicate SKU (409)
|
||||
- rejects invalid quantity format (422)
|
||||
- rejects missing required fields (422)
|
||||
- warehouse-staff cannot delete items (403)
|
||||
- unauthenticated request returns 401
|
||||
- lists items with pagination
|
||||
- filters items by category
|
||||
- updates item stock level
|
||||
- archives an item
|
||||
- prevents archiving items with pending orders
|
||||
```
|
||||
|
||||
### Component Layer (30% of tests)
|
||||
|
||||
Cover every visual state and interaction.
|
||||
|
||||
```
|
||||
tests/components/InventoryForm.spec.tsx
|
||||
- shows validation errors on empty submit
|
||||
- shows inline error for invalid SKU format
|
||||
- disables submit while saving
|
||||
- calls onSubmit with form data
|
||||
- resets form after successful save
|
||||
|
||||
tests/components/InventoryTable.spec.tsx
|
||||
- renders item rows from props
|
||||
- shows empty state when no items
|
||||
- handles archive confirmation modal
|
||||
- sorts by column header click
|
||||
- shows stock level badges with correct colors
|
||||
```
|
||||
|
||||
### E2E Layer (10% of tests)
|
||||
|
||||
Cover only critical paths proving full stack works.
|
||||
|
||||
```
|
||||
tests/e2e/inventory.spec.ts
|
||||
- manager creates item and sees it in list
|
||||
- manager updates item stock level
|
||||
- warehouse-staff cannot access admin settings
|
||||
```
|
||||
|
||||
### Execution Profile
|
||||
|
||||
For this feature:
|
||||
|
||||
- **11 API tests** — ~2 seconds total, no browser
|
||||
- **10 component tests** — ~5 seconds total, real browser but no server
|
||||
- **3 E2E tests** — ~15 seconds total, full stack
|
||||
|
||||
Total: 24 tests, ~22 seconds. API tests catch most regressions. Component tests catch UI bugs. E2E tests prove wiring works. If E2E fails but API and component pass, the problem is in integration (routing, state management, API client).
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Anti-Pattern | Problem | Better Approach |
|
||||
| ----------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| E2E for every validation rule | 30-second browser test for something API covers in 200ms | API test for validation, one component test for error display |
|
||||
| No API tests, all E2E | Slow suite, flaky from UI timing, hard to diagnose | API tests for data/logic, E2E for critical paths only |
|
||||
| Component tests mocking everything | Tests pass but app broken because mocks drift | Mock only external boundaries; API tests verify real contracts |
|
||||
| Same assertion in API, component, AND E2E | Triple maintenance cost | Each layer tests what it uniquely verifies |
|
||||
| E2E creating test data via UI | 2-minute test where 90 seconds is setup | Seed via API in `beforeEach`, test actual flow |
|
||||
| Testing third-party behavior | Testing that Stripe validates cards (Stripe's job) | Mock Stripe; trust their contract |
|
||||
| Skipping API layer | Can't tell if bug is frontend or backend | API tests isolate backend; component tests isolate frontend |
|
||||
| One giant E2E for entire feature | 5-minute test failing somewhere with no clear cause | Focused E2E per critical path; use `test.step()` |
|
||||
|
||||
## Related
|
||||
|
||||
- [test-suite-structure.md](../core/test-suite-structure.md) — file structure and naming
|
||||
- [api-testing.md](../testing-patterns/api-testing.md) — Playwright's `request` API for HTTP testing
|
||||
- [component-testing.md](../testing-patterns/component-testing.md) — setting up component tests
|
||||
- [authentication.md](../advanced/authentication.md) — auth flow patterns with `storageState`
|
||||
- [when-to-mock.md](when-to-mock.md) — when to mock vs hit real services
|
||||
- [pom-vs-fixtures.md](pom-vs-fixtures.md) — organizing shared test logic
|
||||
383
.cursor/skills/playwright-testing/architecture/when-to-mock.md
Normal file
383
.cursor/skills/playwright-testing/architecture/when-to-mock.md
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
# Mocking Strategy: Real vs Mock Services
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Core Principle](#core-principle)
|
||||
2. [Decision Matrix](#decision-matrix)
|
||||
3. [Decision Flowchart](#decision-flowchart)
|
||||
4. [Mocking Techniques](#mocking-techniques)
|
||||
5. [Real Service Strategies](#real-service-strategies)
|
||||
6. [Hybrid Approach: Fixture-Based Mock Control](#hybrid-approach-fixture-based-mock-control)
|
||||
7. [Validating Mock Accuracy](#validating-mock-accuracy)
|
||||
8. [Anti-Patterns](#anti-patterns)
|
||||
|
||||
> **When to use**: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**Mock at the boundary, test your stack end-to-end.** Mock third-party services you don't own (payment gateways, email providers, OAuth). Never mock your own frontend-to-backend communication. Tests should prove YOUR code works, not that third-party APIs are available.
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Scenario | Mock? | Strategy |
|
||||
| --- | --- | --- |
|
||||
| Your own REST/GraphQL API | Never | Hit real API against staging or local dev |
|
||||
| Your database (through your API) | Never | Seed via API or fixtures |
|
||||
| Authentication (your auth system) | Mostly no | Use `storageState` to skip login in most tests |
|
||||
| Stripe / payment gateway | Always | `route.fulfill()` with expected responses |
|
||||
| SendGrid / email service | Always | Mock the API call, verify request payload |
|
||||
| OAuth providers (Google, GitHub) | Always | Mock token exchange, test your callback handler |
|
||||
| Analytics (Segment, Mixpanel) | Always | `route.abort()` or `route.fulfill()` |
|
||||
| Maps / geocoding APIs | Always | Mock with static responses |
|
||||
| Feature flags (LaunchDarkly) | Usually | Mock to force specific flag states |
|
||||
| CDN / static assets | Never | Let them load normally |
|
||||
| Flaky external dependency | CI: mock, local: real | Conditional mocking based on environment |
|
||||
| Slow external dependency | Dev: mock, nightly: real | Separate test projects in config |
|
||||
|
||||
## Decision Flowchart
|
||||
|
||||
```text
|
||||
Is this service part of YOUR codebase?
|
||||
├── YES → Do NOT mock. Test the real integration.
|
||||
│ ├── Is it slow? → Optimize the service, not the test.
|
||||
│ └── Is it flaky? → Fix the service. Flaky infra is a bug.
|
||||
└── NO → It's a third-party service.
|
||||
├── Is it paid per call? → ALWAYS mock.
|
||||
├── Is it rate-limited? → ALWAYS mock.
|
||||
├── Is it slow or unreliable? → ALWAYS mock.
|
||||
└── Is it a complex multi-step flow? → Mock with HAR recording.
|
||||
```
|
||||
|
||||
## Mocking Techniques
|
||||
|
||||
### Blocking Unwanted Requests
|
||||
|
||||
Block third-party scripts that slow tests and add no coverage:
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/{analytics,tracking,segment,hotjar}.{com,io}/**', (route) => {
|
||||
route.abort();
|
||||
});
|
||||
});
|
||||
|
||||
test('dashboard renders without tracking scripts', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Full Mock (route.fulfill)
|
||||
|
||||
Completely replace a third-party API response:
|
||||
|
||||
```typescript
|
||||
test('order flow with mocked payment service', async ({ page }) => {
|
||||
await page.route('**/api/charge', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
transactionId: 'txn_mock_abc',
|
||||
status: 'completed',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/order/confirm');
|
||||
await page.getByRole('button', { name: 'Complete Purchase' }).click();
|
||||
await expect(page.getByText('Order confirmed')).toBeVisible();
|
||||
});
|
||||
|
||||
test('display error on payment decline', async ({ page }) => {
|
||||
await page.route('**/api/charge', (route) => {
|
||||
route.fulfill({
|
||||
status: 402,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: { code: 'insufficient_funds', message: 'Card declined.' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/order/confirm');
|
||||
await page.getByRole('button', { name: 'Complete Purchase' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('Card declined');
|
||||
});
|
||||
```
|
||||
|
||||
### Partial Mock (Modify Responses)
|
||||
|
||||
Let the real API call happen but tweak the response:
|
||||
|
||||
```typescript
|
||||
test('display low inventory warning', async ({ page }) => {
|
||||
await page.route('**/api/inventory/*', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const data = await response.json();
|
||||
|
||||
data.quantity = 1;
|
||||
data.lowStock = true;
|
||||
|
||||
await route.fulfill({
|
||||
response,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/products/widget-pro');
|
||||
await expect(page.getByText('Only 1 remaining')).toBeVisible();
|
||||
});
|
||||
|
||||
test('inject test notification into real response', async ({ page }) => {
|
||||
await page.route('**/api/alerts', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const data = await response.json();
|
||||
|
||||
data.items.push({
|
||||
id: 'test-alert',
|
||||
text: 'Report generated',
|
||||
category: 'info',
|
||||
});
|
||||
|
||||
await route.fulfill({
|
||||
response,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/home');
|
||||
await expect(page.getByText('Report generated')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Record and Replay (HAR Files)
|
||||
|
||||
For complex API sequences (OAuth flows, multi-step wizards):
|
||||
|
||||
**Recording:**
|
||||
|
||||
```typescript
|
||||
test('capture API traffic for admin panel', async ({ page }) => {
|
||||
await page.routeFromHAR('tests/fixtures/admin-panel.har', {
|
||||
url: '**/api/**',
|
||||
update: true,
|
||||
});
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Reports' }).click();
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
});
|
||||
```
|
||||
|
||||
**Replaying:**
|
||||
|
||||
```typescript
|
||||
test('admin panel loads with recorded data', async ({ page }) => {
|
||||
await page.routeFromHAR('tests/fixtures/admin-panel.har', {
|
||||
url: '**/api/**',
|
||||
update: false,
|
||||
});
|
||||
|
||||
await page.goto('/admin');
|
||||
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**HAR maintenance:**
|
||||
|
||||
- Record against a known-good staging environment
|
||||
- Commit `.har` files to version control
|
||||
- Re-record when APIs change
|
||||
- Scope HAR to specific URL patterns
|
||||
|
||||
## Real Service Strategies
|
||||
|
||||
### Local Dev Server
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30_000,
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Staging Environment
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
use: {
|
||||
baseURL: process.env.CI
|
||||
? 'https://staging.example.com'
|
||||
: 'http://localhost:3000',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Test Containers
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
webServer: {
|
||||
command: 'docker compose -f docker-compose.test.yml up --wait',
|
||||
url: 'http://localhost:3000/health',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
globalTeardown: './tests/global-teardown.ts',
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/global-teardown.ts
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export default function globalTeardown() {
|
||||
if (process.env.CI) {
|
||||
execSync('docker compose -f docker-compose.test.yml down -v');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Hybrid Approach: Fixture-Based Mock Control
|
||||
|
||||
Create fixtures that let individual tests opt into mocking specific services:
|
||||
|
||||
```typescript
|
||||
// tests/fixtures/service-mocks.ts
|
||||
import { test as base } from '@playwright/test';
|
||||
|
||||
type MockConfig = {
|
||||
mockPayments: boolean;
|
||||
mockNotifications: boolean;
|
||||
mockAnalytics: boolean;
|
||||
};
|
||||
|
||||
export const test = base.extend<MockConfig>({
|
||||
mockPayments: [true, { option: true }],
|
||||
mockNotifications: [true, { option: true }],
|
||||
mockAnalytics: [true, { option: true }],
|
||||
|
||||
page: async ({ page, mockPayments, mockNotifications, mockAnalytics }, use) => {
|
||||
if (mockPayments) {
|
||||
await page.route('**/api/billing/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'paid', id: 'inv_mock_789' }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (mockNotifications) {
|
||||
await page.route('**/api/notify', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ delivered: true }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (mockAnalytics) {
|
||||
await page.route('**/{segment,mixpanel,amplitude}.**/**', (route) => {
|
||||
route.abort();
|
||||
});
|
||||
}
|
||||
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/billing.spec.ts
|
||||
import { test, expect } from './fixtures/service-mocks';
|
||||
|
||||
test('subscription renewal sends notification', async ({ page }) => {
|
||||
await page.goto('/account/billing');
|
||||
await page.getByRole('button', { name: 'Renew Now' }).click();
|
||||
await expect(page.getByText('Subscription renewed')).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('integration suite', () => {
|
||||
test.use({ mockPayments: false });
|
||||
|
||||
test('real billing flow against test gateway', async ({ page }) => {
|
||||
await page.goto('/account/billing');
|
||||
await page.getByRole('button', { name: 'Renew Now' }).click();
|
||||
await expect(page.getByText('Subscription renewed')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Environment-Based Test Projects
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: 'ci-fast',
|
||||
testMatch: '**/*.spec.ts',
|
||||
use: { baseURL: 'http://localhost:3000' },
|
||||
},
|
||||
{
|
||||
name: 'nightly-full',
|
||||
testMatch: '**/*.integration.spec.ts',
|
||||
use: { baseURL: 'https://staging.example.com' },
|
||||
timeout: 120_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Validating Mock Accuracy
|
||||
|
||||
Guard against mock drift from real APIs:
|
||||
|
||||
```typescript
|
||||
test.describe('contract validation', () => {
|
||||
test('billing mock matches real API shape', async ({ request }) => {
|
||||
const realResponse = await request.post('/api/billing/charge', {
|
||||
data: { amount: 5000, currency: 'usd' },
|
||||
});
|
||||
const realBody = await realResponse.json();
|
||||
|
||||
const mockBody = {
|
||||
status: 'paid',
|
||||
id: 'inv_mock_789',
|
||||
};
|
||||
|
||||
expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort());
|
||||
|
||||
for (const key of Object.keys(mockBody)) {
|
||||
expect(typeof mockBody[key]).toBe(typeof realBody[key]);
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't Do This | Problem | Do This Instead |
|
||||
| --- | --- | --- |
|
||||
| Mock your own API | Tests pass, app breaks. Zero integration coverage. | Hit your real API. Mock only third-party services. |
|
||||
| Mock everything for speed | You test a fiction. Frontend and backend may be incompatible. | Mock only external boundaries. |
|
||||
| Never mock anything | Tests are slow, flaky, fail when third parties have outages. | Mock third-party services. |
|
||||
| Use outdated mocks | Mock returns different shape than real API. | Run contract validation tests. Re-record HAR files regularly. |
|
||||
| Mock with `page.evaluate()` to stub fetch | Fragile, doesn't survive navigation. | Use `page.route()` which intercepts at network layer. |
|
||||
| Copy-paste mocks across files | One API change requires updating many files. | Centralize mocks in fixtures. |
|
||||
| Block all network and whitelist | Extremely brittle. Every new endpoint requires update. | Allow all by default. Selectively mock third-party services. |
|
||||
Loading…
Add table
Add a link
Reference in a new issue