mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 09:12:40 +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
424
.cursor/skills/playwright-testing/core/annotations.md
Normal file
424
.cursor/skills/playwright-testing/core/annotations.md
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
# Test Annotations & Organization
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Skip Annotations](#skip-annotations)
|
||||
2. [Fixme & Fail Annotations](#fixme--fail-annotations)
|
||||
3. [Slow Tests](#slow-tests)
|
||||
4. [Test Steps](#test-steps)
|
||||
5. [Custom Annotations](#custom-annotations)
|
||||
6. [Conditional Annotations](#conditional-annotations)
|
||||
|
||||
## Skip Annotations
|
||||
|
||||
### Basic Skip
|
||||
|
||||
```typescript
|
||||
// Skip unconditionally
|
||||
test.skip("feature not implemented", async ({ page }) => {
|
||||
// This test won't run
|
||||
});
|
||||
|
||||
// Skip with reason
|
||||
test("payment flow", async ({ page }) => {
|
||||
test.skip(true, "Payment gateway in maintenance");
|
||||
// Test body won't execute
|
||||
});
|
||||
```
|
||||
|
||||
### Conditional Skip
|
||||
|
||||
```typescript
|
||||
test("webkit-specific feature", async ({ page, browserName }) => {
|
||||
test.skip(browserName !== "webkit", "This feature only works in WebKit");
|
||||
|
||||
await page.goto("/webkit-feature");
|
||||
});
|
||||
|
||||
test("production only", async ({ page }) => {
|
||||
test.skip(process.env.ENV !== "production", "Only runs against production");
|
||||
|
||||
await page.goto("/prod-feature");
|
||||
});
|
||||
```
|
||||
|
||||
### Skip by Platform
|
||||
|
||||
```typescript
|
||||
test("windows-specific", async ({ page }) => {
|
||||
test.skip(process.platform !== "win32", "Windows only");
|
||||
});
|
||||
|
||||
test("not on CI", async ({ page }) => {
|
||||
test.skip(!!process.env.CI, "Skipped in CI environment");
|
||||
});
|
||||
```
|
||||
|
||||
### Skip Describe Block
|
||||
|
||||
```typescript
|
||||
test.describe("Admin features", () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
"Firefox admin bug",
|
||||
);
|
||||
|
||||
test("admin dashboard", async ({ page }) => {
|
||||
// Skipped in Firefox
|
||||
});
|
||||
|
||||
test("admin settings", async ({ page }) => {
|
||||
// Skipped in Firefox
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Fixme & Fail Annotations
|
||||
|
||||
### Fixme - Known Issues
|
||||
|
||||
```typescript
|
||||
// Mark test as needing fix (skips the test)
|
||||
test.fixme("broken after refactor", async ({ page }) => {
|
||||
// Test won't run but is tracked
|
||||
});
|
||||
|
||||
// Conditional fixme
|
||||
test("flaky on CI", async ({ page }) => {
|
||||
test.fixme(!!process.env.CI, "Investigate CI flakiness - ticket #123");
|
||||
|
||||
await page.goto("/flaky-feature");
|
||||
});
|
||||
```
|
||||
|
||||
### Fail - Expected Failures
|
||||
|
||||
```typescript
|
||||
// Test is expected to fail (runs but expects failure)
|
||||
test("known bug", async ({ page }) => {
|
||||
test.fail();
|
||||
|
||||
await page.goto("/buggy-page");
|
||||
// If this passes, the test fails (bug was fixed!)
|
||||
await expect(page.getByText("Working")).toBeVisible();
|
||||
});
|
||||
|
||||
// Conditional fail
|
||||
test("fails on webkit", async ({ page, browserName }) => {
|
||||
test.fail(browserName === "webkit", "WebKit rendering bug #456");
|
||||
|
||||
await page.goto("/render-test");
|
||||
await expect(page.getByTestId("element")).toHaveCSS("width", "100px");
|
||||
});
|
||||
```
|
||||
|
||||
### Difference Between Skip, Fixme, Fail
|
||||
|
||||
| Annotation | Runs? | Use Case |
|
||||
| -------------- | ----- | -------------------------------- |
|
||||
| `test.skip()` | No | Feature not applicable |
|
||||
| `test.fixme()` | No | Known bug, needs investigation |
|
||||
| `test.fail()` | Yes | Expected to fail, tracking a bug |
|
||||
|
||||
## Slow Tests
|
||||
|
||||
### Mark Slow Tests
|
||||
|
||||
```typescript
|
||||
// Triple the default timeout
|
||||
test("large data import", async ({ page }) => {
|
||||
test.slow();
|
||||
|
||||
await page.goto("/import");
|
||||
await page.setInputFiles("#file", "large-file.csv");
|
||||
await page.getByRole("button", { name: "Import" }).click();
|
||||
|
||||
await expect(page.getByText("Import complete")).toBeVisible();
|
||||
});
|
||||
|
||||
// Conditional slow
|
||||
test("video processing", async ({ page, browserName }) => {
|
||||
test.slow(browserName === "webkit", "WebKit video processing is slow");
|
||||
|
||||
await page.goto("/video-editor");
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Timeout
|
||||
|
||||
```typescript
|
||||
test("very long operation", async ({ page }) => {
|
||||
// Set specific timeout (in milliseconds)
|
||||
test.setTimeout(120000); // 2 minutes
|
||||
|
||||
await page.goto("/long-operation");
|
||||
});
|
||||
|
||||
// Timeout for describe block
|
||||
test.describe("Integration tests", () => {
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
test("test 1", async ({ page }) => {
|
||||
// Has 60 second timeout
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Steps
|
||||
|
||||
### Basic Steps
|
||||
|
||||
```typescript
|
||||
test("checkout flow", async ({ page }) => {
|
||||
await test.step("Add item to cart", async () => {
|
||||
await page.goto("/products");
|
||||
await page.getByRole("button", { name: "Add to Cart" }).click();
|
||||
});
|
||||
|
||||
await test.step("Go to checkout", async () => {
|
||||
await page.getByRole("link", { name: "Cart" }).click();
|
||||
await page.getByRole("button", { name: "Checkout" }).click();
|
||||
});
|
||||
|
||||
await test.step("Fill shipping info", async () => {
|
||||
await page.getByLabel("Address").fill("123 Test St");
|
||||
await page.getByLabel("City").fill("Test City");
|
||||
});
|
||||
|
||||
await test.step("Complete payment", async () => {
|
||||
await page.getByLabel("Card").fill("4242424242424242");
|
||||
await page.getByRole("button", { name: "Pay" }).click();
|
||||
});
|
||||
|
||||
await expect(page.getByText("Order confirmed")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Nested Steps
|
||||
|
||||
```typescript
|
||||
test("user registration", async ({ page }) => {
|
||||
await test.step("Fill registration form", async () => {
|
||||
await page.goto("/register");
|
||||
|
||||
await test.step("Personal info", async () => {
|
||||
await page.getByLabel("Name").fill("John Doe");
|
||||
await page.getByLabel("Email").fill("john@example.com");
|
||||
});
|
||||
|
||||
await test.step("Security", async () => {
|
||||
await page.getByLabel("Password").fill("SecurePass123");
|
||||
await page.getByLabel("Confirm Password").fill("SecurePass123");
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Submit and verify", async () => {
|
||||
await page.getByRole("button", { name: "Register" }).click();
|
||||
await expect(page.getByText("Welcome")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Steps with Return Values
|
||||
|
||||
```typescript
|
||||
test("verify order", async ({ page }) => {
|
||||
const orderId = await test.step("Create order", async () => {
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Place Order" }).click();
|
||||
|
||||
// Return value from step
|
||||
return await page.getByTestId("order-id").textContent();
|
||||
});
|
||||
|
||||
await test.step("Verify order details", async () => {
|
||||
await page.goto(`/orders/${orderId}`);
|
||||
await expect(page.getByText(`Order #${orderId}`)).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Step in Page Object
|
||||
|
||||
```typescript
|
||||
// pages/checkout.page.ts
|
||||
export class CheckoutPage {
|
||||
async fillShippingInfo(address: string, city: string) {
|
||||
await test.step("Fill shipping information", async () => {
|
||||
await this.page.getByLabel("Address").fill(address);
|
||||
await this.page.getByLabel("City").fill(city);
|
||||
});
|
||||
}
|
||||
|
||||
async completePayment(cardNumber: string) {
|
||||
await test.step("Complete payment", async () => {
|
||||
await this.page.getByLabel("Card").fill(cardNumber);
|
||||
await this.page.getByRole("button", { name: "Pay" }).click();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Annotations
|
||||
|
||||
### Add Annotations
|
||||
|
||||
```typescript
|
||||
test("important feature", async ({ page }, testInfo) => {
|
||||
// Add custom annotation
|
||||
testInfo.annotations.push({
|
||||
type: "priority",
|
||||
description: "high",
|
||||
});
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: "ticket",
|
||||
description: "JIRA-123",
|
||||
});
|
||||
|
||||
await page.goto("/feature");
|
||||
});
|
||||
```
|
||||
|
||||
### Annotation Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/annotations.fixture.ts
|
||||
import { test as base, TestInfo } from "@playwright/test";
|
||||
|
||||
type AnnotationFixtures = {
|
||||
annotate: {
|
||||
ticket: (id: string) => void;
|
||||
priority: (level: "low" | "medium" | "high") => void;
|
||||
owner: (name: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export const test = base.extend<AnnotationFixtures>({
|
||||
annotate: async ({}, use, testInfo) => {
|
||||
await use({
|
||||
ticket: (id) => {
|
||||
testInfo.annotations.push({ type: "ticket", description: id });
|
||||
},
|
||||
priority: (level) => {
|
||||
testInfo.annotations.push({ type: "priority", description: level });
|
||||
},
|
||||
owner: (name) => {
|
||||
testInfo.annotations.push({ type: "owner", description: name });
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("critical feature", async ({ page, annotate }) => {
|
||||
annotate.ticket("JIRA-456");
|
||||
annotate.priority("high");
|
||||
annotate.owner("Alice");
|
||||
|
||||
await page.goto("/critical");
|
||||
});
|
||||
```
|
||||
|
||||
### Read Annotations in Reporter
|
||||
|
||||
```typescript
|
||||
// reporters/annotation-reporter.ts
|
||||
import { Reporter, TestCase, TestResult } from "@playwright/test/reporter";
|
||||
|
||||
class AnnotationReporter implements Reporter {
|
||||
onTestEnd(test: TestCase, result: TestResult) {
|
||||
const ticket = test.annotations.find((a) => a.type === "ticket");
|
||||
const priority = test.annotations.find((a) => a.type === "priority");
|
||||
|
||||
if (ticket) {
|
||||
console.log(`Test linked to: ${ticket.description}`);
|
||||
}
|
||||
|
||||
if (priority?.description === "high" && result.status === "failed") {
|
||||
console.log(`HIGH PRIORITY FAILURE: ${test.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AnnotationReporter;
|
||||
```
|
||||
|
||||
## Conditional Annotations
|
||||
|
||||
### Annotation Helper
|
||||
|
||||
```typescript
|
||||
// helpers/test-annotations.ts
|
||||
import { test } from "@playwright/test";
|
||||
|
||||
export function skipInCI(reason = "Skipped in CI") {
|
||||
test.skip(!!process.env.CI, reason);
|
||||
}
|
||||
|
||||
export function skipInBrowser(browser: string, reason: string) {
|
||||
test.beforeEach(({ browserName }) => {
|
||||
test.skip(browserName === browser, reason);
|
||||
});
|
||||
}
|
||||
|
||||
export function onlyInEnv(env: string) {
|
||||
test.skip(process.env.ENV !== env, `Only runs in ${env}`);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/feature.spec.ts
|
||||
import { skipInCI, onlyInEnv } from "../helpers/test-annotations";
|
||||
|
||||
test("local only feature", async ({ page }) => {
|
||||
skipInCI("Uses local resources");
|
||||
|
||||
await page.goto("/local-feature");
|
||||
});
|
||||
|
||||
test("production check", async ({ page }) => {
|
||||
onlyInEnv("production");
|
||||
|
||||
await page.goto("/prod-only");
|
||||
});
|
||||
```
|
||||
|
||||
### Describe-Level Conditions
|
||||
|
||||
```typescript
|
||||
test.describe("Mobile features", () => {
|
||||
test.beforeEach(({ isMobile }) => {
|
||||
test.skip(!isMobile, "Mobile only tests");
|
||||
});
|
||||
|
||||
test("touch gestures", async ({ page }) => {
|
||||
// Only runs on mobile
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Desktop features", () => {
|
||||
test.beforeEach(({ isMobile }) => {
|
||||
test.skip(isMobile, "Desktop only tests");
|
||||
});
|
||||
|
||||
test("hover interactions", async ({ page }) => {
|
||||
// Only runs on desktop
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| --------------------------- | ---------------------- | -------------------------------- |
|
||||
| Skipping without reason | Hard to track why | Always provide description |
|
||||
| Too many skipped tests | Test debt accumulates | Review and clean up regularly |
|
||||
| Using skip instead of fixme | Loses intent | Use fixme for bugs, skip for N/A |
|
||||
| Not using steps | Hard to debug failures | Group logical actions in steps |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Test Tags**: See [test-tags.md](test-tags.md) for tagging and filtering tests with `--grep`
|
||||
- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests
|
||||
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting
|
||||
361
.cursor/skills/playwright-testing/core/assertions-waiting.md
Normal file
361
.cursor/skills/playwright-testing/core/assertions-waiting.md
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
# Assertions & Waiting
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Web-First Assertions](#web-first-assertions)
|
||||
2. [Generic Assertions](#generic-assertions)
|
||||
3. [Soft Assertions](#soft-assertions)
|
||||
4. [Waiting Strategies](#waiting-strategies)
|
||||
5. [Polling & Retrying](#polling--retrying)
|
||||
6. [Custom Matchers](#custom-matchers)
|
||||
|
||||
## Web-First Assertions
|
||||
|
||||
Auto-retry until condition is met or timeout. Always prefer these over generic assertions.
|
||||
|
||||
### Locator Assertions
|
||||
|
||||
```typescript
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
// Visibility
|
||||
await expect(page.getByRole("button")).toBeVisible();
|
||||
await expect(page.getByRole("button")).toBeHidden();
|
||||
await expect(page.getByRole("button")).not.toBeVisible();
|
||||
|
||||
// Enabled/Disabled
|
||||
await expect(page.getByRole("button")).toBeEnabled();
|
||||
await expect(page.getByRole("button")).toBeDisabled();
|
||||
|
||||
// Text content
|
||||
await expect(page.getByRole("heading")).toHaveText("Welcome");
|
||||
await expect(page.getByRole("heading")).toHaveText(/welcome/i);
|
||||
await expect(page.getByRole("heading")).toContainText("Welcome");
|
||||
|
||||
// Count
|
||||
await expect(page.getByRole("listitem")).toHaveCount(5);
|
||||
|
||||
// Attributes
|
||||
await expect(page.getByRole("link")).toHaveAttribute("href", "/home");
|
||||
await expect(page.getByRole("img")).toHaveAttribute("alt", /logo/i);
|
||||
|
||||
// CSS
|
||||
await expect(page.getByRole("button")).toHaveClass(/primary/);
|
||||
await expect(page.getByRole("button")).toHaveCSS("color", "rgb(0, 0, 255)");
|
||||
|
||||
// Input values
|
||||
await expect(page.getByLabel("Email")).toHaveValue("user@example.com");
|
||||
await expect(page.getByLabel("Email")).toBeEmpty();
|
||||
|
||||
// Focus
|
||||
await expect(page.getByLabel("Email")).toBeFocused();
|
||||
|
||||
// Checked state
|
||||
await expect(page.getByRole("checkbox")).toBeChecked();
|
||||
await expect(page.getByRole("checkbox")).not.toBeChecked();
|
||||
|
||||
// Editable state
|
||||
await expect(page.getByLabel("Name")).toBeEditable();
|
||||
```
|
||||
|
||||
### Page Assertions
|
||||
|
||||
```typescript
|
||||
// URL
|
||||
await expect(page).toHaveURL("/dashboard");
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
|
||||
// Title
|
||||
await expect(page).toHaveTitle("Dashboard - MyApp");
|
||||
await expect(page).toHaveTitle(/dashboard/i);
|
||||
```
|
||||
|
||||
### Response Assertions
|
||||
|
||||
```typescript
|
||||
const response = await page.request.get("/api/users");
|
||||
await expect(response).toBeOK();
|
||||
await expect(response).not.toBeOK();
|
||||
```
|
||||
|
||||
## Generic Assertions
|
||||
|
||||
Use for non-UI values. Do NOT retry - execute immediately.
|
||||
|
||||
```typescript
|
||||
// Equality
|
||||
expect(value).toBe(5);
|
||||
expect(object).toEqual({ name: "Test" });
|
||||
expect(array).toContain("item");
|
||||
|
||||
// Truthiness
|
||||
expect(value).toBeTruthy();
|
||||
expect(value).toBeFalsy();
|
||||
expect(value).toBeNull();
|
||||
expect(value).toBeUndefined();
|
||||
expect(value).toBeDefined();
|
||||
|
||||
// Numbers
|
||||
expect(value).toBeGreaterThan(5);
|
||||
expect(value).toBeLessThanOrEqual(10);
|
||||
expect(value).toBeCloseTo(5.5, 1);
|
||||
|
||||
// Strings
|
||||
expect(string).toMatch(/pattern/);
|
||||
expect(string).toContain("substring");
|
||||
|
||||
// Arrays/Objects
|
||||
expect(array).toHaveLength(3);
|
||||
expect(object).toHaveProperty("key", "value");
|
||||
|
||||
// Exceptions
|
||||
expect(() => fn()).toThrow();
|
||||
expect(() => fn()).toThrow("error message");
|
||||
await expect(asyncFn()).rejects.toThrow();
|
||||
```
|
||||
|
||||
## Soft Assertions
|
||||
|
||||
Continue test execution after failure, report all failures at end.
|
||||
|
||||
```typescript
|
||||
test("check multiple elements", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Won't stop on first failure
|
||||
await expect.soft(page.getByRole("heading")).toHaveText("Dashboard");
|
||||
await expect.soft(page.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
await expect.soft(page.getByText("Welcome")).toBeVisible();
|
||||
|
||||
// Test continues; all failures reported at end
|
||||
});
|
||||
```
|
||||
|
||||
### Soft Assertions with Early Exit
|
||||
|
||||
```typescript
|
||||
test("check form", async ({ page }) => {
|
||||
await expect.soft(page.getByRole("form")).toBeVisible();
|
||||
|
||||
// Exit early if form not visible (pointless to check fields)
|
||||
if (expect.soft.hasFailures()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await expect.soft(page.getByLabel("Name")).toBeVisible();
|
||||
await expect.soft(page.getByLabel("Email")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Waiting Strategies
|
||||
|
||||
### Auto-Waiting (Default)
|
||||
|
||||
Actions automatically wait for:
|
||||
|
||||
- Element to be attached to DOM
|
||||
- Element to be visible
|
||||
- Element to be stable (no animations)
|
||||
- Element to be enabled
|
||||
- Element to receive events
|
||||
|
||||
```typescript
|
||||
// These auto-wait
|
||||
await page.click("button");
|
||||
await page.fill("input", "text");
|
||||
await page.getByRole("button").click();
|
||||
```
|
||||
|
||||
### Wait for Navigation
|
||||
|
||||
```typescript
|
||||
// Wait for URL change
|
||||
await page.waitForURL("/dashboard");
|
||||
await page.waitForURL(/\/dashboard/);
|
||||
|
||||
// Wait for navigation after action
|
||||
await Promise.all([
|
||||
page.waitForURL("**/dashboard"),
|
||||
page.click('a[href="/dashboard"]'),
|
||||
]);
|
||||
|
||||
// Or without Promise.all
|
||||
const urlPromise = page.waitForURL("**/dashboard");
|
||||
await page.click("a");
|
||||
await urlPromise;
|
||||
```
|
||||
|
||||
### Wait for Network
|
||||
|
||||
```typescript
|
||||
// Wait for specific response
|
||||
const responsePromise = page.waitForResponse("**/api/users");
|
||||
await page.click("button");
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// Wait for request
|
||||
const requestPromise = page.waitForRequest("**/api/submit");
|
||||
await page.click("button");
|
||||
const request = await requestPromise;
|
||||
|
||||
// Wait for no network activity
|
||||
await page.waitForLoadState("networkidle");
|
||||
```
|
||||
|
||||
### Wait for Element State
|
||||
|
||||
```typescript
|
||||
// Wait for element to appear
|
||||
await page.getByRole("dialog").waitFor({ state: "visible" });
|
||||
|
||||
// Wait for element to disappear
|
||||
await page.getByText("Loading...").waitFor({ state: "hidden" });
|
||||
|
||||
// Wait for element to be attached
|
||||
await page.getByTestId("result").waitFor({ state: "attached" });
|
||||
|
||||
// Wait for element to be detached
|
||||
await page.getByTestId("modal").waitFor({ state: "detached" });
|
||||
```
|
||||
|
||||
### Wait for Function
|
||||
|
||||
```typescript
|
||||
// Wait for arbitrary condition
|
||||
await page.waitForFunction(() => {
|
||||
return document.querySelector(".loaded") !== null;
|
||||
});
|
||||
|
||||
// With arguments
|
||||
await page.waitForFunction(
|
||||
(selector) => document.querySelector(selector)?.textContent === "Ready",
|
||||
".status",
|
||||
);
|
||||
```
|
||||
|
||||
## Polling & Retrying
|
||||
|
||||
### toPass() for Polling
|
||||
|
||||
Retry until block passes or times out:
|
||||
|
||||
```typescript
|
||||
await expect(async () => {
|
||||
const response = await page.request.get("/api/status");
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.ready).toBe(true);
|
||||
}).toPass({
|
||||
intervals: [1000, 2000, 5000], // Retry intervals
|
||||
timeout: 30000,
|
||||
});
|
||||
```
|
||||
|
||||
### expect.poll()
|
||||
|
||||
Poll a function until assertion passes:
|
||||
|
||||
```typescript
|
||||
// Poll API until condition met
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const response = await page.request.get("/api/job/123");
|
||||
return (await response.json()).status;
|
||||
},
|
||||
{
|
||||
intervals: [1000, 2000, 5000],
|
||||
timeout: 30000,
|
||||
},
|
||||
)
|
||||
.toBe("completed");
|
||||
|
||||
// Poll DOM value
|
||||
await expect.poll(() => page.getByTestId("counter").textContent()).toBe("10");
|
||||
```
|
||||
|
||||
## Custom Matchers
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts or fixtures
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
expect.extend({
|
||||
async toHaveDataLoaded(page: Page) {
|
||||
const locator = page.getByTestId("data-container");
|
||||
let pass = false;
|
||||
let message = "";
|
||||
|
||||
try {
|
||||
await expect(locator).toBeVisible();
|
||||
await expect(locator).not.toContainText("Loading");
|
||||
pass = true;
|
||||
} catch (e) {
|
||||
message = `Expected data to be loaded but found loading state`;
|
||||
}
|
||||
|
||||
return { pass, message: () => message };
|
||||
},
|
||||
});
|
||||
|
||||
// Extend TypeScript types
|
||||
declare global {
|
||||
namespace PlaywrightTest {
|
||||
interface Matchers<R> {
|
||||
toHaveDataLoaded(): Promise<R>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
await expect(page).toHaveDataLoaded();
|
||||
```
|
||||
|
||||
## Timeouts
|
||||
|
||||
### Configure Timeouts
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
timeout: 30000, // Test timeout
|
||||
expect: {
|
||||
timeout: 5000, // Assertion timeout
|
||||
},
|
||||
});
|
||||
|
||||
// Per-test timeout
|
||||
test("long test", async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
// ...
|
||||
});
|
||||
|
||||
// Per-assertion timeout
|
||||
await expect(page.getByRole("button")).toBeVisible({ timeout: 10000 });
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
| Do | Don't |
|
||||
| ------------------------------ | ------------------------------ |
|
||||
| Use web-first assertions | Use generic assertions for DOM |
|
||||
| Let auto-waiting work | Add unnecessary explicit waits |
|
||||
| Use `toPass()` for polling | Write manual retry loops |
|
||||
| Configure appropriate timeouts | Use `waitForTimeout()` |
|
||||
| Check specific conditions | Wait for arbitrary time |
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| --------------------------------------------------------- | ----------------------------- | -------------------------------------------- |
|
||||
| `await page.waitForTimeout(5000)` | Slow, flaky, arbitrary timing | Use auto-waiting or `waitForResponse` |
|
||||
| `await new Promise(resolve => setTimeout(resolve, 1000))` | Same as above | Use `waitForResponse` or element state waits |
|
||||
| Generic assertions on DOM elements | No auto-retry, flaky | Use web-first assertions with `expect()` |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Debugging timeout issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
|
||||
- **Fixing flaky tests**: See [debugging.md](../debugging/debugging.md) for race condition solutions
|
||||
- **Network interception**: See [test-suite-structure.md](test-suite-structure.md) for API mocking
|
||||
452
.cursor/skills/playwright-testing/core/configuration.md
Normal file
452
.cursor/skills/playwright-testing/core/configuration.md
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
# Playwright Configuration
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [CLI Quick Reference](#cli-quick-reference)
|
||||
2. [Decision Guide](#decision-guide)
|
||||
3. [Production-Ready Config](#production-ready-config)
|
||||
4. [Patterns](#patterns)
|
||||
5. [Anti-Patterns](#anti-patterns)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
7. [Related](#related)
|
||||
|
||||
> **When to use**: Setting up a new project, adjusting timeouts, adding browser targets, configuring CI behavior, or managing environment-specific settings.
|
||||
|
||||
## CLI Quick Reference
|
||||
|
||||
```bash
|
||||
npx playwright init # scaffold config + first test
|
||||
npx playwright test --config=custom.config.ts # use alternate config
|
||||
npx playwright test --project=chromium # run single project
|
||||
npx playwright test --reporter=html # override reporter
|
||||
npx playwright test --grep @smoke # run tests tagged @smoke
|
||||
npx playwright test --grep-invert @slow # exclude @slow tests
|
||||
npx playwright show-report # open last HTML report
|
||||
DEBUG=pw:api npx playwright test # verbose logging
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
### Timeout Selection
|
||||
|
||||
| Symptom | Setting | Default | Recommended |
|
||||
|---------|---------|---------|-------------|
|
||||
| Test takes too long overall | `timeout` | 30s | 30-60s (max 120s) |
|
||||
| Assertion retries too long/short | `expect.timeout` | 5s | 5-10s |
|
||||
| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 30s | 10-30s |
|
||||
| `click()`, `fill()` time out | `actionTimeout` | 0 (unlimited) | 10-15s |
|
||||
| Dev server slow to start | `webServer.timeout` | 60s | 60-180s |
|
||||
|
||||
### Server Management
|
||||
|
||||
| Scenario | Approach |
|
||||
|----------|----------|
|
||||
| App in same repo | `webServer` with `reuseExistingServer: !process.env.CI` |
|
||||
| Separate repos | Manual start or Docker Compose |
|
||||
| Testing deployed environment | No `webServer`; set `baseURL` via env |
|
||||
| Multiple services | Array of `webServer` entries |
|
||||
|
||||
### Single vs Multi-Project
|
||||
|
||||
| Scenario | Approach |
|
||||
|----------|----------|
|
||||
| Early development | Single project (chromium only) |
|
||||
| Pre-release validation | Multi-project: chromium + firefox + webkit |
|
||||
| Mobile-responsive app | Add mobile projects alongside desktop |
|
||||
| Auth + non-auth tests | Setup project with dependencies |
|
||||
| Tight CI budget | Chromium on PRs; all browsers on main |
|
||||
|
||||
### globalSetup vs Setup Projects vs Fixtures
|
||||
|
||||
| Need | Use |
|
||||
|------|-----|
|
||||
| One-time DB seed | `globalSetup` |
|
||||
| Shared browser auth | Setup project with `dependencies` |
|
||||
| Per-test isolated state | Custom fixture via `test.extend()` |
|
||||
| Cleanup after all tests | `globalTeardown` |
|
||||
|
||||
## Production-Ready Config
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: '**/*.spec.ts',
|
||||
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? '50%' : undefined,
|
||||
|
||||
reporter: process.env.CI
|
||||
? [['html', { open: 'never' }], ['github']]
|
||||
: [['html', { open: 'on-failure' }]],
|
||||
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:4000',
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 15_000,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/Los_Angeles',
|
||||
},
|
||||
|
||||
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 7'] } },
|
||||
{ name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run start',
|
||||
url: 'http://localhost:4000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Environment-Specific Configuration
|
||||
|
||||
**Use when**: Tests run against dev, staging, and production environments.
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
const ENV = process.env.TEST_ENV || 'local';
|
||||
dotenv.config({ path: path.resolve(__dirname, `.env.${ENV}`) });
|
||||
|
||||
const envConfig: Record<string, { baseURL: string; retries: number }> = {
|
||||
local: { baseURL: 'http://localhost:4000', retries: 0 },
|
||||
staging: { baseURL: 'https://staging.myapp.com', retries: 2 },
|
||||
prod: { baseURL: 'https://myapp.com', retries: 2 },
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
retries: envConfig[ENV].retries,
|
||||
use: { baseURL: envConfig[ENV].baseURL },
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
TEST_ENV=staging npx playwright test
|
||||
TEST_ENV=prod npx playwright test --grep @smoke
|
||||
```
|
||||
|
||||
### Setup Project with Dependencies
|
||||
|
||||
**Use when**: Tests need shared authentication state before running.
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /auth\.setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'playwright/.auth/session.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
storageState: 'playwright/.auth/session.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// e2e/auth.setup.ts
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
const authFile = 'playwright/.auth/session.json';
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Username').fill('testuser@example.com');
|
||||
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
```
|
||||
|
||||
### webServer with Build Step
|
||||
|
||||
**Use when**: Tests need a running application server managed by Playwright.
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
use: { baseURL: 'http://localhost:4000' },
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? 'npm run build && npm run preview'
|
||||
: 'npm run dev',
|
||||
url: 'http://localhost:4000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
NODE_ENV: 'test',
|
||||
DB_URL: process.env.DB_URL || 'postgresql://localhost:5432/testdb',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### globalSetup / globalTeardown
|
||||
|
||||
**Use when**: One-time non-browser work like seeding a database. Runs once per test run.
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
globalSetup: './e2e/setup.ts',
|
||||
globalTeardown: './e2e/teardown.ts',
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// e2e/setup.ts
|
||||
import { FullConfig } from '@playwright/test';
|
||||
|
||||
export default async function globalSetup(config: FullConfig) {
|
||||
const { execSync } = await import('child_process');
|
||||
execSync('npx prisma db seed', { stdio: 'inherit' });
|
||||
process.env.TEST_RUN_ID = `run-${Date.now()}`;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// e2e/teardown.ts
|
||||
import { FullConfig } from '@playwright/test';
|
||||
|
||||
export default async function globalTeardown(config: FullConfig) {
|
||||
const { execSync } = await import('child_process');
|
||||
execSync('npx prisma db push --force-reset', { stdio: 'inherit' });
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables with .env
|
||||
|
||||
**Use when**: Managing secrets, URLs, or feature flags without hardcoding.
|
||||
|
||||
```bash
|
||||
# .env.example (commit this)
|
||||
BASE_URL=http://localhost:4000
|
||||
TEST_PASSWORD=
|
||||
API_KEY=
|
||||
|
||||
# .env.local (gitignored)
|
||||
BASE_URL=http://localhost:4000
|
||||
TEST_PASSWORD=secret123
|
||||
API_KEY=dev-key-abc
|
||||
|
||||
# .env.staging (gitignored)
|
||||
BASE_URL=https://staging.myapp.com
|
||||
TEST_PASSWORD=staging-pass
|
||||
API_KEY=staging-key-xyz
|
||||
```
|
||||
|
||||
```bash
|
||||
# .gitignore
|
||||
.env
|
||||
.env.local
|
||||
.env.staging
|
||||
.env.production
|
||||
playwright/.auth/
|
||||
```
|
||||
|
||||
Install dotenv:
|
||||
|
||||
```bash
|
||||
npm install -D dotenv
|
||||
```
|
||||
|
||||
### Tag-Based Test Filtering
|
||||
|
||||
**Use when**: Running subsets of tests in different CI stages (PR vs nightly).
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
|
||||
// Filter by tags in CI
|
||||
grep: process.env.CI ? /@smoke|@critical/ : undefined,
|
||||
grepInvert: process.env.CI ? /@flaky/ : undefined,
|
||||
});
|
||||
```
|
||||
|
||||
**Project-specific filtering:**
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
projects: [
|
||||
{
|
||||
name: 'smoke',
|
||||
grep: /@smoke/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'regression',
|
||||
grepInvert: /@smoke/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'critical-only',
|
||||
grep: /@critical/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
# Run specific project
|
||||
npx playwright test --project=smoke
|
||||
npx playwright test --project=regression
|
||||
```
|
||||
|
||||
### Artifact Collection Strategy
|
||||
|
||||
| Setting | Local | CI | Reason |
|
||||
|---------|-------|-----|--------|
|
||||
| `trace` | `'off'` | `'on-first-retry'` | Traces are large; collect on failure only |
|
||||
| `screenshot` | `'off'` | `'only-on-failure'` | Useful for CI debugging |
|
||||
| `video` | `'off'` | `'retain-on-failure'` | Recording slows tests |
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
use: {
|
||||
trace: process.env.CI ? 'on-first-retry' : 'off',
|
||||
screenshot: process.env.CI ? 'only-on-failure' : 'off',
|
||||
video: process.env.CI ? 'retain-on-failure' : 'off',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't | Problem | Do Instead |
|
||||
|-------|---------|------------|
|
||||
| `timeout: 300_000` globally | Masks flaky tests; slow CI | Fix root cause; keep 30s default |
|
||||
| Hardcoded URLs: `page.goto('http://localhost:4000/login')` | Breaks in other environments | Use `baseURL` + relative paths |
|
||||
| All browsers on every PR | 3x CI time | Chromium on PRs; all on main |
|
||||
| `trace: 'on'` always | Huge artifacts, slow uploads | `trace: 'on-first-retry'` |
|
||||
| `video: 'on'` always | Massive storage; slow tests | `video: 'retain-on-failure'` |
|
||||
| Config in test files: `test.use({ viewport: {...} })` everywhere | Scattered, inconsistent | Define once in project config |
|
||||
| `retries: 3` locally | Hides flakiness | `retries: 0` local, `retries: 2` CI |
|
||||
| No `forbidOnly` in CI | Committed `test.only` runs single test | `forbidOnly: !!process.env.CI` |
|
||||
| `globalSetup` for browser auth | No browser context available | Use setup project with dependencies |
|
||||
| Committing `.env` with credentials | Security risk | Commit `.env.example` only |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### baseURL Not Working
|
||||
|
||||
**Cause**: Using absolute URL in `page.goto()` ignores `baseURL`.
|
||||
|
||||
```ts
|
||||
// Wrong - ignores baseURL
|
||||
await page.goto('http://localhost:4000/dashboard');
|
||||
|
||||
// Correct - uses baseURL
|
||||
await page.goto('/dashboard');
|
||||
```
|
||||
|
||||
### webServer Starts But Tests Get Connection Refused
|
||||
|
||||
**Cause**: `webServer.url` doesn't match actual server address or health check returns non-200.
|
||||
|
||||
```ts
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:4000/api/health', // use real endpoint
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
```
|
||||
|
||||
### Tests Pass Locally But Timeout in CI
|
||||
|
||||
**Cause**: CI machines are slower. Increase timeouts and reduce workers:
|
||||
|
||||
```ts
|
||||
export default defineConfig({
|
||||
workers: process.env.CI ? '50%' : undefined,
|
||||
use: {
|
||||
navigationTimeout: process.env.CI ? 30_000 : 15_000,
|
||||
actionTimeout: process.env.CI ? 15_000 : 10_000,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### "Target page, context or browser has been closed"
|
||||
|
||||
**Cause**: Test exceeded `timeout` and Playwright tore down browser during action.
|
||||
|
||||
**Fix**: Don't increase global timeout. Find slow step using trace:
|
||||
|
||||
```bash
|
||||
npx playwright test --trace on
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [test-tags.md](./test-tags.md) - tagging and filtering tests with `--grep`
|
||||
- [fixtures-hooks.md](./fixtures-hooks.md) - custom fixtures for per-test state
|
||||
- [test-suite-structure.md](test-suite-structure.md) - file structure and naming
|
||||
- [authentication.md](../advanced/authentication.md) - setup projects for shared auth
|
||||
- [projects-dependencies.md](./projects-dependencies.md) - advanced multi-project patterns
|
||||
417
.cursor/skills/playwright-testing/core/fixtures-hooks.md
Normal file
417
.cursor/skills/playwright-testing/core/fixtures-hooks.md
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
# Fixtures & Hooks
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Built-in Fixtures](#built-in-fixtures)
|
||||
2. [Custom Fixtures](#custom-fixtures)
|
||||
3. [Fixture Scopes](#fixture-scopes)
|
||||
4. [Hooks](#hooks)
|
||||
5. [Authentication Patterns](#authentication-patterns)
|
||||
6. [Database Fixtures](#database-fixtures)
|
||||
|
||||
## Built-in Fixtures
|
||||
|
||||
### Core Fixtures
|
||||
|
||||
```typescript
|
||||
test("example", async ({
|
||||
page, // Isolated page instance
|
||||
context, // Browser context (cookies, localStorage)
|
||||
browser, // Browser instance
|
||||
browserName, // 'chromium', 'firefox', or 'webkit'
|
||||
request, // API request context
|
||||
}) => {
|
||||
// Each test gets fresh instances
|
||||
});
|
||||
```
|
||||
|
||||
### Request Fixture
|
||||
|
||||
```typescript
|
||||
test("API call", async ({ request }) => {
|
||||
const response = await request.get("/api/users");
|
||||
await expect(response).toBeOK();
|
||||
|
||||
const users = await response.json();
|
||||
expect(users).toHaveLength(5);
|
||||
});
|
||||
```
|
||||
|
||||
## Custom Fixtures
|
||||
|
||||
### Basic Custom Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
// Declare fixture types
|
||||
type MyFixtures = {
|
||||
todoPage: TodoPage;
|
||||
apiClient: ApiClient;
|
||||
};
|
||||
|
||||
export const test = base.extend<MyFixtures>({
|
||||
// Fixture with setup and teardown
|
||||
todoPage: async ({ page }, use) => {
|
||||
const todoPage = new TodoPage(page);
|
||||
await todoPage.goto();
|
||||
|
||||
await use(todoPage); // Test runs here
|
||||
|
||||
// Teardown (optional)
|
||||
await todoPage.clearTodos();
|
||||
},
|
||||
|
||||
// Simple fixture
|
||||
apiClient: async ({ request }, use) => {
|
||||
await use(new ApiClient(request));
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
### Fixture with Options
|
||||
|
||||
```typescript
|
||||
type Options = {
|
||||
defaultUser: { email: string; password: string };
|
||||
};
|
||||
|
||||
type Fixtures = {
|
||||
authenticatedPage: Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<Options & Fixtures>({
|
||||
// Define option with default
|
||||
defaultUser: [
|
||||
{ email: "test@example.com", password: "pass123" },
|
||||
{ option: true },
|
||||
],
|
||||
|
||||
// Use option in fixture
|
||||
authenticatedPage: async ({ page, defaultUser }, use) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill(defaultUser.email);
|
||||
await page.getByLabel("Password").fill(defaultUser.password);
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
// Override in config
|
||||
export default defineConfig({
|
||||
use: {
|
||||
defaultUser: { email: "admin@example.com", password: "admin123" },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Automatic Fixtures
|
||||
|
||||
```typescript
|
||||
export const test = base.extend<{}, { setupDb: void }>({
|
||||
// Auto-fixture runs for every test without explicit usage
|
||||
setupDb: [
|
||||
async ({}, use) => {
|
||||
await seedDatabase();
|
||||
await use();
|
||||
await cleanDatabase();
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Fixture Scopes
|
||||
|
||||
### Test Scope (Default)
|
||||
|
||||
Created fresh for each test:
|
||||
|
||||
```typescript
|
||||
test.extend({
|
||||
page: async ({ browser }, use) => {
|
||||
const page = await browser.newPage();
|
||||
await use(page);
|
||||
await page.close();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Worker Scope
|
||||
|
||||
Shared across tests in the same worker (each worker gets its own instance; tests in different workers do not share it):
|
||||
|
||||
```typescript
|
||||
type WorkerFixtures = {
|
||||
sharedAccount: Account;
|
||||
};
|
||||
|
||||
export const test = base.extend<{}, WorkerFixtures>({
|
||||
sharedAccount: [
|
||||
async ({ browser }, use) => {
|
||||
// Expensive setup - runs once per worker
|
||||
const account = await createTestAccount();
|
||||
await use(account);
|
||||
await deleteTestAccount(account);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Isolate test data between parallel workers
|
||||
|
||||
When tests in different workers touch the same backend or DB (e.g. same user, same tenant), they can collide and cause flaky failures. Use `testInfo.workerIndex` (or `process.env.TEST_WORKER_INDEX`) in a worker-scoped fixture to create unique data per worker:
|
||||
|
||||
```typescript
|
||||
import { test as baseTest } from "@playwright/test";
|
||||
|
||||
type WorkerFixtures = {
|
||||
dbUserName: string;
|
||||
};
|
||||
|
||||
export const test = baseTest.extend<{}, WorkerFixtures>({
|
||||
dbUserName: [
|
||||
async ({}, use, testInfo) => {
|
||||
const userName = `user-${testInfo.workerIndex}`;
|
||||
await createUserInTestDatabase(userName);
|
||||
await use(userName);
|
||||
await deleteUserFromTestDatabase(userName);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Then each worker uses a distinct user (e.g. `user-1`, `user-2`), so parallel workers do not overwrite each other’s data.
|
||||
|
||||
## Hooks
|
||||
|
||||
### beforeEach / afterEach
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Runs before each test in file
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
// Runs after each test
|
||||
if (testInfo.status !== "passed") {
|
||||
await page.screenshot({ path: `failed-${testInfo.title}.png` });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### beforeAll / afterAll
|
||||
|
||||
```typescript
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// Runs once before all tests in file
|
||||
// Note: Cannot use page fixture here
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Runs once after all tests in file
|
||||
});
|
||||
```
|
||||
|
||||
### Describe-Level Hooks
|
||||
|
||||
```typescript
|
||||
test.describe("User Management", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/users");
|
||||
});
|
||||
|
||||
test("can list users", async ({ page }) => {
|
||||
// Starts at /users
|
||||
});
|
||||
|
||||
test("can add user", async ({ page }) => {
|
||||
// Starts at /users
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Authentication Patterns
|
||||
|
||||
### Global Setup with Storage State
|
||||
|
||||
```typescript
|
||||
// auth.setup.ts
|
||||
import { test as setup, expect } from "@playwright/test";
|
||||
|
||||
const authFile = ".auth/user.json";
|
||||
|
||||
setup("authenticate", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill(process.env.TEST_EMAIL!);
|
||||
await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!);
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{ name: "setup", testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
storageState: ".auth/user.json",
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Auth States
|
||||
|
||||
```typescript
|
||||
// auth.setup.ts
|
||||
setup("admin auth", async ({ page }) => {
|
||||
await login(page, "admin@example.com", "adminpass");
|
||||
await page.context().storageState({ path: ".auth/admin.json" });
|
||||
});
|
||||
|
||||
setup("user auth", async ({ page }) => {
|
||||
await login(page, "user@example.com", "userpass");
|
||||
await page.context().storageState({ path: ".auth/user.json" });
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
projects: [
|
||||
{
|
||||
name: "admin tests",
|
||||
testMatch: /.*admin.*\.spec\.ts/,
|
||||
use: { storageState: ".auth/admin.json" },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
{
|
||||
name: "user tests",
|
||||
testMatch: /.*user.*\.spec\.ts/,
|
||||
use: { storageState: ".auth/user.json" },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### Auth Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/auth.fixture.ts
|
||||
export const test = base.extend<{ adminPage: Page; userPage: Page }>({
|
||||
adminPage: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: ".auth/admin.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
|
||||
userPage: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: ".auth/user.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Database Fixtures
|
||||
|
||||
This section covers **per-test database fixtures** (isolation, transaction rollback). For related topics:
|
||||
|
||||
- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)
|
||||
- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)
|
||||
|
||||
### Transaction Rollback Pattern
|
||||
|
||||
```typescript
|
||||
import { test as base } from "@playwright/test";
|
||||
import { db } from "../db";
|
||||
|
||||
export const test = base.extend<{ dbTransaction: Transaction }>({
|
||||
dbTransaction: async ({}, use) => {
|
||||
const transaction = await db.beginTransaction();
|
||||
|
||||
await use(transaction);
|
||||
|
||||
await transaction.rollback(); // Clean slate for next test
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Seed Data Fixture
|
||||
|
||||
```typescript
|
||||
type TestData = {
|
||||
testUser: User;
|
||||
testProducts: Product[];
|
||||
};
|
||||
|
||||
export const test = base.extend<TestData>({
|
||||
testUser: async ({}, use) => {
|
||||
const user = await db.users.create({
|
||||
email: `test-${Date.now()}@example.com`,
|
||||
name: "Test User",
|
||||
});
|
||||
|
||||
await use(user);
|
||||
|
||||
await db.users.delete(user.id);
|
||||
},
|
||||
|
||||
testProducts: async ({ testUser }, use) => {
|
||||
const products = await db.products.createMany([
|
||||
{ name: "Product A", ownerId: testUser.id },
|
||||
{ name: "Product B", ownerId: testUser.id },
|
||||
]);
|
||||
|
||||
await use(products);
|
||||
|
||||
await db.products.deleteMany(products.map((p) => p.id));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Fixture Tips
|
||||
|
||||
| Tip | Explanation |
|
||||
| ------------------ | ------------------------------------------- |
|
||||
| Fixtures are lazy | Only created when used |
|
||||
| Compose fixtures | Use other fixtures as dependencies |
|
||||
| Keep setup minimal | Do heavy lifting in worker-scoped fixtures |
|
||||
| Clean up resources | Use teardown in fixtures, not afterEach |
|
||||
| Avoid shared state | Each fixture instance should be independent |
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ----------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Shared mutable state between tests | Race conditions, order dependencies | Use fixtures for isolation |
|
||||
| Global variables in tests | Tests depend on execution order | Use fixtures or beforeEach for setup |
|
||||
| Not cleaning up test data | Tests interfere with each other | Use fixtures with teardown or database transactions |
|
||||
| Shared `page` or `context` in `beforeAll` | State leak between tests; flaky when tests run in parallel | Use default one-context-per-test, or `beforeEach` + fresh page; if serial is required, prefer `test.describe.configure({ mode: 'serial' })` and document that isolation is sacrificed |
|
||||
| Backend/DB state shared across workers | Tests in different workers collide on same data | Use worker-scoped fixture with `testInfo.workerIndex` to create unique data per worker |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Page Objects with fixtures**: See [page-object-model.md](page-object-model.md) for POM patterns
|
||||
- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for test structure
|
||||
- **Debugging fixture issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
|
||||
434
.cursor/skills/playwright-testing/core/global-setup.md
Normal file
434
.cursor/skills/playwright-testing/core/global-setup.md
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
# Global Setup & Teardown
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Global Setup](#global-setup)
|
||||
2. [Global Teardown](#global-teardown)
|
||||
3. [Database Patterns](#database-patterns)
|
||||
4. [Environment Provisioning](#environment-provisioning)
|
||||
5. [Setup Projects vs Global Setup](#setup-projects-vs-global-setup)
|
||||
6. [Parallel Execution Caveats](#parallel-execution-caveats)
|
||||
|
||||
## Global Setup
|
||||
|
||||
### Basic Global Setup
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { FullConfig } from "@playwright/test";
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
console.log("Running global setup...");
|
||||
// Perform one-time setup: start services, run migrations, etc.
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
### Configure Global Setup
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
globalSetup: require.resolve("./global-setup"),
|
||||
globalTeardown: require.resolve("./global-teardown"),
|
||||
});
|
||||
```
|
||||
|
||||
> **Authentication in Global Setup**: For authentication patterns using storage state in global setup, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). Setup projects are generally preferred for authentication as they provide access to Playwright fixtures.
|
||||
|
||||
### Global Setup with Return Value
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
async function globalSetup(config: FullConfig): Promise<() => Promise<void>> {
|
||||
const server = await startTestServer();
|
||||
|
||||
// Return cleanup function (alternative to globalTeardown)
|
||||
return async () => {
|
||||
await server.stop();
|
||||
};
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
### Access Config in Global Setup
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { FullConfig } from "@playwright/test";
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const { baseURL } = config.projects[0].use;
|
||||
console.log(`Setting up for ${baseURL}`);
|
||||
|
||||
// Access custom config
|
||||
const workers = config.workers;
|
||||
const timeout = config.timeout;
|
||||
|
||||
// Access environment
|
||||
const isCI = !!process.env.CI;
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
## Global Teardown
|
||||
|
||||
### Basic Global Teardown
|
||||
|
||||
```typescript
|
||||
// global-teardown.ts
|
||||
import { FullConfig } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
|
||||
async function globalTeardown(config: FullConfig) {
|
||||
console.log("Running global teardown...");
|
||||
|
||||
// Clean up auth files
|
||||
if (fs.existsSync(".auth")) {
|
||||
fs.rmSync(".auth", { recursive: true });
|
||||
}
|
||||
|
||||
// Clean up test data
|
||||
await cleanupTestDatabase();
|
||||
|
||||
// Stop services
|
||||
await stopTestServices();
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
```
|
||||
|
||||
### Conditional Teardown
|
||||
|
||||
```typescript
|
||||
// global-teardown.ts
|
||||
async function globalTeardown(config: FullConfig) {
|
||||
// Skip cleanup in CI (containers are discarded anyway)
|
||||
if (process.env.CI) {
|
||||
console.log("Skipping teardown in CI");
|
||||
return;
|
||||
}
|
||||
|
||||
// Local cleanup
|
||||
await cleanupLocalTestData();
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
```
|
||||
|
||||
## Database Patterns
|
||||
|
||||
This section covers **one-time database setup** (migrations, snapshots, per-worker databases). For related topics:
|
||||
|
||||
- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)
|
||||
- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)
|
||||
|
||||
### Database Migration in Setup
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { execSync } from "child_process";
|
||||
|
||||
async function globalSetup() {
|
||||
console.log("Running database migrations...");
|
||||
|
||||
// Run migrations
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
|
||||
// Seed test data
|
||||
execSync("npx prisma db seed", { stdio: "inherit" });
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
### Database Snapshot Pattern
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { execSync } from "child_process";
|
||||
import fs from "fs";
|
||||
|
||||
const SNAPSHOT_PATH = "./test-db-snapshot.sql";
|
||||
|
||||
async function globalSetup() {
|
||||
// Check if snapshot exists
|
||||
if (fs.existsSync(SNAPSHOT_PATH)) {
|
||||
console.log("Restoring database from snapshot...");
|
||||
execSync(`psql $DATABASE_URL < ${SNAPSHOT_PATH}`, { stdio: "inherit" });
|
||||
return;
|
||||
}
|
||||
|
||||
// First run: migrate and create snapshot
|
||||
console.log("Creating database snapshot...");
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
execSync("npx prisma db seed", { stdio: "inherit" });
|
||||
execSync(`pg_dump $DATABASE_URL > ${SNAPSHOT_PATH}`, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
### Test Database per Worker
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const workerCount = config.workers || 1;
|
||||
|
||||
// Create a database for each worker
|
||||
for (let i = 0; i < workerCount; i++) {
|
||||
const dbName = `test_db_worker_${i}`;
|
||||
await createDatabase(dbName);
|
||||
await runMigrations(dbName);
|
||||
await seedDatabase(dbName);
|
||||
}
|
||||
}
|
||||
|
||||
// global-teardown.ts
|
||||
async function globalTeardown(config: FullConfig) {
|
||||
const workerCount = config.workers || 1;
|
||||
|
||||
for (let i = 0; i < workerCount; i++) {
|
||||
await dropDatabase(`test_db_worker_${i}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Provisioning
|
||||
|
||||
### Start Services in Setup
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { execSync, spawn } from "child_process";
|
||||
|
||||
let serverProcess: any;
|
||||
|
||||
async function globalSetup() {
|
||||
// Start backend server
|
||||
serverProcess = spawn("npm", ["run", "start:test"], {
|
||||
stdio: "pipe",
|
||||
detached: true,
|
||||
});
|
||||
|
||||
// Wait for server to be ready
|
||||
await waitForServer("http://localhost:3000/health", 30000);
|
||||
|
||||
// Store PID for teardown
|
||||
process.env.SERVER_PID = serverProcess.pid.toString();
|
||||
}
|
||||
|
||||
async function waitForServer(url: string, timeout: number) {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Server not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
throw new Error(`Server did not start within ${timeout}ms`);
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { execSync } from "child_process";
|
||||
|
||||
async function globalSetup() {
|
||||
console.log("Starting Docker services...");
|
||||
|
||||
execSync("docker-compose -f docker-compose.test.yml up -d", {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
// Wait for services to be healthy
|
||||
execSync("docker-compose -f docker-compose.test.yml exec -T db pg_isready", {
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// global-teardown.ts
|
||||
import { execSync } from "child_process";
|
||||
|
||||
async function globalTeardown() {
|
||||
console.log("Stopping Docker services...");
|
||||
|
||||
execSync("docker-compose -f docker-compose.test.yml down -v", {
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
```
|
||||
|
||||
### Environment Variables Setup
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
|
||||
async function globalSetup() {
|
||||
// Load test-specific environment
|
||||
const envFile = process.env.CI ? ".env.ci" : ".env.test";
|
||||
dotenv.config({ path: path.resolve(process.cwd(), envFile) });
|
||||
|
||||
// Validate required variables
|
||||
const required = ["DATABASE_URL", "API_KEY", "TEST_EMAIL"];
|
||||
for (const key of required) {
|
||||
if (!process.env[key]) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
## Setup Projects vs Global Setup
|
||||
|
||||
### When to Use Each
|
||||
|
||||
| Use Global Setup | Use Setup Projects |
|
||||
| ------------------------------------- | ---------------------------------------- |
|
||||
| One-time setup (migrations, services) | Per-project setup (auth states) |
|
||||
| No access to Playwright fixtures | Need page, request fixtures |
|
||||
| Runs once before all projects | Can run per-project or have dependencies |
|
||||
| Shared across all workers | Can be parallelized |
|
||||
|
||||
### Setup Project Pattern
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
// Setup project
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
},
|
||||
// Test projects depend on setup
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
> **For complete authentication setup patterns**, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).
|
||||
|
||||
### Combining Both
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
// Global: Start services, run migrations
|
||||
globalSetup: require.resolve("./global-setup"),
|
||||
globalTeardown: require.resolve("./global-teardown"),
|
||||
|
||||
projects: [
|
||||
// Setup project: Create auth states
|
||||
{ name: "setup", testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
storageState: ".auth/user.json",
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Parallel Execution Caveats
|
||||
|
||||
### Understanding Global Setup Execution
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ globalSetup runs ONCE │
|
||||
│ ↓ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Worker 1│ │ Worker 2│ │ Worker 3│ │ Worker 4│ │
|
||||
│ │ tests │ │ tests │ │ tests │ │ tests │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ ↓ │
|
||||
│ globalTeardown runs ONCE │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key implications:**
|
||||
|
||||
- Global setup has **no access** to Playwright fixtures (`page`, `request`, `context`)
|
||||
- State created in global setup is **shared** across all workers
|
||||
- If tests **modify** shared state, they may conflict with parallel workers
|
||||
- Global setup **cannot** react to individual test needs
|
||||
|
||||
### When to Prefer Worker-Scoped Fixtures
|
||||
|
||||
Use **worker-scoped fixtures** instead of globalSetup when:
|
||||
|
||||
| Scenario | Why Fixtures Are Better |
|
||||
| ------------------------------------ | ---------------------------------------------------- |
|
||||
| Each worker needs isolated resources | Fixtures can create per-worker databases, servers |
|
||||
| Setup needs Playwright APIs | Fixtures have access to `page`, `request`, `browser` |
|
||||
| Setup depends on test configuration | Fixtures receive test context and options |
|
||||
| Resources need cleanup per worker | Worker fixtures auto-cleanup when worker exits |
|
||||
|
||||
### Common Parallel Pitfall
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Global setup creates ONE user, all workers fight over it
|
||||
async function globalSetup() {
|
||||
await createUser({ email: "test@example.com" }); // Shared!
|
||||
}
|
||||
|
||||
// ✅ GOOD: Each worker gets its own user via worker-scoped fixture
|
||||
// Uses workerInfo.workerIndex to create unique data per worker
|
||||
```
|
||||
|
||||
> **For worker-scoped fixture patterns** (per-worker databases, unique test data, `workerIndex` isolation), see [fixtures-hooks.md](fixtures-hooks.md#isolate-test-data-between-parallel-workers).
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------ | -------------------------------- | ------------------------------------------ |
|
||||
| Heavy setup in globalSetup | Slow test startup | Use setup projects for parallelizable work |
|
||||
| Not cleaning up in teardown | Leaks resources, flaky CI | Always clean up or use containers |
|
||||
| Hardcoded URLs in setup | Breaks in different environments | Use config.projects[0].use.baseURL |
|
||||
| No timeout on service wait | Hangs forever if service fails | Add timeout with clear error |
|
||||
| Shared mutable state | Race conditions in parallel | Use worker-scoped fixtures for isolation |
|
||||
| Global setup for per-test data | Tests conflict | Use test-scoped fixtures |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Fixtures & Auth**: See [fixtures-hooks.md](fixtures-hooks.md) for worker-scoped fixtures and auth patterns
|
||||
- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI setup patterns
|
||||
- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project configuration
|
||||
242
.cursor/skills/playwright-testing/core/locators.md
Normal file
242
.cursor/skills/playwright-testing/core/locators.md
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# Locator Strategies
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Priority Order](#priority-order)
|
||||
2. [User-Facing Locators](#user-facing-locators)
|
||||
3. [Filtering & Chaining](#filtering--chaining)
|
||||
4. [Dynamic Content](#dynamic-content)
|
||||
5. [Shadow DOM](#shadow-dom)
|
||||
6. [Iframes](#iframes)
|
||||
|
||||
## Priority Order
|
||||
|
||||
Use locators in this order of preference:
|
||||
|
||||
1. **Role-based** (most resilient): `getByRole`
|
||||
2. **Label-based**: `getByLabel`, `getByPlaceholder`
|
||||
3. **Text-based**: `getByText`, `getByTitle`
|
||||
4. **Test IDs** (when semantic locators aren't possible): `getByTestId`
|
||||
5. **CSS/XPath** (last resort): `locator('css=...')`, `locator('xpath=...')`
|
||||
|
||||
## User-Facing Locators
|
||||
|
||||
### getByRole
|
||||
|
||||
Most robust approach - matches how users and assistive technology perceive the page.
|
||||
|
||||
```typescript
|
||||
// Buttons
|
||||
page.getByRole("button", { name: "Submit", exact: true }); // exact accessible name
|
||||
page.getByRole("button", { name: /submit/i }); // flexible case-insensitive match
|
||||
|
||||
// Links
|
||||
page.getByRole("link", { name: "Home" });
|
||||
|
||||
// Form elements
|
||||
page.getByRole("textbox", { name: "Email" });
|
||||
page.getByRole("checkbox", { name: "Remember me" });
|
||||
page.getByRole("combobox", { name: "Country" });
|
||||
page.getByRole("radio", { name: "Option A" });
|
||||
|
||||
// Headings
|
||||
page.getByRole("heading", { name: "Welcome", level: 1 });
|
||||
|
||||
// Lists & items
|
||||
page.getByRole("list").getByRole("listitem");
|
||||
|
||||
// Navigation & regions
|
||||
page.getByRole("navigation");
|
||||
page.getByRole("main");
|
||||
page.getByRole("dialog");
|
||||
page.getByRole("alert");
|
||||
```
|
||||
|
||||
### getByLabel
|
||||
|
||||
For form elements with associated labels.
|
||||
|
||||
```typescript
|
||||
// Input with <label for="email">
|
||||
page.getByLabel("Email address");
|
||||
|
||||
// Input with aria-label
|
||||
page.getByLabel("Search");
|
||||
|
||||
// Exact match
|
||||
page.getByLabel("Email", { exact: true });
|
||||
```
|
||||
|
||||
### getByPlaceholder
|
||||
|
||||
```typescript
|
||||
page.getByPlaceholder("Enter your email");
|
||||
page.getByPlaceholder(/email/i);
|
||||
```
|
||||
|
||||
### getByText
|
||||
|
||||
```typescript
|
||||
// Partial match (default)
|
||||
page.getByText("Welcome");
|
||||
|
||||
// Exact match
|
||||
page.getByText("Welcome to our site", { exact: true });
|
||||
|
||||
// Regex
|
||||
page.getByText(/welcome/i);
|
||||
```
|
||||
|
||||
### getByTestId
|
||||
|
||||
Configure custom test ID attribute in `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
use: {
|
||||
testIdAttribute: "data-testid"; // default
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```typescript
|
||||
// HTML: <button data-testid="submit-btn">Submit</button>
|
||||
page.getByTestId("submit-btn");
|
||||
```
|
||||
|
||||
## Filtering & Chaining
|
||||
|
||||
### filter()
|
||||
|
||||
Narrow down locators:
|
||||
|
||||
```typescript
|
||||
// Filter by text
|
||||
page.getByRole("listitem").filter({ hasText: "Product" });
|
||||
|
||||
// Filter by NOT having text
|
||||
page.getByRole("listitem").filter({ hasNotText: "Out of stock" });
|
||||
|
||||
// Filter by child locator
|
||||
page.getByRole("listitem").filter({
|
||||
has: page.getByRole("button", { name: "Buy" }),
|
||||
});
|
||||
|
||||
// Combine filters
|
||||
page
|
||||
.getByRole("listitem")
|
||||
.filter({ hasText: "Product" })
|
||||
.filter({ has: page.getByText("$9.99") });
|
||||
```
|
||||
|
||||
### Chaining
|
||||
|
||||
```typescript
|
||||
// Navigate down the DOM tree
|
||||
page.getByRole("article").getByRole("heading");
|
||||
|
||||
// Get parent/ancestor
|
||||
page.getByText("Child").locator("..");
|
||||
page.getByText("Child").locator("xpath=ancestor::article");
|
||||
```
|
||||
|
||||
### nth() and first()/last()
|
||||
|
||||
```typescript
|
||||
page.getByRole("listitem").first();
|
||||
page.getByRole("listitem").last();
|
||||
page.getByRole("listitem").nth(2); // 0-indexed
|
||||
```
|
||||
|
||||
## Dynamic Content
|
||||
|
||||
### Waiting for Elements
|
||||
|
||||
Locators auto-wait for actionability by default. For explicit state waiting:
|
||||
|
||||
```typescript
|
||||
await page.getByRole("button").waitFor({ state: "visible" });
|
||||
await page.getByText("Loading").waitFor({ state: "hidden" });
|
||||
```
|
||||
|
||||
> **For comprehensive waiting strategies** (element state, navigation, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies).
|
||||
|
||||
### Lists with Dynamic Items
|
||||
|
||||
```typescript
|
||||
// Wait for specific count
|
||||
await expect(page.getByRole("listitem")).toHaveCount(5);
|
||||
|
||||
// Get all matching elements
|
||||
const items = await page.getByRole("listitem").all();
|
||||
for (const item of items) {
|
||||
await expect(item).toBeVisible();
|
||||
}
|
||||
```
|
||||
|
||||
## Shadow DOM
|
||||
|
||||
Playwright pierces shadow DOM by default:
|
||||
|
||||
```typescript
|
||||
// Automatically finds elements inside shadow roots
|
||||
page.getByRole("button", { name: "Shadow Button" });
|
||||
|
||||
// Explicit shadow DOM traversal (if needed)
|
||||
page.locator("my-component").locator("internal:shadow=button");
|
||||
```
|
||||
|
||||
## Iframes
|
||||
|
||||
```typescript
|
||||
// By frame name or URL
|
||||
const frame = page.frameLocator('iframe[name="content"]');
|
||||
await frame.getByRole("button").click();
|
||||
|
||||
// By index
|
||||
const frame = page.frameLocator("iframe").first();
|
||||
|
||||
// Nested iframes
|
||||
const nestedFrame = page.frameLocator("#outer").frameLocator("#inner");
|
||||
await nestedFrame.getByText("Content").click();
|
||||
```
|
||||
|
||||
## Debugging Locators
|
||||
|
||||
```typescript
|
||||
// Highlight element in headed mode
|
||||
await page.getByRole("button").highlight();
|
||||
|
||||
// Count matches
|
||||
const count = await page.getByRole("listitem").count();
|
||||
|
||||
// Check if exists without waiting
|
||||
const exists = (await page.getByRole("button").count()) > 0;
|
||||
|
||||
// Use Playwright Inspector
|
||||
// PWDEBUG=1 npx playwright test
|
||||
```
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
| Issue | Solution |
|
||||
| ----------------------- | ------------------------------------------------ |
|
||||
| Multiple elements match | Add filters or use `nth()`, `first()`, `last()` |
|
||||
| Element not found | Check visibility, wait for load, verify selector |
|
||||
| Stale element | Locators are lazy; re-query if DOM changes |
|
||||
| Dynamic IDs | Use stable attributes like role, text, test-id |
|
||||
| Hidden elements | Use `{ force: true }` only when necessary |
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| --------------------------------- | --------------------------------- | ------------------------------------------------- |
|
||||
| `page.locator('.btn-primary')` | Brittle, implementation-dependent | `page.getByRole('button', { name: 'Submit' })` |
|
||||
| `page.locator('#dynamic-id-123')` | Breaks when IDs change | Use stable attributes like role, text, or test-id |
|
||||
| Testing implementation details | Breaks on refactoring | Test user-visible behavior |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Debugging selector issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
|
||||
- **Waiting for elements**: See [assertions-waiting.md](assertions-waiting.md) for waiting strategies
|
||||
- **Using in Page Objects**: See [page-object-model.md](page-object-model.md) for organizing locators
|
||||
315
.cursor/skills/playwright-testing/core/page-object-model.md
Normal file
315
.cursor/skills/playwright-testing/core/page-object-model.md
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
# Page Object Model (POM)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Basic Structure](#basic-structure)
|
||||
3. [Component Objects](#component-objects)
|
||||
4. [Composition Patterns](#composition-patterns)
|
||||
5. [Factory Functions](#factory-functions)
|
||||
6. [Best Practices](#best-practices)
|
||||
|
||||
## Overview
|
||||
|
||||
Page Object Model encapsulates page structure and interactions, providing:
|
||||
|
||||
- **Maintainability**: Change selectors in one place
|
||||
- **Reusability**: Share page interactions across tests
|
||||
- **Readability**: Tests express intent, not implementation
|
||||
|
||||
## Basic Structure
|
||||
|
||||
### Page Class
|
||||
|
||||
```typescript
|
||||
// pages/login.page.ts
|
||||
import { Page, Locator, expect } 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Tests
|
||||
|
||||
```typescript
|
||||
// tests/login.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { LoginPage } from "../pages/login.page";
|
||||
|
||||
test.describe("Login", () => {
|
||||
test("successful login redirects to dashboard", async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login("user@example.com", "password123");
|
||||
await expect(page).toHaveURL("/dashboard");
|
||||
});
|
||||
|
||||
test("shows error for invalid credentials", async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login("invalid@example.com", "wrong");
|
||||
await loginPage.expectError("Invalid credentials");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Component Objects
|
||||
|
||||
For reusable UI components:
|
||||
|
||||
```typescript
|
||||
// components/navbar.component.ts
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
export class NavbarComponent {
|
||||
readonly container: Locator;
|
||||
readonly logo: Locator;
|
||||
readonly searchInput: Locator;
|
||||
readonly userMenu: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.container = page.getByRole("navigation");
|
||||
this.logo = this.container.getByRole("link", { name: "Home" });
|
||||
this.searchInput = this.container.getByRole("searchbox");
|
||||
this.userMenu = this.container.getByRole("button", { name: /user menu/i });
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
await this.searchInput.fill(query);
|
||||
await this.searchInput.press("Enter");
|
||||
}
|
||||
|
||||
async openUserMenu() {
|
||||
await this.userMenu.click();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// components/modal.component.ts
|
||||
import { Locator, expect } from "@playwright/test";
|
||||
|
||||
export class ModalComponent {
|
||||
readonly container: Locator;
|
||||
readonly title: Locator;
|
||||
readonly closeButton: Locator;
|
||||
readonly confirmButton: Locator;
|
||||
|
||||
constructor(container: Locator) {
|
||||
this.container = container;
|
||||
this.title = container.getByRole("heading");
|
||||
this.closeButton = container.getByRole("button", { name: "Close" });
|
||||
this.confirmButton = container.getByRole("button", { name: "Confirm" });
|
||||
}
|
||||
|
||||
async expectTitle(title: string) {
|
||||
await expect(this.title).toHaveText(title);
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.closeButton.click();
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
await this.confirmButton.click();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Composition Patterns
|
||||
|
||||
### Page with Components
|
||||
|
||||
```typescript
|
||||
// pages/dashboard.page.ts
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { NavbarComponent } from "../components/navbar.component";
|
||||
import { ModalComponent } from "../components/modal.component";
|
||||
|
||||
export class DashboardPage {
|
||||
readonly page: Page;
|
||||
readonly navbar: NavbarComponent;
|
||||
readonly newProjectButton: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.navbar = new NavbarComponent(page);
|
||||
this.newProjectButton = page.getByRole("button", { name: "New Project" });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/dashboard");
|
||||
}
|
||||
|
||||
async createProject() {
|
||||
await this.newProjectButton.click();
|
||||
return new ModalComponent(this.page.getByRole("dialog"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Page Navigation
|
||||
|
||||
```typescript
|
||||
// pages/base.page.ts
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export abstract class BasePage {
|
||||
constructor(readonly page: Page) {}
|
||||
|
||||
abstract goto(): Promise<void>;
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
return this.page.title();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Return new page object on navigation
|
||||
export class LoginPage extends BasePage {
|
||||
async login(email: string, password: string): Promise<DashboardPage> {
|
||||
await this.emailInput.fill(email);
|
||||
await this.passwordInput.fill(password);
|
||||
await this.submitButton.click();
|
||||
return new DashboardPage(this.page);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
const dashboardPage = await loginPage.login("user@example.com", "pass");
|
||||
await dashboardPage.expectWelcomeMessage();
|
||||
```
|
||||
|
||||
## Factory Functions
|
||||
|
||||
Alternative to classes for simpler pages:
|
||||
|
||||
```typescript
|
||||
// pages/login.page.ts
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export function createLoginPage(page: Page) {
|
||||
const emailInput = page.getByLabel("Email");
|
||||
const passwordInput = page.getByLabel("Password");
|
||||
const submitButton = page.getByRole("button", { name: "Sign in" });
|
||||
|
||||
return {
|
||||
goto: () => page.goto("/login"),
|
||||
login: async (email: string, password: string) => {
|
||||
await emailInput.fill(email);
|
||||
await passwordInput.fill(password);
|
||||
await submitButton.click();
|
||||
},
|
||||
emailInput,
|
||||
passwordInput,
|
||||
submitButton,
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const loginPage = createLoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login("user@example.com", "password");
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do
|
||||
|
||||
- **Keep locators in page objects** - Single source of truth
|
||||
- **Return new page objects** when navigation occurs
|
||||
- **Expose elements** for custom assertions in tests
|
||||
- **Use descriptive method names** - `submitOrder()` not `clickButton()`
|
||||
- **Keep methods focused** - One action per method
|
||||
|
||||
### Don't
|
||||
|
||||
- **Don't include assertions in page methods** (usually) - Keep in tests
|
||||
- **Don't expose implementation details** - Hide complex interactions
|
||||
- **Don't make page objects too large** - Split into components
|
||||
- **Don't share state** between page object instances
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── pages/
|
||||
│ ├── base.page.ts
|
||||
│ ├── login.page.ts
|
||||
│ ├── dashboard.page.ts
|
||||
│ └── settings.page.ts
|
||||
├── components/
|
||||
│ ├── navbar.component.ts
|
||||
│ ├── modal.component.ts
|
||||
│ └── table.component.ts
|
||||
├── fixtures/
|
||||
│ └── pages.fixture.ts
|
||||
└── specs/
|
||||
├── login.spec.ts
|
||||
└── dashboard.spec.ts
|
||||
```
|
||||
|
||||
### Using with Fixtures
|
||||
|
||||
```typescript
|
||||
// fixtures/pages.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
import { LoginPage } from "../pages/login.page";
|
||||
import { DashboardPage } from "../pages/dashboard.page";
|
||||
|
||||
type Pages = {
|
||||
loginPage: LoginPage;
|
||||
dashboardPage: DashboardPage;
|
||||
};
|
||||
|
||||
export const test = base.extend<Pages>({
|
||||
loginPage: async ({ page }, use) => {
|
||||
await use(new LoginPage(page));
|
||||
},
|
||||
dashboardPage: async ({ page }, use) => {
|
||||
await use(new DashboardPage(page));
|
||||
},
|
||||
});
|
||||
|
||||
// Usage in tests
|
||||
test("can login", async ({ loginPage }) => {
|
||||
await loginPage.goto();
|
||||
await loginPage.login("user@example.com", "password");
|
||||
});
|
||||
```
|
||||
|
||||
## Related References
|
||||
|
||||
- **Locator strategies**: See [locators.md](locators.md) for selecting elements
|
||||
- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for advanced fixture patterns
|
||||
- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring test suites
|
||||
453
.cursor/skills/playwright-testing/core/projects-dependencies.md
Normal file
453
.cursor/skills/playwright-testing/core/projects-dependencies.md
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
# Projects & Dependencies
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Project Configuration](#project-configuration)
|
||||
2. [Project Dependencies](#project-dependencies)
|
||||
3. [Setup Projects](#setup-projects)
|
||||
4. [Filtering & Running Projects](#filtering--running-projects)
|
||||
5. [Sharing Configuration](#sharing-configuration)
|
||||
6. [Advanced Patterns](#advanced-patterns)
|
||||
|
||||
## Project Configuration
|
||||
|
||||
### Basic Multi-Browser Setup
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Environment-Based Projects
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "staging",
|
||||
use: {
|
||||
baseURL: "https://staging.example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "production",
|
||||
use: {
|
||||
baseURL: "https://example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "local",
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Test Type Projects
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "e2e",
|
||||
testDir: "./tests/e2e",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "api",
|
||||
testDir: "./tests/api",
|
||||
use: { baseURL: "http://localhost:3000" },
|
||||
},
|
||||
{
|
||||
name: "visual",
|
||||
testDir: "./tests/visual",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Project Dependencies
|
||||
|
||||
### Setup Dependency
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
// Setup project runs first
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
},
|
||||
|
||||
// Browser projects depend on setup
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
storageState: ".auth/user.json",
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
storageState: ".auth/user.json",
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Auth States
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
// Auth setup projects
|
||||
{
|
||||
name: "setup-admin",
|
||||
testMatch: /admin\.setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: "setup-user",
|
||||
testMatch: /user\.setup\.ts/,
|
||||
},
|
||||
|
||||
// Admin tests
|
||||
{
|
||||
name: "admin-tests",
|
||||
testDir: "./tests/admin",
|
||||
use: { storageState: ".auth/admin.json" },
|
||||
dependencies: ["setup-admin"],
|
||||
},
|
||||
|
||||
// User tests
|
||||
{
|
||||
name: "user-tests",
|
||||
testDir: "./tests/user",
|
||||
use: { storageState: ".auth/user.json" },
|
||||
dependencies: ["setup-user"],
|
||||
},
|
||||
|
||||
// Tests that need both
|
||||
{
|
||||
name: "integration-tests",
|
||||
testDir: "./tests/integration",
|
||||
dependencies: ["setup-admin", "setup-user"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Chained Dependencies
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
// Step 1: Database setup
|
||||
{
|
||||
name: "db-setup",
|
||||
testMatch: /db\.setup\.ts/,
|
||||
},
|
||||
|
||||
// Step 2: Auth setup (needs DB)
|
||||
{
|
||||
name: "auth-setup",
|
||||
testMatch: /auth\.setup\.ts/,
|
||||
dependencies: ["db-setup"],
|
||||
},
|
||||
|
||||
// Step 3: Seed data (needs auth)
|
||||
{
|
||||
name: "seed-setup",
|
||||
testMatch: /seed\.setup\.ts/,
|
||||
dependencies: ["auth-setup"],
|
||||
},
|
||||
|
||||
// Tests (need everything)
|
||||
{
|
||||
name: "tests",
|
||||
testDir: "./tests",
|
||||
dependencies: ["seed-setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Setup Projects
|
||||
|
||||
### Authentication Setup
|
||||
|
||||
Setup projects are the recommended way to handle authentication. They run before your main test projects and can use Playwright fixtures.
|
||||
|
||||
> **For complete authentication patterns** (storage state, multiple auth states, auth fixtures), see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).
|
||||
|
||||
### Data Seeding Setup
|
||||
|
||||
```typescript
|
||||
// seed.setup.ts
|
||||
import { test as setup } from "@playwright/test";
|
||||
|
||||
setup("seed test data", async ({ request }) => {
|
||||
// Create test data via API
|
||||
await request.post("/api/test/seed", {
|
||||
data: {
|
||||
users: 10,
|
||||
products: 50,
|
||||
orders: 100,
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Cleanup Setup
|
||||
|
||||
```typescript
|
||||
// cleanup.setup.ts
|
||||
import { test as setup } from "@playwright/test";
|
||||
|
||||
setup("cleanup previous run", async ({ request }) => {
|
||||
// Clean up data from previous test runs
|
||||
await request.delete("/api/test/cleanup");
|
||||
});
|
||||
```
|
||||
|
||||
## Filtering & Running Projects
|
||||
|
||||
### Run Specific Project
|
||||
|
||||
```bash
|
||||
# Run single project
|
||||
npx playwright test --project=chromium
|
||||
|
||||
# Run multiple projects
|
||||
npx playwright test --project=chromium --project=firefox
|
||||
```
|
||||
|
||||
### Run by Grep
|
||||
|
||||
```bash
|
||||
# Run tests matching pattern
|
||||
npx playwright test --grep @smoke
|
||||
|
||||
# Run project with grep
|
||||
npx playwright test --project=chromium --grep @critical
|
||||
|
||||
# Exclude pattern
|
||||
npx playwright test --grep-invert @slow
|
||||
```
|
||||
|
||||
### Project-Specific Grep
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "smoke",
|
||||
grep: /@smoke/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "regression",
|
||||
grepInvert: /@smoke/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Sharing Configuration
|
||||
|
||||
### Base Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
const baseConfig = {
|
||||
timeout: 30000,
|
||||
expect: { timeout: 5000 },
|
||||
use: {
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
...baseConfig,
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...baseConfig.use,
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
...baseConfig.use,
|
||||
...devices["Desktop Firefox"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Shared Project Settings
|
||||
|
||||
```typescript
|
||||
const sharedBrowserConfig = {
|
||||
timeout: 60000,
|
||||
retries: 2,
|
||||
use: {
|
||||
video: "on-first-retry",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
...sharedBrowserConfig,
|
||||
use: {
|
||||
...sharedBrowserConfig.use,
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
...sharedBrowserConfig,
|
||||
use: {
|
||||
...sharedBrowserConfig.use,
|
||||
...devices["Desktop Firefox"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Conditional Projects
|
||||
|
||||
```typescript
|
||||
const projects = [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
];
|
||||
|
||||
// Add Firefox only in CI
|
||||
if (process.env.CI) {
|
||||
projects.push({
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
});
|
||||
}
|
||||
|
||||
// Add mobile only for specific test dirs
|
||||
if (process.env.TEST_MOBILE) {
|
||||
projects.push({
|
||||
name: "mobile",
|
||||
use: { ...devices["iPhone 14"] },
|
||||
});
|
||||
}
|
||||
|
||||
export default defineConfig({ projects });
|
||||
```
|
||||
|
||||
### Project Metadata
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
metadata: {
|
||||
platform: "desktop",
|
||||
browser: "chromium",
|
||||
priority: "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Access in test
|
||||
test("example", async ({ page }, testInfo) => {
|
||||
const { platform, priority } = testInfo.project.metadata;
|
||||
console.log(`Running on ${platform} with ${priority} priority`);
|
||||
});
|
||||
```
|
||||
|
||||
### Teardown Projects
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
teardown: "teardown", // Run teardown after this completes
|
||||
},
|
||||
{
|
||||
name: "teardown",
|
||||
testMatch: /.*\.teardown\.ts/,
|
||||
},
|
||||
{
|
||||
name: "tests",
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// cleanup.teardown.ts
|
||||
import { test as teardown } from "@playwright/test";
|
||||
|
||||
teardown("cleanup", async ({ request }) => {
|
||||
await request.delete("/api/test/data");
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| -------------------------- | ---------------------- | ----------------------------------- |
|
||||
| Too many browser projects | Slow CI, expensive | Focus on critical browsers |
|
||||
| Missing setup dependencies | Tests fail randomly | Declare all dependencies explicitly |
|
||||
| Duplicated configuration | Hard to maintain | Extract shared config |
|
||||
| Not using setup projects | Repeated auth in tests | Use setup project + storageState |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Global Setup**: See [global-setup.md](global-setup.md) for globalSetup vs setup projects
|
||||
- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for authentication patterns
|
||||
- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for running projects in CI
|
||||
492
.cursor/skills/playwright-testing/core/test-data.md
Normal file
492
.cursor/skills/playwright-testing/core/test-data.md
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
# Test Data Factories & Generators
|
||||
|
||||
This file covers **reusable test data builders** (factories, Faker, data generators). For related topics:
|
||||
|
||||
- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)
|
||||
- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Factory Pattern](#factory-pattern)
|
||||
2. [Faker Integration](#faker-integration)
|
||||
3. [Data-Driven Testing](#data-driven-testing)
|
||||
4. [Test Data Fixtures](#test-data-fixtures)
|
||||
5. [Database Seeding](#database-seeding)
|
||||
|
||||
## Factory Pattern
|
||||
|
||||
### Basic Factory
|
||||
|
||||
```typescript
|
||||
// factories/user.factory.ts
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "admin" | "user" | "guest";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
let userIdCounter = 0;
|
||||
|
||||
export function createUser(overrides: Partial<User> = {}): User {
|
||||
userIdCounter++;
|
||||
return {
|
||||
id: `user-${userIdCounter}`,
|
||||
email: `user${userIdCounter}@test.com`,
|
||||
name: `Test User ${userIdCounter}`,
|
||||
role: "user",
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const user = createUser();
|
||||
const admin = createUser({ role: "admin", name: "Admin User" });
|
||||
```
|
||||
|
||||
### Factory with Traits
|
||||
|
||||
```typescript
|
||||
// factories/product.factory.ts
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
category: string;
|
||||
featured: boolean;
|
||||
}
|
||||
|
||||
type ProductTrait = "outOfStock" | "featured" | "expensive" | "sale";
|
||||
|
||||
const traits: Record<ProductTrait, Partial<Product>> = {
|
||||
outOfStock: { stock: 0 },
|
||||
featured: { featured: true },
|
||||
expensive: { price: 999.99 },
|
||||
sale: { price: 9.99 },
|
||||
};
|
||||
|
||||
let productIdCounter = 0;
|
||||
|
||||
export function createProduct(
|
||||
overrides: Partial<Product> = {},
|
||||
...traitNames: ProductTrait[]
|
||||
): Product {
|
||||
productIdCounter++;
|
||||
|
||||
const appliedTraits = traitNames.reduce(
|
||||
(acc, trait) => ({ ...acc, ...traits[trait] }),
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
id: `prod-${productIdCounter}`,
|
||||
name: `Product ${productIdCounter}`,
|
||||
price: 29.99,
|
||||
stock: 100,
|
||||
category: "General",
|
||||
featured: false,
|
||||
...appliedTraits,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const product = createProduct();
|
||||
const featuredProduct = createProduct({}, "featured");
|
||||
const saleItem = createProduct({ name: "Sale Item" }, "sale", "featured");
|
||||
const soldOut = createProduct({}, "outOfStock");
|
||||
```
|
||||
|
||||
### Factory with Relationships
|
||||
|
||||
```typescript
|
||||
// factories/order.factory.ts
|
||||
import { createUser, User } from "./user.factory";
|
||||
import { createProduct, Product } from "./product.factory";
|
||||
|
||||
interface OrderItem {
|
||||
product: Product;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
user: User;
|
||||
items: OrderItem[];
|
||||
total: number;
|
||||
status: "pending" | "paid" | "shipped" | "delivered";
|
||||
}
|
||||
|
||||
let orderIdCounter = 0;
|
||||
|
||||
export function createOrder(overrides: Partial<Order> = {}): Order {
|
||||
orderIdCounter++;
|
||||
|
||||
const user = overrides.user ?? createUser();
|
||||
const items = overrides.items ?? [{ product: createProduct(), quantity: 1 }];
|
||||
const total = items.reduce(
|
||||
(sum, item) => sum + item.product.price * item.quantity,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
id: `order-${orderIdCounter}`,
|
||||
user,
|
||||
items,
|
||||
total,
|
||||
status: "pending",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const order = createOrder();
|
||||
const bigOrder = createOrder({
|
||||
items: [
|
||||
{ product: createProduct({ price: 100 }), quantity: 5 },
|
||||
{ product: createProduct({ price: 50 }), quantity: 2 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Faker Integration
|
||||
|
||||
### Setup Faker
|
||||
|
||||
```bash
|
||||
npm install -D @faker-js/faker
|
||||
```
|
||||
|
||||
```typescript
|
||||
// factories/faker-user.factory.ts
|
||||
import { faker } from "@faker-js/faker";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
country: string;
|
||||
zipCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function createFakeUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: faker.string.uuid(),
|
||||
email: faker.internet.email(),
|
||||
name: faker.person.fullName(),
|
||||
avatar: faker.image.avatar(),
|
||||
address: {
|
||||
street: faker.location.streetAddress(),
|
||||
city: faker.location.city(),
|
||||
country: faker.location.country(),
|
||||
zipCode: faker.location.zipCode(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Seeded Faker for Reproducibility
|
||||
|
||||
```typescript
|
||||
import { faker } from "@faker-js/faker";
|
||||
|
||||
// Set seed for reproducible data
|
||||
faker.seed(12345);
|
||||
|
||||
export function createDeterministicUser(): User {
|
||||
return {
|
||||
id: faker.string.uuid(),
|
||||
email: faker.internet.email(),
|
||||
name: faker.person.fullName(),
|
||||
// Same seed = same data every time
|
||||
};
|
||||
}
|
||||
|
||||
// Or seed per test
|
||||
test("user profile", async ({ page }) => {
|
||||
faker.seed(42); // Reset seed for this test
|
||||
const user = createFakeUser();
|
||||
// user will always have the same data
|
||||
});
|
||||
```
|
||||
|
||||
### Faker Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/faker.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
import { faker } from "@faker-js/faker";
|
||||
|
||||
type FakerFixtures = {
|
||||
fake: typeof faker;
|
||||
};
|
||||
|
||||
export const test = base.extend<FakerFixtures>({
|
||||
fake: async ({}, use, testInfo) => {
|
||||
// Seed based on test name for reproducibility
|
||||
faker.seed(testInfo.title.length);
|
||||
await use(faker);
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("create user with fake data", async ({ page, fake }) => {
|
||||
await page.goto("/signup");
|
||||
|
||||
await page.getByLabel("Name").fill(fake.person.fullName());
|
||||
await page.getByLabel("Email").fill(fake.internet.email());
|
||||
await page.getByLabel("Password").fill(fake.internet.password());
|
||||
|
||||
await page.getByRole("button", { name: "Sign Up" }).click();
|
||||
});
|
||||
```
|
||||
|
||||
## Data-Driven Testing
|
||||
|
||||
### test.each with Arrays
|
||||
|
||||
```typescript
|
||||
const loginScenarios = [
|
||||
{ email: "user@example.com", password: "pass123", expected: "Dashboard" },
|
||||
{ email: "admin@example.com", password: "admin123", expected: "Admin Panel" },
|
||||
{
|
||||
email: "invalid@example.com",
|
||||
password: "wrong",
|
||||
expected: "Invalid credentials",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { email, password, expected } of loginScenarios) {
|
||||
test(`login with ${email}`, async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill(email);
|
||||
await page.getByLabel("Password").fill(password);
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await expect(page.getByText(expected)).toBeVisible();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Parameterized Tests
|
||||
|
||||
```typescript
|
||||
// data/checkout-scenarios.ts
|
||||
export const checkoutScenarios = [
|
||||
{
|
||||
name: "standard shipping",
|
||||
shipping: "standard",
|
||||
expectedDays: "5-7 business days",
|
||||
expectedCost: "$5.99",
|
||||
},
|
||||
{
|
||||
name: "express shipping",
|
||||
shipping: "express",
|
||||
expectedDays: "2-3 business days",
|
||||
expectedCost: "$14.99",
|
||||
},
|
||||
{
|
||||
name: "overnight shipping",
|
||||
shipping: "overnight",
|
||||
expectedDays: "Next business day",
|
||||
expectedCost: "$29.99",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { checkoutScenarios } from "./data/checkout-scenarios";
|
||||
|
||||
test.describe("shipping options", () => {
|
||||
for (const scenario of checkoutScenarios) {
|
||||
test(`checkout with ${scenario.name}`, async ({ page }) => {
|
||||
await page.goto("/checkout");
|
||||
|
||||
await page.getByLabel(scenario.shipping, { exact: false }).check();
|
||||
|
||||
await expect(page.getByText(scenario.expectedDays)).toBeVisible();
|
||||
await expect(page.getByText(scenario.expectedCost)).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### CSV/JSON Data Source
|
||||
|
||||
```typescript
|
||||
import fs from "fs";
|
||||
|
||||
interface TestCase {
|
||||
input: string;
|
||||
expected: string;
|
||||
}
|
||||
|
||||
// Load test data from JSON
|
||||
const testCases: TestCase[] = JSON.parse(
|
||||
fs.readFileSync("./data/search-tests.json", "utf-8"),
|
||||
);
|
||||
|
||||
test.describe("search functionality", () => {
|
||||
for (const { input, expected } of testCases) {
|
||||
test(`search for "${input}"`, async ({ page }) => {
|
||||
await page.goto("/search");
|
||||
await page.getByLabel("Search").fill(input);
|
||||
await page.getByLabel("Search").press("Enter");
|
||||
|
||||
await expect(page.getByText(expected)).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Test Data Fixtures
|
||||
|
||||
### Fixture with Factory
|
||||
|
||||
```typescript
|
||||
// fixtures/data.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
import { createUser, User } from "../factories/user.factory";
|
||||
import { createProduct, Product } from "../factories/product.factory";
|
||||
|
||||
type DataFixtures = {
|
||||
testUser: User;
|
||||
testProducts: Product[];
|
||||
};
|
||||
|
||||
export const test = base.extend<DataFixtures>({
|
||||
testUser: async ({}, use) => {
|
||||
const user = createUser({ name: "E2E Test User" });
|
||||
await use(user);
|
||||
},
|
||||
|
||||
testProducts: async ({}, use) => {
|
||||
const products = [
|
||||
createProduct({ name: "Test Product 1" }),
|
||||
createProduct({ name: "Test Product 2" }),
|
||||
createProduct({ name: "Test Product 3" }),
|
||||
];
|
||||
await use(products);
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("add product to cart", async ({ page, testUser, testProducts }) => {
|
||||
// Mock API with test data
|
||||
await page.route("**/api/user", (route) => route.fulfill({ json: testUser }));
|
||||
await page.route("**/api/products", (route) =>
|
||||
route.fulfill({ json: testProducts }),
|
||||
);
|
||||
|
||||
await page.goto("/products");
|
||||
await expect(page.getByText(testProducts[0].name)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Database Seeding
|
||||
|
||||
### API-Based Seeding
|
||||
|
||||
```typescript
|
||||
// fixtures/seed.fixture.ts
|
||||
import { test as base, APIRequestContext } from "@playwright/test";
|
||||
import { createUser } from "../factories/user.factory";
|
||||
|
||||
type SeedFixtures = {
|
||||
seedUser: (overrides?: Partial<User>) => Promise<User>;
|
||||
cleanupUsers: string[];
|
||||
};
|
||||
|
||||
export const test = base.extend<SeedFixtures>({
|
||||
cleanupUsers: [],
|
||||
|
||||
seedUser: async ({ request, cleanupUsers }, use) => {
|
||||
await use(async (overrides = {}) => {
|
||||
const userData = createUser(overrides);
|
||||
|
||||
const response = await request.post("/api/test/users", {
|
||||
data: userData,
|
||||
});
|
||||
const user = await response.json();
|
||||
|
||||
cleanupUsers.push(user.id);
|
||||
return user;
|
||||
});
|
||||
},
|
||||
|
||||
// Cleanup after test
|
||||
cleanupUsers: async ({ request }, use) => {
|
||||
const userIds: string[] = [];
|
||||
await use(userIds);
|
||||
|
||||
// Delete all created users
|
||||
for (const id of userIds) {
|
||||
await request.delete(`/api/test/users/${id}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("user profile page", async ({ page, seedUser }) => {
|
||||
const user = await seedUser({ name: "John Doe" });
|
||||
|
||||
await page.goto(`/users/${user.id}`);
|
||||
await expect(page.getByText("John Doe")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Transaction Rollback Seeding
|
||||
|
||||
```typescript
|
||||
// fixtures/db.fixture.ts
|
||||
export const test = base.extend<{}, { db: DbTransaction }>({
|
||||
db: [
|
||||
async ({}, use) => {
|
||||
const client = await pool.connect();
|
||||
await client.query("BEGIN");
|
||||
|
||||
await use({
|
||||
query: (sql: string, params?: any[]) => client.query(sql, params),
|
||||
seed: async (table: string, data: object) => {
|
||||
const keys = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const placeholders = keys.map((_, i) => `$${i + 1}`);
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`,
|
||||
values,
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
});
|
||||
|
||||
await client.query("ROLLBACK");
|
||||
client.release();
|
||||
},
|
||||
{ scope: "test" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------- | ------------------------------- | -------------------------- |
|
||||
| Hardcoded test data | Brittle, repetitive | Use factories |
|
||||
| Random data without seed | Non-reproducible failures | Seed faker per test |
|
||||
| Shared mutable test data | Tests interfere with each other | Create fresh data per test |
|
||||
| Manual data creation everywhere | Duplication, maintenance burden | Centralize in factories |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for fixture patterns
|
||||
- **API Testing**: See [test-suite-structure.md](test-suite-structure.md) for API mocking
|
||||
361
.cursor/skills/playwright-testing/core/test-suite-structure.md
Normal file
361
.cursor/skills/playwright-testing/core/test-suite-structure.md
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
# Test Suite Structure
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Configuration](#configuration)
|
||||
2. [E2E Tests](#e2e-tests)
|
||||
3. [Component Tests](#component-tests)
|
||||
4. [API Tests](#api-tests)
|
||||
5. [Visual Regression Tests](#visual-regression-tests)
|
||||
6. [Directory Structure](#directory-structure)
|
||||
7. [Tagging & Filtering](#tagging--filtering)
|
||||
|
||||
### Project Setup
|
||||
|
||||
```bash
|
||||
npm init playwright@latest
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Essential Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [["html"], ["list"]],
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{ name: "setup", testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## E2E Tests
|
||||
|
||||
Full user journey tests through the browser.
|
||||
|
||||
### Structure
|
||||
|
||||
```typescript
|
||||
// tests/e2e/checkout.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Checkout Flow", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/products");
|
||||
});
|
||||
|
||||
test("complete purchase as guest", async ({ page }) => {
|
||||
// Add to cart
|
||||
await page.getByRole("button", { name: "Add to Cart" }).first().click();
|
||||
await expect(page.getByTestId("cart-count")).toHaveText("1");
|
||||
|
||||
// Go to checkout
|
||||
await page.getByRole("link", { name: "Cart" }).click();
|
||||
await page.getByRole("button", { name: "Checkout" }).click();
|
||||
|
||||
// Fill shipping
|
||||
await page.getByLabel("Email").fill("guest@example.com");
|
||||
await page.getByLabel("Address").fill("123 Test St");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Payment
|
||||
await page.getByLabel("Card Number").fill("4242424242424242");
|
||||
await page.getByRole("button", { name: "Pay Now" }).click();
|
||||
|
||||
// Confirmation
|
||||
await expect(page.getByRole("heading")).toHaveText("Order Confirmed");
|
||||
});
|
||||
|
||||
test("apply discount code", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Add to Cart" }).first().click();
|
||||
await page.getByRole("link", { name: "Cart" }).click();
|
||||
|
||||
await page.getByLabel("Discount Code").fill("SAVE10");
|
||||
await page.getByRole("button", { name: "Apply" }).click();
|
||||
|
||||
await expect(page.getByText("10% discount applied")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Test critical user journeys
|
||||
- Keep tests independent
|
||||
- Use realistic data
|
||||
- Clean up test data in teardown
|
||||
|
||||
## Component Tests
|
||||
|
||||
Test individual components in isolation using Playwright Component Testing.
|
||||
|
||||
```bash
|
||||
npm init playwright@latest -- --ct
|
||||
```
|
||||
|
||||
For comprehensive component testing patterns including mounting, props, events, slots, mocking, and framework-specific examples (React, Vue, Svelte), see **[component-testing.md](../testing-patterns/component-testing.md)**.
|
||||
|
||||
## API Tests
|
||||
|
||||
Test backend APIs without browser.
|
||||
|
||||
### API Mocking Patterns
|
||||
|
||||
For E2E tests that need to mock API responses:
|
||||
|
||||
```typescript
|
||||
// Mock single endpoint
|
||||
test("displays mocked users", async ({ page }) => {
|
||||
await page.route("**/api/users", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
json: [{ id: 1, name: "Test User" }],
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto("/users");
|
||||
await expect(page.getByText("Test User")).toBeVisible();
|
||||
});
|
||||
|
||||
// Mock with different responses
|
||||
test("handles API errors", async ({ page }) => {
|
||||
await page.route("**/api/users", (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
json: { error: "Server error" },
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto("/users");
|
||||
await expect(page.getByText("Server error")).toBeVisible();
|
||||
});
|
||||
|
||||
// Conditional mocking
|
||||
test("mocks based on request", async ({ page }) => {
|
||||
await page.route("**/api/users", (route, request) => {
|
||||
if (request.method() === "GET") {
|
||||
route.fulfill({ json: [{ id: 1, name: "User" }] });
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mock with delay (simulate slow network)
|
||||
test("handles slow API", async ({ page }) => {
|
||||
await page.route("**/api/data", (route) =>
|
||||
route.fulfill({
|
||||
json: { data: "test" },
|
||||
delay: 2000, // 2 second delay
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByText("Loading...")).toBeVisible();
|
||||
await expect(page.getByText("test")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
For advanced patterns (GraphQL mocking, HAR recording, request modification, network throttling), see **[network-advanced.md](../advanced/network-advanced.md)**.
|
||||
|
||||
## Visual Regression Tests
|
||||
|
||||
Compare screenshots to detect visual changes.
|
||||
|
||||
### Basic Visual Test
|
||||
|
||||
```typescript
|
||||
// tests/visual/homepage.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("homepage visual", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveScreenshot("homepage.png");
|
||||
});
|
||||
|
||||
test("component visual", async ({ page }) => {
|
||||
await page.goto("/components");
|
||||
|
||||
const button = page.getByRole("button", { name: "Primary" });
|
||||
await expect(button).toHaveScreenshot("primary-button.png");
|
||||
});
|
||||
```
|
||||
|
||||
### Visual Test Options
|
||||
|
||||
```typescript
|
||||
test("dashboard visual", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
await expect(page).toHaveScreenshot("dashboard.png", {
|
||||
fullPage: true, // Capture entire scrollable page
|
||||
maxDiffPixels: 100, // Allow up to 100 different pixels
|
||||
maxDiffPixelRatio: 0.01, // Or 1% difference
|
||||
threshold: 0.2, // Pixel comparison threshold
|
||||
animations: "disabled", // Disable animations
|
||||
mask: [page.getByTestId("date")], // Mask dynamic content
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Handling Dynamic Content
|
||||
|
||||
```typescript
|
||||
test("page with dynamic content", async ({ page }) => {
|
||||
await page.goto("/profile");
|
||||
|
||||
// Mask elements that change
|
||||
await expect(page).toHaveScreenshot("profile.png", {
|
||||
mask: [
|
||||
page.getByTestId("timestamp"),
|
||||
page.getByTestId("avatar"),
|
||||
page.getByRole("img"),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// Or hide elements via CSS
|
||||
test("page hiding dynamic elements", async ({ page }) => {
|
||||
await page.goto("/profile");
|
||||
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
.dynamic-content { visibility: hidden !important; }
|
||||
[data-testid="ad-banner"] { display: none !important; }
|
||||
`,
|
||||
});
|
||||
|
||||
await expect(page).toHaveScreenshot("profile-stable.png");
|
||||
});
|
||||
```
|
||||
|
||||
### Visual Test Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
maxDiffPixels: 50,
|
||||
animations: "disabled",
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "visual-chrome",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
testMatch: /.*visual.*\.spec\.ts/,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Update Snapshots
|
||||
|
||||
```bash
|
||||
# Update all snapshots
|
||||
npx playwright test --update-snapshots
|
||||
|
||||
# Update specific test
|
||||
npx playwright test homepage.spec.ts --update-snapshots
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── e2e/ # End-to-end tests
|
||||
│ ├── auth.spec.ts
|
||||
│ ├── checkout.spec.ts
|
||||
│ └── dashboard.spec.ts
|
||||
├── component/ # Component tests
|
||||
│ ├── Button.spec.tsx
|
||||
│ └── Modal.spec.tsx
|
||||
├── api/ # API tests
|
||||
│ ├── users.spec.ts
|
||||
│ └── products.spec.ts
|
||||
├── visual/ # Visual regression tests
|
||||
│ └── homepage.spec.ts
|
||||
├── fixtures/ # Custom fixtures
|
||||
│ ├── auth.fixture.ts
|
||||
│ └── api.fixture.ts
|
||||
└── pages/ # Page objects
|
||||
├── login.page.ts
|
||||
└── dashboard.page.ts
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------------- | ---------------------------------- | ------------------------- |
|
||||
| Long test files | Hard to maintain, slow to navigate | Split by feature, use POM |
|
||||
| Tests depend on execution order | Flaky, hard to debug | Keep tests independent |
|
||||
| Testing multiple features in one test | Hard to debug failures | One feature per test |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Component Testing**: See [component-testing.md](../testing-patterns/component-testing.md) for comprehensive CT patterns
|
||||
- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project-based filtering
|
||||
- **Page Objects**: See [page-object-model.md](page-object-model.md) for organizing page interactions
|
||||
- **Test Data**: See [fixtures-hooks.md](fixtures-hooks.md) for managing test data
|
||||
|
||||
## Tagging & Filtering
|
||||
|
||||
### Using Tags
|
||||
|
||||
```typescript
|
||||
test("user login @smoke @auth", async ({ page }) => {
|
||||
// ...
|
||||
});
|
||||
|
||||
test("checkout flow @e2e @critical", async ({ page }) => {
|
||||
// ...
|
||||
});
|
||||
|
||||
test.describe("API tests @api", () => {
|
||||
test("create user", async ({ request }) => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Running Tagged Tests
|
||||
|
||||
```bash
|
||||
# Run smoke tests
|
||||
npx playwright test --grep @smoke
|
||||
|
||||
# Run all except slow tests
|
||||
npx playwright test --grep-invert @slow
|
||||
|
||||
# Combine tags
|
||||
npx playwright test --grep "@smoke|@critical"
|
||||
```
|
||||
|
||||
For project-based filtering and advanced project configuration, see **[projects-dependencies.md](projects-dependencies.md)**.
|
||||
298
.cursor/skills/playwright-testing/core/test-tags.md
Normal file
298
.cursor/skills/playwright-testing/core/test-tags.md
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
# Test Tags
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Basic Tagging](#basic-tagging)
|
||||
2. [Tagging Describe Blocks](#tagging-describe-blocks)
|
||||
3. [Running Tagged Tests](#running-tagged-tests)
|
||||
4. [Filtering by Tags](#filtering-by-tags)
|
||||
5. [Configuration-Based Filtering](#configuration-based-filtering)
|
||||
6. [Tag Organization Patterns](#tag-organization-patterns)
|
||||
7. [Common Tag Categories](#common-tag-categories)
|
||||
8. [Anti-Patterns to Avoid](#anti-patterns-to-avoid)
|
||||
9. [Related References](#related-references)
|
||||
|
||||
## Basic Tagging
|
||||
|
||||
### Tag via Details Object
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test(
|
||||
"test login page",
|
||||
{
|
||||
tag: "@fast",
|
||||
},
|
||||
async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("heading")).toBeVisible();
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"test dashboard",
|
||||
{
|
||||
tag: "@slow",
|
||||
},
|
||||
async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByTestId("charts")).toBeVisible();
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Tag via Title (not recommended)
|
||||
|
||||
```typescript
|
||||
test("test full report @slow", async ({ page }) => {
|
||||
await page.goto("/reports/full");
|
||||
await expect(page.getByText("Report loaded")).toBeVisible();
|
||||
});
|
||||
|
||||
test("quick validation @fast @smoke", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Tagging Describe Blocks
|
||||
|
||||
### Tag All Tests in Group
|
||||
|
||||
```typescript
|
||||
test.describe(
|
||||
"report tests",
|
||||
{
|
||||
tag: "@report",
|
||||
},
|
||||
() => {
|
||||
test("test report header", async ({ page }) => {
|
||||
// Inherits @report tag
|
||||
});
|
||||
|
||||
test("test report footer", async ({ page }) => {
|
||||
// Inherits @report tag
|
||||
});
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Combine Group and Test Tags
|
||||
|
||||
```typescript
|
||||
test.describe(
|
||||
"admin features",
|
||||
{
|
||||
tag: "@admin",
|
||||
},
|
||||
() => {
|
||||
test("admin dashboard", async ({ page }) => {
|
||||
// Has @admin tag
|
||||
});
|
||||
|
||||
test(
|
||||
"admin settings",
|
||||
{
|
||||
tag: ["@slow", "@critical"],
|
||||
},
|
||||
async ({ page }) => {
|
||||
// Has @admin, @slow, @critical tags
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Running Tagged Tests
|
||||
|
||||
### Run Tests with Specific Tag
|
||||
|
||||
```bash
|
||||
# Run all @fast tests
|
||||
npx playwright test --grep @fast
|
||||
```
|
||||
|
||||
### Exclude Tests with Tag
|
||||
|
||||
```bash
|
||||
# Run all tests except @slow
|
||||
npx playwright test --grep-invert @slow
|
||||
```
|
||||
|
||||
## Filtering by Tags
|
||||
|
||||
### Logical OR (Either Tag)
|
||||
|
||||
```bash
|
||||
# Run tests with @fast OR @smoke
|
||||
npx playwright test --grep "@fast|@smoke"
|
||||
```
|
||||
|
||||
### Logical AND (Both Tags)
|
||||
|
||||
```bash
|
||||
# Run tests with both @fast AND @critical
|
||||
npx playwright test --grep "(?=.*@fast)(?=.*@critical)"
|
||||
```
|
||||
|
||||
### Complex Patterns
|
||||
|
||||
```bash
|
||||
# Run @e2e tests that are also @critical
|
||||
npx playwright test --grep "(?=.*@e2e)(?=.*@critical)"
|
||||
|
||||
# Run @api tests excluding @slow
|
||||
npx playwright test --grep "@api" --grep-invert "@slow"
|
||||
```
|
||||
|
||||
## Configuration-Based Filtering
|
||||
|
||||
### Filter in playwright.config.ts
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
grep: /@smoke/,
|
||||
grepInvert: /@flaky/,
|
||||
});
|
||||
```
|
||||
|
||||
### Project-Specific Tags
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "smoke",
|
||||
grep: /@smoke/,
|
||||
},
|
||||
{
|
||||
name: "regression",
|
||||
grepInvert: /@smoke/,
|
||||
},
|
||||
{
|
||||
name: "critical-only",
|
||||
grep: /@critical/,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Environment-Based Filtering
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const isCI = !!process.env.CI;
|
||||
|
||||
export default defineConfig({
|
||||
grep: isCI ? /@smoke|@critical/ : undefined,
|
||||
grepInvert: isCI ? /@flaky/ : undefined,
|
||||
});
|
||||
```
|
||||
|
||||
## Tag Organization Patterns
|
||||
|
||||
### By Test Type
|
||||
|
||||
```typescript
|
||||
// Smoke tests - quick validation
|
||||
test("homepage loads", { tag: "@smoke" }, async ({ page }) => {});
|
||||
test("login works", { tag: "@smoke" }, async ({ page }) => {});
|
||||
|
||||
// Regression tests - comprehensive
|
||||
test("full checkout flow", { tag: "@regression" }, async ({ page }) => {});
|
||||
test("all payment methods", { tag: "@regression" }, async ({ page }) => {});
|
||||
|
||||
// E2E tests - user journeys
|
||||
test("complete user journey", { tag: "@e2e" }, async ({ page }) => {});
|
||||
```
|
||||
|
||||
### By Priority
|
||||
|
||||
```typescript
|
||||
test(
|
||||
"payment processing",
|
||||
{
|
||||
tag: ["@critical", "@p0"],
|
||||
},
|
||||
async ({ page }) => {}
|
||||
);
|
||||
|
||||
test(
|
||||
"user preferences",
|
||||
{
|
||||
tag: ["@p1"],
|
||||
},
|
||||
async ({ page }) => {}
|
||||
);
|
||||
|
||||
test(
|
||||
"theme customization",
|
||||
{
|
||||
tag: ["@p2"],
|
||||
},
|
||||
async ({ page }) => {}
|
||||
);
|
||||
```
|
||||
|
||||
### By Feature Area
|
||||
|
||||
```typescript
|
||||
test.describe(
|
||||
"authentication",
|
||||
{
|
||||
tag: "@auth",
|
||||
},
|
||||
() => {
|
||||
test("login @smoke", async ({ page }) => {});
|
||||
test("logout", async ({ page }) => {});
|
||||
test("password reset @slow", async ({ page }) => {});
|
||||
}
|
||||
);
|
||||
|
||||
test.describe(
|
||||
"payments",
|
||||
{
|
||||
tag: "@payments",
|
||||
},
|
||||
() => {
|
||||
test("credit card @critical", async ({ page }) => {});
|
||||
test("paypal @critical", async ({ page }) => {});
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Common Tag Categories
|
||||
|
||||
| Category | Tags | Purpose |
|
||||
| --------------- | --------------------------------------------- | ----------------------------- |
|
||||
| **Speed** | `@fast`, `@slow` | Execution time classification |
|
||||
| **Priority** | `@critical`, `@p0`, `@p1`, `@p2` | Business importance |
|
||||
| **Type** | `@smoke`, `@regression`, `@e2e` | Test suite categorization |
|
||||
| **Feature** | `@auth`, `@payments`, `@settings` | Feature area grouping |
|
||||
| **Pipeline** | `@pr`, `@nightly`, `@release` | CI/CD execution timing |
|
||||
| **Status** | `@flaky`, `@wip`, `@quarantine` | Test health tracking |
|
||||
| **Environment** | `@local`, `@staging`, `@prod` | Target environment |
|
||||
| **Team** | `@team-frontend`, `@team-backend`, `@team-qa` | Team assignment |
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------ | ------------------------ | ---------------------------------------------- |
|
||||
| Too many tags per test | Hard to maintain | Limit to 2-3 relevant tags |
|
||||
| Inconsistent naming | Confusing filtering | Establish naming conventions |
|
||||
| Missing `@` prefix | Tags won't match filters | Always prefix with `@` |
|
||||
| Overlapping tag meanings | Ambiguous categorization | Define clear tag semantics |
|
||||
| Not using tags | Can't selectively run | Tag by type, priority, or feature |
|
||||
| Tags in test title | Hard to parse/filter | Use the details object for tags, not the title |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests
|
||||
- **Annotations**: See [annotations.md](annotations.md) for skip, fixme, fail, slow
|
||||
- **CI/CD Integration**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for pipeline setup
|
||||
Loading…
Add table
Add a link
Reference in a new issue