Advanced
This tutorial covers advanced Playwright patterns for production environments including Page Object Model advanced patterns, custom fixtures, debugging tools, CI/CD integration, parallel execution, authentication flows, and production testing strategies.
Example 61: Page Object Model - Advanced Composition
Page Object Model (POM) encapsulates page interactions in reusable classes. Advanced POM uses composition to share common components across pages.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
A[BasePage]
B[Navigation Component]
C[LoginPage extends BasePage]
D[DashboardPage extends BasePage]
C --> A
D --> A
C --> B
D --> B
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
style D fill:#029E73,stroke:#000,color:#fff
Code:
import { test, expect, Page, Locator } from "@playwright/test";
// Base page with common functionality
class BasePage {
// => BasePage class definition
readonly page: Page;
// => page: readonly class field
constructor(page: Page) {
// => constructor() method
this.page = page; // => Store page reference
// => this.page: set instance property
}
// => End of block
async goto(path: string): Promise<void> {
// => goto() method
await this.page.goto(`https://example.com${path}`); // => Navigate with base URL
// => Navigates to URL and waits
}
// => End of block
async waitForPageLoad(): Promise<void> {
// => waitForPageLoad() method
await this.page.waitForLoadState("networkidle"); // => Wait for network to settle
// => Ensures page fully loaded
}
// => End of block
}
// Shared navigation component
class NavigationComponent {
// => NavigationComponent class definition
readonly page: Page;
// => page: readonly class field
readonly homeLink: Locator;
// => homeLink: readonly class field
readonly profileLink: Locator;
// => profileLink: readonly class field
readonly logoutButton: Locator;
// => logoutButton: readonly class field
constructor(page: Page) {
// => constructor() method
this.page = page;
// => this.page: set instance property
this.homeLink = page.getByRole("link", { name: "Home" }); // => Locate home link
// => this.homeLink: set instance property
this.profileLink = page.getByRole("link", { name: "Profile" }); // => Locate profile link
// => this.profileLink: set instance property
this.logoutButton = page.getByRole("button", { name: "Logout" }); // => Locate logout button
// => this.logoutButton: set instance property
}
// => End of block
async navigateToHome(): Promise<void> {
// => navigateToHome() method
await this.homeLink.click(); // => Click home link
// => Navigates to home page
}
// => End of block
async logout(): Promise<void> {
// => logout() method
await this.logoutButton.click(); // => Click logout
// => Logs user out of application
}
// => End of block
}
// Login page with composition
class LoginPage extends BasePage {
// => LoginPage extends BasePage: inherits parent methods
readonly navigation: NavigationComponent;
// => navigation: readonly class field
readonly emailInput: Locator;
// => emailInput: readonly class field
readonly passwordInput: Locator;
// => passwordInput: readonly class field
readonly submitButton: Locator;
// => submitButton: readonly class field
constructor(page: Page) {
// => constructor() method
super(page); // => Call base constructor
// => super() function defined
this.navigation = new NavigationComponent(page); // => Compose navigation component
// => this.navigation: set instance property
this.emailInput = page.getByLabel("Email"); // => Locate email input
// => this.emailInput: set instance property
this.passwordInput = page.getByLabel("Password"); // => Locate password input
// => this.passwordInput: set instance property
this.submitButton = page.getByRole("button", { name: "Sign In" }); // => Locate submit button
// => this.submitButton: set instance property
}
// => End of block
async login(email: string, password: string): Promise<void> {
// => login() method
await this.emailInput.fill(email); // => Fill email
// => Fills input field
await this.passwordInput.fill(password); // => Fill password
// => Fills input field
await this.submitButton.click(); // => Submit form
// => Clicks element
await this.waitForPageLoad(); // => Wait for navigation
// => Waits for condition
}
// => End of block
}
// Dashboard page reuses navigation component
class DashboardPage extends BasePage {
// => DashboardPage extends BasePage: inherits parent methods
readonly navigation: NavigationComponent;
// => navigation: readonly class field
readonly welcomeMessage: Locator;
// => welcomeMessage: readonly class field
constructor(page: Page) {
// => constructor() method
super(page);
// => super() function defined
this.navigation = new NavigationComponent(page); // => Same navigation component
// => this.navigation: set instance property
this.welcomeMessage = page.getByTestId("welcome-message"); // => Locate welcome message
// => this.welcomeMessage: set instance property
}
// => End of block
async getWelcomeText(): Promise<string> {
// => getWelcomeText() method
return (await this.welcomeMessage.textContent()) || ""; // => Get welcome text
// => Returns welcome message
}
// => End of block
}
// => End of block
test("advanced POM with composition", async ({ page }) => {
// => Test case or suite
const loginPage = new LoginPage(page); // => Create login page object
// => loginPage: creates LoginPage instance
await loginPage.goto("/login"); // => Navigate using base method
// => Awaits async operation
await loginPage.login("user@example.com", "password"); // => Login using page method
// => Awaits async operation
const dashboard = new DashboardPage(page); // => Create dashboard page object
// => dashboard: creates DashboardPage instance
const welcomeText = await dashboard.getWelcomeText(); // => Get welcome message
// => welcomeText: awaits async operation
expect(welcomeText).toContain("Welcome"); // => Verify logged in
// => expect() function defined
await dashboard.navigation.logout(); // => Logout using shared component
// => Reused navigation logic across pages
});
// => End of blockKey Takeaway: Advanced POM uses composition to share common components (navigation, footer, modals) across multiple page objects. BasePage provides shared utilities while component classes handle specific UI elements.
Why It Matters: Component composition reduces code duplication and maintenance burden. When navigation changes, update NavigationComponent once instead of every page object - component-based POM helps manage multiple pages with shared UI elements.
Example 62: Component Objects Pattern
Component objects represent reusable UI components (modals, dropdowns, cards) that appear across multiple pages. They encapsulate component-specific interactions.
Code:
import { test, expect, Page, Locator } from "@playwright/test";
// Modal component appearing across multiple pages
class ModalComponent {
// => ModalComponent class definition
readonly page: Page;
// => page: readonly class field
readonly container: Locator;
// => container: readonly class field
readonly title: Locator;
// => title: readonly class field
readonly closeButton: Locator;
// => closeButton: readonly class field
readonly confirmButton: Locator;
// => confirmButton: readonly class field
readonly cancelButton: Locator;
// => cancelButton: readonly class field
constructor(page: Page) {
// => constructor() method
this.page = page;
// => this.page: set instance property
this.container = page.getByRole("dialog"); // => Locate modal dialog
// => this.container: set instance property
this.title = this.container.getByRole("heading"); // => Title within modal
// => this.title: set instance property
this.closeButton = this.container.getByLabel("Close"); // => Close button (X icon)
// => this.closeButton: set instance property
this.confirmButton = this.container.getByRole("button", { name: "Confirm" }); // => Confirm button
// => this.confirmButton: set instance property
this.cancelButton = this.container.getByRole("button", { name: "Cancel" }); // => Cancel button
// => this.cancelButton: set instance property
}
// => End of block
async waitForModal(): Promise<void> {
// => waitForModal() method
await this.container.waitFor({ state: "visible" }); // => Wait for modal to appear
// => Ensures modal fully rendered
}
// => End of block
async getTitle(): Promise<string> {
// => getTitle() method
return (await this.title.textContent()) || ""; // => Get modal title
// => Returns modal heading text
}
// => End of block
async confirm(): Promise<void> {
// => confirm() method
await this.confirmButton.click(); // => Click confirm
// => Clicks element
await this.container.waitFor({ state: "hidden" }); // => Wait for modal to disappear
// => Waits for condition
}
// => End of block
async cancel(): Promise<void> {
// => cancel() method
await this.cancelButton.click(); // => Click cancel
// => Clicks element
await this.container.waitFor({ state: "hidden" }); // => Wait for modal to disappear
// => Waits for condition
}
// => End of block
async close(): Promise<void> {
// => close() method
await this.closeButton.click(); // => Click close icon
// => Clicks element
await this.container.waitFor({ state: "hidden" }); // => Wait for modal to disappear
// => Waits for condition
}
// => End of block
}
// Dropdown component appearing in forms
class DropdownComponent {
// => DropdownComponent class definition
readonly page: Page;
// => page: readonly class field
readonly trigger: Locator;
// => trigger: readonly class field
readonly options: Locator;
// => options: readonly class field
constructor(page: Page, label: string) {
// => constructor() method
this.page = page;
// => this.page: set instance property
this.trigger = page.getByLabel(label); // => Dropdown trigger (select or button)
// => this.trigger: set instance property
this.options = page.getByRole("option"); // => Dropdown options
// => this.options: set instance property
}
// => End of block
async select(optionText: string): Promise<void> {
// => select() method
await this.trigger.click(); // => Open dropdown
// => Clicks element
await this.options.filter({ hasText: optionText }).click(); // => Select option
// => Clicks specific option by text
}
// => End of block
async getSelectedValue(): Promise<string> {
// => getSelectedValue() method
return await this.trigger.inputValue(); // => Get current selection
// => Returns selected value
}
// => End of block
}
// => End of block
test("component objects for modals", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com/profile");
// Trigger delete action
const deleteButton = page.getByRole("button", { name: "Delete Account" });
// => deleteButton: assigned value
await deleteButton.click(); // => Opens confirmation modal
// => Clicks element
const modal = new ModalComponent(page); // => Create modal component
// => modal: creates ModalComponent instance
await modal.waitForModal(); // => Wait for modal appearance
// => Waits for condition
const title = await modal.getTitle(); // => Get modal title
// => title: awaits async operation
expect(title).toBe("Confirm Deletion"); // => Verify correct modal
// => expect() function defined
await modal.confirm(); // => Confirm deletion
// => Modal closed and action executed
});
// => End of block
test("component objects for dropdowns", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com/settings");
// => Navigates to URL and waits
const languageDropdown = new DropdownComponent(page, "Language"); // => Create dropdown component
// => languageDropdown: creates DropdownComponent instance
await languageDropdown.select("English"); // => Select language
// => Awaits async operation
const selectedLanguage = await languageDropdown.getSelectedValue(); // => Get selection
// => selectedLanguage: awaits async operation
expect(selectedLanguage).toBe("English"); // => Verify selected
// => expect() function defined
const timezoneDropdown = new DropdownComponent(page, "Timezone"); // => Reuse component for different dropdown
// => timezoneDropdown: creates DropdownComponent instance
await timezoneDropdown.select("UTC+7"); // => Select timezone
// => Same component, different instance
});
// => End of blockKey Takeaway: Component objects encapsulate reusable UI components (modals, dropdowns, cards) that appear across multiple pages. They provide a consistent API for interacting with specific component types.
Why It Matters: UI components are reused extensively in modern web apps. Component objects prevent code duplication when the same modal or dropdown appears on 20 different pages - Component objects can significantly reduce test code.
Example 63: Custom Fixtures for Test Setup
Custom fixtures provide reusable test setup and teardown. They inject dependencies into tests using Playwright's built-in fixture system.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
A[Test Function]
B[authenticatedPage Fixture]
C[apiClient Fixture]
D[testData Fixture]
A --> B
A --> C
A --> D
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#DE8F05,stroke:#000,color:#fff
style D fill:#DE8F05,stroke:#000,color:#fff
Code:
import { test as base, expect, Page } from "@playwright/test";
// Define custom fixtures
type MyFixtures = {
// => MyFixtures: TypeScript fixture type definition
authenticatedPage: Page; // => Page with authenticated user
// => authenticatedPage: property value
testUser: { email: string; password: string }; // => Test user credentials
// => testUser: property value
apiClient: any; // => API client for test data setup
// => apiClient: property value
};
// Extend base test with custom fixtures
const test = base.extend<MyFixtures>({
// => test: assigned value
testUser: async ({}, use) => {
// => Define test user fixture
const user = {
// => user: assigned value
email: "test@example.com",
// => email: property value
password: "SecurePassword123!",
// => password: property value
};
// => End of block
await use(user); // => Provide user to test
// Cleanup: none needed for static data
},
// => Statement executed
authenticatedPage: async ({ page, testUser }, use) => {
// => Fixture depends on page and testUser
// Setup: Login automatically
await page.goto("https://example.com/login");
// => Navigates to URL and waits
await page.getByLabel("Email").fill(testUser.email); // => Fill email
// => Fills input field
await page.getByLabel("Password").fill(testUser.password); // => Fill password
// => Fills input field
await page.getByRole("button", { name: "Sign In" }).click(); // => Submit login
// => Clicks element
await page.waitForURL("**/dashboard"); // => Wait for redirect
// => Page now authenticated
await use(page); // => Provide authenticated page to test
// Cleanup: Logout
await page.getByRole("button", { name: "Logout" }).click(); // => Logout after test
// => Clicks element
},
// => Statement executed
apiClient: async ({ request }, use) => {
// => Create API client fixture
const client = {
// => client: assigned value
createUser: async (userData: any) => {
// => createUser: async (userData: any) => {
const response = await request.post("/api/users", { data: userData });
// => response: awaits async operation
return response.json(); // => Returns created user
// => Returns result
},
// => Statement executed
deleteUser: async (userId: string) => {
// => deleteUser: async (userId: string) => {
await request.delete(`/api/users/${userId}`);
// => Deletes user
},
// => Statement executed
};
// => End of block
await use(client); // => Provide API client to test
// Cleanup: handled by test
},
// => Statement executed
});
// Use custom fixtures in tests
test("fixture for authenticated page", async ({ authenticatedPage }) => {
// => Test receives pre-authenticated page
const welcomeMessage = authenticatedPage.getByTestId("welcome-message");
// => welcomeMessage: assigned value
await expect(welcomeMessage).toBeVisible(); // => Already logged in
// No manual login needed
});
// => End of block
test("fixture for test data creation", async ({ apiClient, page }) => {
// Setup test data via API
const user = await apiClient.createUser({
// => user: awaits async operation
name: "Test User",
// => name: property value
email: "newuser@example.com",
// => email: property value
}); // => Creates user via API
// Test UI with created data
await page.goto("https://example.com/users");
// => Navigates to URL and waits
const userRow = page.getByRole("row", { name: user.name });
// => userRow: assigned value
await expect(userRow).toBeVisible(); // => Verify user appears in UI
// Cleanup
await apiClient.deleteUser(user.id); // => Remove test data
// => Awaits async operation
});
// => End of block
test("combined fixtures", async ({ authenticatedPage, testUser }) => {
// => Uses multiple custom fixtures
const profileLink = authenticatedPage.getByRole("link", { name: "Profile" });
// => profileLink: assigned value
await profileLink.click(); // => Navigate to profile
// => Clicks element
const emailDisplay = authenticatedPage.getByText(testUser.email);
// => emailDisplay: assigned value
await expect(emailDisplay).toBeVisible(); // => Verify user email shown
// Fixtures compose naturally
});
// => End of blockKey Takeaway: Custom fixtures encapsulate test setup and teardown, providing reusable dependencies through Playwright's fixture system. Fixtures can depend on other fixtures for composition.
Why It Matters: Test setup duplication leads to maintenance burden and brittle tests. Fixtures centralize setup logic and enable dependency injection - Production teams use fixtures extensively for different test scenarios.
Example 64: Fixture Composition and Scoping
Fixtures support composition (fixtures depending on other fixtures) and different scopes (test-scoped vs. worker-scoped). Worker-scoped fixtures run once per worker process.
Code:
import { test as base, expect } from "@playwright/test";
// => Playwright imports
type MyFixtures = {
// => MyFixtures: TypeScript fixture type definition
database: any; // => Worker-scoped database connection
// => database: property value
testUser: any; // => Test-scoped user (unique per test)
// => testUser: property value
cleanupList: string[]; // => Test-scoped cleanup tracker
// => cleanupList: property value
};
// => End of block
const test = base.extend<MyFixtures>({
// Worker-scoped fixture: shared across tests in same worker
database: [
// => database: property value
async ({}, use) => {
// => Worker-scoped (runs once per worker)
console.log("Connecting to database (worker-scoped)");
// => console.log() called
const db = {
// => db: assigned value
connected: true,
// => connected: property value
users: new Map(), // => Shared state across tests
// => users: property value
connect: () => console.log("DB connected"),
// => connect: property value
disconnect: () => console.log("DB disconnected"),
// => disconnect: property value
};
// => End of block
await db.connect(); // => Connect once
// => Awaits async operation
await use(db); // => Provide database to all tests
// => Awaits async operation
await db.disconnect(); // => Disconnect once
// => Awaits async operation
console.log("Disconnecting from database (worker-scoped)");
// => console.log() called
},
// => Statement executed
{ scope: "worker" },
// => Statement executed
], // => Worker scope
// Test-scoped fixture: fresh instance per test
testUser: async ({ database, cleanupList }, use) => {
// => Test-scoped (runs for each test)
// => Depends on database fixture
const userId = `user-${Date.now()}`;
// => userId: assigned value
const user = {
// => user: assigned value
id: userId,
// => id: property value
name: "Test User",
// => name: property value
email: `${userId}@example.com`,
// => email: property value
};
// => End of block
database.users.set(userId, user); // => Create user in database
// => Statement executed
cleanupList.push(userId); // => Track for cleanup
// => cleanupList.push() called
await use(user); // => Provide user to test
// Cleanup: runs after test
database.users.delete(userId); // => Remove user
// => Statement executed
console.log(`Cleaned up user: ${userId}`);
// => console.log() called
},
// Test-scoped cleanup tracker
cleanupList: async ({}, use) => {
// => Fresh list per test
const list: string[] = [];
// => list: assigned value
await use(list); // => Provide list to test
// => Awaits async operation
console.log(`Cleanup list: ${list.join(", ")}`); // => Log cleanups
// => console.log() called
},
// => Statement executed
});
// => End of block
test.describe("fixture scoping", () => {
// => Test case or suite
test("first test with fixtures", async ({ testUser, database }) => {
// => Gets fresh testUser, shared database
expect(testUser.id).toBeDefined(); // => Unique user
// => expect() function defined
expect(database.connected).toBe(true); // => Shared database
// => expect() function defined
console.log(`Test 1 user: ${testUser.id}`);
// => console.log() called
});
// => End of block
test("second test with fixtures", async ({ testUser, database }) => {
// => Gets NEW testUser, SAME database
expect(testUser.id).toBeDefined(); // => Different user from test 1
// => expect() function defined
expect(database.connected).toBe(true); // => Same database connection
// => expect() function defined
console.log(`Test 2 user: ${testUser.id}`);
// Users are isolated, database is shared
});
// => End of block
test("fixture composition", async ({ testUser, cleanupList }) => {
// => testUser depends on cleanupList
expect(cleanupList).toContain(testUser.id); // => User ID added to cleanup list
// Fixtures compose through dependencies
});
// => End of block
});
// => End of blockOutput:
Connecting to database (worker-scoped) ← Once at worker start
Test 1 user: user-1738425600000
Cleaned up user: user-1738425600000 ← After test 1
Test 2 user: user-1738425600001 ← Different user
Cleaned up user: user-1738425600001 ← After test 2
Cleanup list: user-1738425600002
Cleaned up user: user-1738425600002
Disconnecting from database (worker-scoped) ← Once at worker end
Key Takeaway: Fixture scoping controls lifecycle - test-scoped fixtures run for each test (fresh data), worker-scoped fixtures run once per worker (shared resources like database connections). Fixtures compose through dependencies.
Why It Matters: Proper scoping prevents test pollution and improves performance. Worker-scoped database connections reduce setup overhead while test-scoped data ensures isolation - Production test suites use worker-scoped browser contexts and test-scoped page fixtures for optimal balance.
Example 65: Parameterized Tests with test.describe.configure
Parameterized tests run the same test logic with different inputs. Use loops or data-driven approaches to reduce duplication.
Code:
import { test, expect } from "@playwright/test";
// Data-driven test with array of test cases
const loginTestCases = [
// => loginTestCases: assigned value
{ email: "user1@example.com", password: "Pass123!", expectedMessage: "Welcome user1" },
// => Statement executed
{ email: "user2@example.com", password: "Pass456!", expectedMessage: "Welcome user2" },
// => Statement executed
{ email: "admin@example.com", password: "Admin789!", expectedMessage: "Welcome admin" },
// => Statement executed
];
// Run same test with different data
for (const testCase of loginTestCases) {
// => Iteration
test(`login with ${testCase.email}`, async ({ page }) => {
// => Parameterized test runs 3 times
await page.goto("https://example.com/login");
// => Navigates to URL and waits
await page.getByLabel("Email").fill(testCase.email); // => Fill email from test case
// => Fills input field
await page.getByLabel("Password").fill(testCase.password); // => Fill password from test case
// => Fills input field
await page.getByRole("button", { name: "Sign In" }).click(); // => Submit
// => Clicks element
const welcomeMessage = page.getByTestId("welcome-message");
// => welcomeMessage: assigned value
await expect(welcomeMessage).toHaveText(testCase.expectedMessage); // => Verify expected message
// => Test case-specific assertion
});
// => End of block
}
// Alternative: describe.configure for browser-specific tests
test.describe.configure({ mode: "parallel" }); // => Run tests in parallel
// => Test case or suite
const browsers = ["chromium", "firefox", "webkit"] as const;
// => browsers: assigned value
for (const browserName of browsers) {
// => Iteration
test.describe(`Browser: ${browserName}`, () => {
// => Browser-specific test suite
test.use({ browserName }); // => Configure browser
// => test.use() called
test("renders homepage correctly", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com");
// => Navigates to URL and waits
const heading = page.getByRole("heading", { name: "Welcome" });
// => heading: assigned value
await expect(heading).toBeVisible(); // => Same test across browsers
// => Runs on chromium, firefox, webkit
});
// => End of block
test("handles navigation", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com");
// => Navigates to URL and waits
const aboutLink = page.getByRole("link", { name: "About" });
// => aboutLink: assigned value
await aboutLink.click(); // => Navigate
// => Clicks element
await expect(page).toHaveURL(/.*about/); // => Verify navigation
// => Cross-browser navigation test
});
// => End of block
});
// => End of block
}
// Matrix testing: browsers × viewport sizes
const viewports = [
// => viewports: assigned value
{ width: 1920, height: 1080, name: "Desktop" },
// => Statement executed
{ width: 768, height: 1024, name: "Tablet" },
// => Statement executed
{ width: 375, height: 667, name: "Mobile" },
// => Statement executed
];
// => Statement executed
for (const viewport of viewports) {
// => Iteration
test.describe(`Viewport: ${viewport.name}`, () => {
// => Test case or suite
test.use({ viewport: { width: viewport.width, height: viewport.height } }); // => Configure viewport
// => test.use() called
test("responsive layout", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com");
// => Navigates to URL and waits
const navigation = page.getByRole("navigation");
// => navigation: assigned value
await expect(navigation).toBeVisible(); // => Navigation visible on all viewports
// => Responsive test across viewports
});
// => End of block
});
// => End of block
}
// => End of blockKey Takeaway: Parameterized tests use loops or describe blocks to run the same test logic with different inputs (credentials, browsers, viewports). Reduces duplication for data-driven and cross-browser testing.
Why It Matters: Testing multiple scenarios manually duplicates code and creates maintenance burden. Parameterized tests enable comprehensive coverage without duplication - Production accessibility tests can run the same checks across multiple page types using parameterization with minimal code.
Example 66: Data-Driven Testing with External Data
Data-driven tests load test cases from external files (JSON, CSV, database). Separates test data from test logic for easier maintenance.
Code:
import { test, expect } from "@playwright/test";
// => Playwright imports
import * as fs from "fs";
// => External import
import * as path from "path";
// Load test data from JSON file
const testDataPath = path.join(__dirname, "test-data", "users.json");
// => testDataPath: assigned value
const testData = JSON.parse(fs.readFileSync(testDataPath, "utf-8"));
// => Reads test data from external file
// users.json contains: [{ email: "...", password: "...", expectedRole: "..." }, ...]
interface UserTestData {
// => Statement executed
email: string;
// => email: property value
password: string;
// => password: property value
expectedRole: string;
// => expectedRole: property value
shouldSucceed: boolean;
// => shouldSucceed: property value
}
// Run tests for each user in data file
for (const userData of testData.users as UserTestData[]) {
// => Iteration
test(`login test for ${userData.email}`, async ({ page }) => {
// => One test per user in JSON
await page.goto("https://example.com/login");
// => Navigates to URL and waits
await page.getByLabel("Email").fill(userData.email); // => Email from JSON
// => Fills input field
await page.getByLabel("Password").fill(userData.password); // => Password from JSON
// => Fills input field
await page.getByRole("button", { name: "Sign In" }).click(); // => Submit
// => Clicks element
if (userData.shouldSucceed) {
// => Expected success path
const roleDisplay = page.getByTestId("user-role");
// => roleDisplay: assigned value
await expect(roleDisplay).toHaveText(userData.expectedRole); // => Verify role
// => Assertion uses expected value from JSON
} else {
// => Expected failure path
const errorMessage = page.getByRole("alert");
// => errorMessage: assigned value
await expect(errorMessage).toBeVisible(); // => Verify error shown
// => Handles both success and failure cases
}
// => End of block
});
// => End of block
}
// Alternative: CSV data parsing
test.describe("CSV data-driven tests", () => {
// => Test case or suite
const csvPath = path.join(__dirname, "test-data", "products.csv");
// => csvPath: assigned value
const csvContent = fs.readFileSync(csvPath, "utf-8");
// => Read CSV file
const rows = csvContent.split("\n").slice(1); // => Skip header row
// => rows: assigned value
const products = rows.map((row) => {
// => products: assigned value
const [id, name, price] = row.split(","); // => Parse CSV columns
// => Statement executed
return { id, name, price: parseFloat(price) };
// => Returns result
});
// => End of block
for (const product of products) {
// => Iteration
test(`search for product: ${product.name}`, async ({ page }) => {
// => One test per CSV row
await page.goto("https://example.com/search");
// => Navigates to URL and waits
const searchInput = page.getByRole("searchbox");
// => searchInput: assigned value
await searchInput.fill(product.name); // => Search from CSV
// => Fills input field
await searchInput.press("Enter"); // => Submit search
// => Awaits async operation
const resultCard = page.getByTestId(`product-${product.id}`);
// => resultCard: assigned value
await expect(resultCard).toBeVisible(); // => Verify product found
// => Asserts condition
const priceDisplay = resultCard.getByTestId("price");
// => priceDisplay: assigned value
await expect(priceDisplay).toHaveText(`$${product.price.toFixed(2)}`); // => Verify price
// => CSV data drives assertions
});
// => End of block
}
// => End of block
});
// Dynamic data from API
test.describe("API-driven test data", () => {
// => Test case or suite
let apiData: any[];
// => Declares apiData
test.beforeAll(async ({ request }) => {
// => Load data before tests
const response = await request.get("https://api.example.com/test-users");
// => response: awaits async operation
apiData = await response.json(); // => API provides test data
// => Data refreshed on each test run
});
// => End of block
test("uses API-provided test data", async ({ page }) => {
// => Data from beforeAll
const firstUser = apiData[0];
// => firstUser: assigned value
await page.goto("https://example.com/users");
// => Navigates to URL and waits
const userCard = page.getByTestId(`user-${firstUser.id}`);
// => userCard: assigned value
await expect(userCard).toBeVisible(); // => Verify user from API
// => Test data synchronized with API
});
// => End of block
});
// => End of blockExample users.json:
{
"users": [
{
"email": "admin@example.com",
"password": "AdminPass123!",
"expectedRole": "Administrator",
"shouldSucceed": true
},
{
"email": "user@example.com",
"password": "UserPass123!",
"expectedRole": "User",
"shouldSucceed": true
},
{
"email": "invalid@example.com",
"password": "wrong",
"expectedRole": "",
"shouldSucceed": false
}
]
}Key Takeaway: Data-driven testing separates test data from test logic by loading test cases from external sources (JSON, CSV, API). Enables non-technical users to add test cases and keeps test code clean.
Why It Matters: Hardcoded test data mixes data with logic and makes updates difficult. External data sources enable easy test expansion and collaboration - Production tests can use JSON-based test data, allowing teams to add many test scenarios without touching code.
Example 67: Visual Regression Testing with Screenshots
Visual regression testing captures screenshots and compares against baselines to detect unexpected UI changes. Playwright provides built-in screenshot comparison.
Code:
import { test, expect } from "@playwright/test";
// => Playwright imports
test("visual regression - full page", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com");
// => Navigates to URL and waits
await page.waitForLoadState("networkidle"); // => Ensure page fully loaded
// => Prevents flaky screenshots from in-flight requests
// Capture and compare full page screenshot
await expect(page).toHaveScreenshot("homepage.png"); // => Takes screenshot and compares
// => On first run: creates baseline screenshot
// => On subsequent runs: compares against baseline
// => Fails if visual differences detected
});
// => End of block
test("visual regression - specific element", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com/products");
// => Navigates to URL and waits
const productCard = page.getByTestId("product-card-1");
// => productCard: assigned value
await expect(productCard).toHaveScreenshot("product-card.png"); // => Element screenshot
// => Only captures specified element
// => Ignores changes outside element
});
// => End of block
test("visual regression - with threshold", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com/dashboard");
// Allow small pixel differences (anti-aliasing, font rendering)
await expect(page).toHaveScreenshot("dashboard.png", {
// => Asserts condition
maxDiffPixels: 100, // => Allow up to 100 pixels difference
// => Prevents false positives from minor rendering variations
});
// Alternative: percentage threshold
await expect(page).toHaveScreenshot("dashboard-percent.png", {
// => Asserts condition
maxDiffPixelRatio: 0.01, // => Allow 1% pixel difference
// => Useful for large screenshots
});
// => End of block
});
// => End of block
test("visual regression - masking dynamic content", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com/profile");
// Mask dynamic elements (timestamps, user IDs)
await expect(page).toHaveScreenshot("profile.png", {
// => Asserts condition
mask: [
// => mask: property value
page.getByTestId("last-login-time"), // => Masks timestamp
// => page.getByTestId() called
page.getByTestId("user-id"), // => Masks dynamic ID
// => page.getByTestId() called
],
// => Masked areas ignored in comparison
});
// => End of block
});
// => End of block
test("visual regression - animations disabled", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com/animated-page");
// Disable animations for consistent screenshots
await page.emulateMedia({ reducedMotion: "reduce" }); // => Disables CSS animations
// => Prevents animation frames from causing differences
await expect(page).toHaveScreenshot("animated-page.png");
// => Screenshot captured without mid-animation frames
});
// => End of block
test.describe("multi-viewport visual regression", () => {
// => Test case or suite
const viewports = [
// => viewports: assigned value
{ width: 1920, height: 1080, name: "desktop" },
// => Statement executed
{ width: 768, height: 1024, name: "tablet" },
// => Statement executed
{ width: 375, height: 667, name: "mobile" },
// => Statement executed
];
// => Statement executed
for (const viewport of viewports) {
// => Iteration
test(`visual regression - ${viewport.name}`, async ({ page }) => {
// => Test case or suite
await page.setViewportSize({ width: viewport.width, height: viewport.height });
// => Set viewport size
await page.goto("https://example.com");
// => Navigates to URL and waits
await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`);
// => Separate baseline per viewport
// => Catches responsive layout issues
});
}
});Key Takeaway: Visual regression testing captures screenshots and compares against baselines to detect unintended UI changes. Use masking for dynamic content and thresholds for minor rendering variations.
Why It Matters: Manual visual testing is time-consuming and misses subtle changes. Automated visual regression catches CSS bugs, layout shifts, and font rendering issues - Automated visual regression testing prevents unintended style changes and catches visual bugs before production.
Example 68: Network Interception and Request Mocking
Network interception captures and modifies network requests. Request mocking replaces real API responses with test data for faster, more reliable tests.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
A[Browser]
B[Playwright Intercept]
C[Mock Response]
D[Real API]
A -->|Request| B
B -->|Return mock| C
B -.->|Skip real API| D
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
style D fill:#CC78BC,stroke:#000,color:#fff
Code:
import { test, expect } from "@playwright/test";
// => Playwright imports
test("intercept and log network requests", async ({ page }) => {
// Log all API requests
page.on("request", (request) => {
// => page.on() called
if (request.url().includes("/api/")) {
// => Conditional check
console.log(`API Request: ${request.method()} ${request.url()}`);
// => Logs all API requests
}
// => End of block
});
// Log API responses
page.on("response", (response) => {
// => page.on() called
if (response.url().includes("/api/")) {
// => Conditional check
console.log(`API Response: ${response.status()} ${response.url()}`);
// => Logs all API responses with status
}
// => End of block
});
// => End of block
await page.goto("https://example.com");
// => Network activity logged in console
});
// => End of block
test("mock API response", async ({ page }) => {
// Intercept and mock API endpoint
await page.route("**/api/users", (route) => {
// => Intercept /api/users requests
route.fulfill({
// => route.fulfill() called
status: 200,
// => status: property value
contentType: "application/json",
// => contentType: property value
body: JSON.stringify([
// => body: property value
{ id: 1, name: "Mock User 1", email: "mock1@example.com" },
// => Statement executed
{ id: 2, name: "Mock User 2", email: "mock2@example.com" },
// => Statement executed
]),
// => Statement executed
}); // => Return mock data instead of real API
// => No real API call made
});
// => End of block
await page.goto("https://example.com/users");
// => Navigates to URL and waits
const userCard = page.getByText("Mock User 1");
// => userCard: assigned value
await expect(userCard).toBeVisible(); // => Mock data displayed
// => Test runs without real API
});
// => End of block
test("mock API failure for error handling", async ({ page }) => {
// Mock API error
await page.route("**/api/users", (route) => {
// => Sets up request interception handler
route.fulfill({
// => route.fulfill() called
status: 500,
// => status: property value
contentType: "application/json",
// => contentType: property value
body: JSON.stringify({ error: "Internal Server Error" }),
// => body: property value
}); // => Simulate API failure
// => Tests error handling code
});
// => End of block
await page.goto("https://example.com/users");
// => Navigates to URL and waits
const errorMessage = page.getByRole("alert");
// => errorMessage: assigned value
await expect(errorMessage).toHaveText("Failed to load users"); // => Error message shown
// => Error handling tested without breaking real API
});
// => End of block
test("conditional mocking - pass through some requests", async ({ page }) => {
// => Test case or suite
await page.route("**/api/**", (route) => {
// => Intercept all API requests
const url = route.request().url();
// => url: assigned value
if (url.includes("/api/users")) {
// Mock users endpoint
route.fulfill({
// => route.fulfill() called
status: 200,
// => status: property value
body: JSON.stringify([{ id: 1, name: "Mock User" }]),
// => body: property value
}); // => Mock users
// => Statement executed
} else {
// Pass through all other API requests
route.continue(); // => Let other requests go to real API
// => Only mocks specific endpoints
}
// => End of block
});
// => End of block
await page.goto("https://example.com");
// => /api/users mocked, other endpoints hit real API
});
// => End of block
test("modify request headers", async ({ page }) => {
// => Test case or suite
await page.route("**/api/**", (route) => {
// Add custom header to all API requests
const headers = {
// => headers: assigned value
...route.request().headers(),
// => Statement executed
"X-Test-Mode": "true", // => Add test header
// => Statement executed
};
// => End of block
route.continue({ headers }); // => Forward with modified headers
// => API receives requests with test header
});
// => End of block
await page.goto("https://example.com");
// => All API requests include X-Test-Mode header
});
// => End of block
test("mock slow network for loading states", async ({ page }) => {
// => Test case or suite
await page.route("**/api/products", async (route) => {
// Simulate slow API
await new Promise((resolve) => setTimeout(resolve, 3000)); // => Wait 3 seconds
// => Simulates slow network
route.fulfill({
// => route.fulfill() called
status: 200,
// => status: property value
body: JSON.stringify([{ id: 1, name: "Product" }]),
// => body: property value
});
// => End of block
});
// => End of block
await page.goto("https://example.com/products");
// Verify loading state appears
const loadingSpinner = page.getByTestId("loading-spinner");
// => loadingSpinner: assigned value
await expect(loadingSpinner).toBeVisible(); // => Loading state shown during delay
// Eventually data loads
await expect(loadingSpinner).toBeHidden({ timeout: 5000 }); // => Loading state disappears
// => Tests loading states reliably
});
// => End of blockKey Takeaway: Network interception captures requests and responses for logging or modification. Request mocking replaces real API responses with test data for faster, more reliable tests and error scenario testing.
Why It Matters: Tests depending on real APIs are slow and flaky. Mocking eliminates external dependencies and enables testing error scenarios impossible to reproduce reliably - Production UI tests that mock API responses significantly reduce test execution time.
Example 69: Advanced Request Mocking with HAR Files
HAR (HTTP Archive) files record real network traffic and replay it in tests. Captures realistic API responses without maintaining mock data manually.
Code:
import { test, expect } from "@playwright/test";
// => Playwright imports
test("record HAR file", async ({ page }) => {
// Record network traffic to HAR file
await page.routeFromHAR("network-archive.har", {
// => Sets up request interception handler
update: true, // => Record mode: captures real traffic
// => Creates/updates HAR file with actual responses
});
// => End of block
await page.goto("https://example.com");
// => Navigates to URL and waits
await page.getByRole("link", { name: "Products" }).click();
// => Clicks element
await page.getByRole("button", { name: "Load More" }).click();
// => All network requests recorded to HAR
// => network-archive.har now contains all API responses
});
// => End of block
test("replay from HAR file", async ({ page }) => {
// Replay recorded traffic
await page.routeFromHAR("network-archive.har", {
// => Sets up request interception handler
update: false, // => Replay mode: uses recorded responses
// => No real API calls made
});
// => End of block
await page.goto("https://example.com");
// => Navigates to URL and waits
await page.getByRole("link", { name: "Products" }).click();
// => Uses recorded responses from HAR
// => Exact same behavior as recording run
const productCard = page.getByTestId("product-1");
// => productCard: assigned value
await expect(productCard).toBeVisible(); // => Data from HAR file
// => Test runs offline using recorded data
});
// => End of block
test("replay with URL filtering", async ({ page }) => {
// Replay only specific URLs from HAR
await page.routeFromHAR("network-archive.har", {
// => Sets up request interception handler
url: "**/api/products/**", // => Only match products API
// => Other requests go to real API
});
// => End of block
await page.goto("https://example.com");
// => /api/products replayed from HAR
// => Other endpoints hit real API
});
// => End of block
test("update HAR incrementally", async ({ page }) => {
// Update mode: record new requests, replay existing
await page.routeFromHAR("network-archive.har", {
// => Sets up request interception handler
update: true,
// => update: property value
updateContent: "embed", // => Embed responses in HAR
// => updateContent: property value
updateMode: "minimal", // => Only record new/changed requests
// => Doesn't re-record existing responses
});
await page.goto("https://example.com/new-feature");
// => New feature requests added to HAR
// => Existing requests replayed from HAR
});HAR File Structure (simplified):
{
"log": {
"entries": [
{
"request": {
"method": "GET",
"url": "https://example.com/api/products"
},
"response": {
"status": 200,
"content": {
"text": "[{\"id\":1,\"name\":\"Product 1\"}]"
}
}
}
]
}
}Key Takeaway: HAR files record and replay real network traffic, combining realism of real APIs with speed of mocked responses. Use update mode to record, replay mode for tests.
Why It Matters: Manual mock data drifts from real API responses over time. HAR files capture realistic traffic and can be refreshed easily - Production frontend tests can use HAR recording to maintain test data accuracy.
Example 70: WebSocket Testing
WebSocket connections enable real-time bidirectional communication. Test WebSocket events and messages for live features like chat or notifications.
Code:
import { test, expect } from "@playwright/test";
// => Playwright imports
test("WebSocket message receiving", async ({ page }) => {
// Listen for WebSocket connections
const wsPromise = page.waitForEvent("websocket");
// => Wait for WebSocket connection to open
await page.goto("https://example.com/chat");
// => Navigates to URL and waits
const ws = await wsPromise; // => WebSocket connection established
// => ws: awaits async operation
console.log(`WebSocket URL: ${ws.url()}`); // => Log WebSocket URL
// Listen for WebSocket frames (messages)
ws.on("framereceived", (event) => {
// => ws.on() called
console.log(`Received: ${event.payload}`); // => Log received messages
// => Captures server-sent messages
});
// => End of block
ws.on("framesent", (event) => {
// => ws.on() called
console.log(`Sent: ${event.payload}`); // => Log sent messages
// => Captures client-sent messages
});
// Trigger message sending
const messageInput = page.getByPlaceholder("Type a message");
// => messageInput: assigned value
await messageInput.fill("Hello WebSocket"); // => Type message
// => Fills input field
await messageInput.press("Enter"); // => Send message
// => WebSocket frame sent
// Wait for response message
await page.waitForSelector('.message:has-text("Hello WebSocket")'); // => Message appears
// => Verifies WebSocket round-trip
});
// => End of block
test("WebSocket connection lifecycle", async ({ page }) => {
// => Test case or suite
let wsOpened = false;
// => wsOpened: assigned value
let wsClosed = false;
// => wsClosed: assigned value
page.on("websocket", (ws) => {
// => page.on() called
console.log("WebSocket opened");
// => console.log() called
wsOpened = true; // => Connection opened
// => Statement executed
ws.on("close", () => {
// => ws.on() called
console.log("WebSocket closed");
// => console.log() called
wsClosed = true; // => Connection closed
// => Statement executed
});
// => End of block
});
// => End of block
await page.goto("https://example.com/live-updates");
// => Navigates to URL and waits
await page.waitForTimeout(1000); // => Wait for connection
// => Waits for condition
expect(wsOpened).toBe(true); // => Verify connection opened
// => expect() function defined
await page.close(); // => Close page
// => Awaits async operation
await page.context().browser()?.close(); // => Cleanup
// => Awaits async operation
expect(wsClosed).toBe(true); // => Verify connection closed
// => Full lifecycle tested
});
// => End of block
test("WebSocket error handling", async ({ page }) => {
// => Test case or suite
const wsPromise = page.waitForEvent("websocket");
// => wsPromise: assigned value
await page.goto("https://example.com/chat");
// => Navigates to URL and waits
const ws = await wsPromise;
// => ws: awaits async operation
let errorOccurred = false;
// => errorOccurred: assigned value
ws.on("socketerror", (error) => {
// => ws.on() called
console.log(`WebSocket error: ${error}`);
// => console.log() called
errorOccurred = true; // => Error captured
// => Statement executed
});
// Trigger network interruption (if testable)
await page.evaluate(() => {
// Close WebSocket from client side
const wsConnection = (window as any).wsConnection;
// => wsConnection: assigned value
if (wsConnection) {
// => Conditional check
wsConnection.close(); // => Force close
// => wsConnection.close() called
}
// => End of block
});
// Verify app handles disconnection
const reconnectingMessage = page.getByText("Reconnecting...");
// => reconnectingMessage: assigned value
await expect(reconnectingMessage).toBeVisible(); // => Error handling UI shown
// => WebSocket error handling tested
});
// => End of blockKey Takeaway: Test WebSocket connections by listening to websocket events, framereceived/framesent for messages, and close/socketerror for lifecycle and errors. Verifies real-time features work correctly.
Why It Matters: WebSocket bugs break real-time features (chat, live updates, collaborative editing) causing poor user experience. Testing WebSocket connections ensures reliability - Real-time messaging tests can use Playwright's WebSocket APIs to verify message delivery.
Example 71: Trace Viewer for Debugging
Trace viewer records test execution with screenshots, console logs, network activity, and DOM snapshots. Essential for debugging flaky or failed tests.
Code:
import { test, expect } from "@playwright/test";
// Enable tracing in playwright.config.ts:
// use: { trace: 'on-first-retry' } or 'retain-on-failure'
test("test with tracing enabled", async ({ page }) => {
// Tracing automatically enabled based on config
// => Records every action, screenshot, network request
await page.goto("https://example.com");
// => Screenshot captured, DOM snapshot saved
await page.getByRole("button", { name: "Submit" }).click();
// => Click recorded with before/after screenshots
await expect(page.getByText("Success")).toBeVisible();
// => Assertion recorded in trace
// If test fails, trace saved to test-results/
// View with: npx playwright show-trace trace.zip
});
test("manual trace control", async ({ page }, testInfo) => {
// Start tracing manually
await page.context().tracing.start({
// => Awaits async operation
screenshots: true, // => Capture screenshots
// => screenshots: property value
snapshots: true, // => Capture DOM snapshots
// => snapshots: property value
sources: true, // => Include source code
// => sources: property value
}); // => Tracing started
await page.goto("https://example.com/complex-flow");
// ... test actions ...
// Stop and save trace
await page.context().tracing.stop({
// => Awaits async operation
path: `traces/${testInfo.title}.zip`, // => Save trace file
// => path: property value
}); // => Trace saved for analysis
// => View with: npx playwright show-trace traces/[test-name].zip
});
test("trace specific scenario", async ({ page }) => {
await page.goto("https://example.com");
// Start tracing for problematic section
await page.context().tracing.start({ screenshots: true, snapshots: true });
// Problematic flow
await page.getByRole("button", { name: "Open Modal" }).click();
// => Clicks element
await page.getByLabel("Email").fill("user@example.com");
// => Fills input field
await page.getByRole("button", { name: "Save" }).click();
// => All actions captured in trace
await page.context().tracing.stop({ path: "traces/modal-flow.zip" });
// Continue test without tracing
await page.getByRole("button", { name: "Continue" }).click();
// => Tracing overhead only for problematic section
});Trace Viewer Features:
- **Timeline**: Visual timeline of all actions with screenshots
- **Network**: All network requests and responses
- **Console**: Console logs from browser
- **Source**: Test source code with executed lines highlighted
- **Call**: Stack traces for each action
- **DOM Snapshots**: Full DOM state at each action
- **Screenshots**: Before/after screenshots for actions
- **Metadata**: Test info, browser, viewport, timingsViewing Traces:
# After test run with tracing enabled
npx playwright show-trace test-results/test-trace.zip
# Opens web UI showing:
# - Every action taken
# - Screenshots at each step
# - Network requests made
# - Console output
# - DOM state at any pointKey Takeaway: Trace viewer records complete test execution including screenshots, network activity, console logs, and DOM snapshots. Essential debugging tool for understanding test failures, especially in CI where you can't run tests locally.
Why It Matters: Debugging failed tests in CI without traces requires guesswork and local reproduction attempts. Trace viewer shows exactly what happened - Trace viewer enables debugging most CI failures without local reproduction.
Example 72: Debug Mode and Playwright Inspector
Debug mode pauses test execution and opens Playwright Inspector for step-by-step debugging. Use for developing tests or investigating failures.
Code:
import { test, expect } from "@playwright/test";
// => Playwright imports
test("debug mode with inspector", async ({ page }) => {
// Run with: npx playwright test --debug
// => Opens Playwright Inspector
await page.goto("https://example.com");
// => Pauses here, Inspector shows page state
await page.getByRole("button", { name: "Submit" }).click();
// => Step through actions one at a time
await expect(page.getByText("Success")).toBeVisible();
// => Verify assertions in Inspector
});
test("programmatic breakpoint", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com/form");
// => Navigates to URL and waits
await page.getByLabel("Email").fill("user@example.com");
// Add breakpoint in code
await page.pause(); // => Pauses execution, opens Inspector
// => Inspect page state, DOM, network at this point
// => Click "Resume" to continue
await page.getByLabel("Password").fill("password123");
// => Fills input field
await page.getByRole("button", { name: "Submit" }).click();
// => Clicks element
});
test("debug with console logging", async ({ page }) => {
// Enable console logging
page.on("console", (msg) => {
// => page.on() called
console.log(`Browser console [${msg.type()}]: ${msg.text()}`);
// => Logs all browser console messages
});
await page.goto("https://example.com");
// Log page state for debugging
const title = await page.title();
// => title: awaits async operation
console.log(`Page title: ${title}`); // => Logs page title
// => console.log() called
const url = page.url();
// => url: assigned value
console.log(`Current URL: ${url}`); // => Logs current URL
// Execute JavaScript and log result
const bodyText = await page.evaluate(() => document.body.innerText);
// => bodyText: awaits async operation
console.log(`Body text length: ${bodyText.length}`); // => Logs text length
// => Console output aids debugging
});
test("step-by-step selector testing", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com");
// Test selector interactively
await page.pause(); // => Opens Inspector
// In Inspector:
// 1. Use "Pick Locator" to test selectors
// 2. Type selectors in console: page.getByRole('button')
// 3. See matching elements highlighted
// 4. Copy working selector to test code
const button = page.getByRole("button", { name: "Submit" });
// => Selector developed in Inspector, then added to test
await button.click();
// => Clicks element
});Inspector Features:
- **Pick Locator**: Click elements to generate selectors
- **Step Through**: Execute test line-by-line
- **Console**: Run Playwright commands interactively
- **Source**: View test code with current line highlighted
- **Network**: View network requests in real-time
- **Console Logs**: See browser console output
- **DOM Snapshot**: Inspect page structureDebug Mode Commands:
# Run single test in debug mode
npx playwright test tests/example.spec.ts --debug
# Debug specific test by name
npx playwright test -g "login test" --debug
# Debug headed (visible browser)
npx playwright test --debug --headedKey Takeaway: Debug mode opens Playwright Inspector for step-by-step test execution, interactive selector testing, and page state inspection. Use page.pause() for programmatic breakpoints.
Why It Matters: Debugging tests by reading code alone is inefficient. Inspector enables interactive debugging with visual feedback - Playwright's debug mode reduced test development time by 40% in user studies by enabling rapid selector iteration.
Example 73: Video Recording for Test Evidence
Video recording captures full test execution as video files. Provides visual evidence of test behavior and failure scenarios.
Code:
import { test, expect } from "@playwright/test";
// Enable video in playwright.config.ts:
// use: { video: 'on-first-retry' } or 'retain-on-failure'
test("test with video recording", async ({ page }) => {
// Video automatically recorded based on config
// => Every browser action captured
await page.goto("https://example.com");
// => Navigation recorded in video
await page.getByRole("button", { name: "Open Menu" }).click();
// => Click action visible in video
await page.getByRole("link", { name: "Settings" }).click();
// => Navigation flow captured
await expect(page).toHaveURL(/.*settings/);
// => URL change visible in video
// If test fails, video saved to test-results/
// Video shows exactly what happened
});
test("always record video", async ({ page }, testInfo) => {
// Force video recording regardless of config
// Configured in playwright.config.ts per project:
// video: { mode: 'on', size: { width: 1280, height: 720 } }
await page.goto("https://example.com/critical-flow");
// Critical business flow
await page.getByRole("button", { name: "Start Process" }).click();
await page.getByLabel("Amount").fill("1000");
await page.getByRole("button", { name: "Confirm" }).click();
// => Complete flow recorded for audit/compliance
const videoPath = await page.video()?.path();
console.log(`Video saved to: ${videoPath}`);
// => Video path available after test
});
test("custom video path", async ({ page, context }, testInfo) => {
// Video configured per browser context in config
// Access video after test completion
await page.goto("https://example.com");
await page.getByRole("button", { name: "Submit" }).click();
// Video automatically saved based on test result
// Path: test-results/[test-file]-[test-name]/video.webm
});
test.describe("video recording configuration", () => {
// Configure in playwright.config.ts:
test("retain on failure only", async ({ page }) => {
// video: 'retain-on-failure'
// => Video kept only if test fails
// => Saves disk space for passing tests
await page.goto("https://example.com");
// Video recorded but deleted if test passes
});
test("record on first retry", async ({ page }) => {
// video: 'on-first-retry'
// => No video on first run
// => Video recorded on retry after failure
await page.goto("https://example.com");
// First run: no video
// Retry run: video recorded
});
});Video Configuration (playwright.config.ts):
import { defineConfig } from "@playwright/test";
// => Import Playwright config builder
export default defineConfig({
// => Export configuration object
use: {
// => Shared browser options for all tests
// Video recording options
video: {
// => Video recording settings
mode: "retain-on-failure", // 'on', 'off', 'retain-on-failure', 'on-first-retry'
// => retain-on-failure: keeps video only when test fails
size: { width: 1280, height: 720 }, // => Video resolution
// => 1280x720 standard HD resolution
},
},
});
// => Configuration applied to all test runsKey Takeaway: Video recording captures full test execution as video files, providing visual evidence of test behavior and failures. Configure retention strategy (always, on failure, on retry) to balance evidence with storage.
Why It Matters: Screenshots show single moments; videos show full interaction flow. Critical for understanding complex failures and demonstrating bugs to developers - Test videos help reproduce and communicate customer-reported issues.
Example 74: HAR Files for Network Analysis
HAR (HTTP Archive) files record all network traffic during test execution. Essential for debugging API issues and performance analysis.
Code:
import { test, expect } from "@playwright/test";
// => Playwright imports
test("record HAR for network debugging", async ({ page, context }) => {
// Start HAR recording
await context.routeFromHAR("network-traffic.har", {
// => Awaits async operation
update: true, // => Record mode
// => update: property value
updateContent: "embed", // => Embed response bodies
// => updateContent: property value
});
await page.goto("https://example.com");
// => All network requests recorded
await page.getByRole("button", { name: "Load Data" }).click();
// => API calls recorded with full request/response
// HAR file contains:
// - All HTTP requests (method, URL, headers)
// - All responses (status, headers, body)
// - Timing information (DNS, connect, request, response)
// - Cookies, redirects, caching info
});
test("analyze HAR for performance", async ({ page, context }) => {
await context.routeFromHAR("performance-har.har", {
// => Awaits async operation
update: true,
// => update: property value
updateContent: "embed",
// => updateContent: property value
});
await page.goto("https://example.com/heavy-page");
// After test, analyze HAR file:
// - Identify slow requests
// - Find large payloads
// - Check caching headers
// - Verify compression
// HAR viewers: Chrome DevTools, HAR Analyzer, Playwright Trace Viewer
});
test("HAR with request filtering", async ({ page, context }) => {
// Record only API requests
await context.routeFromHAR("api-only.har", {
// => Awaits async operation
update: true,
// => update: property value
url: "**/api/**", // => Only record API paths
// => Excludes static assets, images, etc.
});
await page.goto("https://example.com");
// Only /api/* requests saved to HAR
// Smaller HAR file, focused on backend interactions
});HAR File Structure:
{
"log": {
"version": "1.2",
"creator": { "name": "Playwright", "version": "1.40" },
"entries": [
{
"request": {
"method": "GET",
"url": "https://example.com/api/users",
"headers": [{ "name": "Accept", "value": "application/json" }]
},
"response": {
"status": 200,
"statusText": "OK",
"headers": [{ "name": "Content-Type", "value": "application/json" }],
"content": {
"size": 1234,
"mimeType": "application/json",
"text": "[{\"id\":1,\"name\":\"User\"}]"
}
},
"time": 245,
"timings": {
"dns": 5,
"connect": 20,
"send": 1,
"wait": 200,
"receive": 19
}
}
]
}
}Analyzing HAR Files:
# Open in Chrome DevTools
# 1. Open DevTools → Network tab
# 2. Drag HAR file onto Network panel
# 3. Analyze requests, timings, payloads
# Open in Playwright Trace Viewer
npx playwright show-trace network-traffic.har
# Shows requests in timeline with full detailsKey Takeaway: HAR files capture complete network traffic including requests, responses, headers, timings, and payloads. Essential for debugging API issues, analyzing performance, and understanding network behavior.
Why It Matters: Network issues are invisible without traffic capture. HAR files provide evidence for debugging slow APIs, failed requests, and caching problems - Support teams use HAR files from tests to diagnose network-related issues.
Example 75: Console Logs Capture and Analysis
Capturing browser console logs helps debug JavaScript errors, warnings, and application behavior. Essential for understanding client-side issues.
Code:
import { test, expect } from "@playwright/test";
// => Playwright imports
test("capture and log console messages", async ({ page }) => {
// Listen to all console messages
page.on("console", (msg) => {
// => page.on() called
console.log(`[${msg.type()}] ${msg.text()}`);
// => Logs all browser console output
// => Types: log, info, warn, error, debug
});
// => End of block
await page.goto("https://example.com");
// => Console messages from page load logged
await page.getByRole("button", { name: "Submit" }).click();
// => Console messages from click handler logged
});
// => End of block
test("detect console errors", async ({ page }) => {
// => Test case or suite
const errors: string[] = [];
// Capture console errors
page.on("console", (msg) => {
// => page.on() called
if (msg.type() === "error") {
// => Conditional check
errors.push(msg.text()); // => Store error messages
// => errors.push() called
}
// => End of block
});
// => End of block
await page.goto("https://example.com");
// => Navigates to URL and waits
await page.getByRole("button", { name: "Trigger Error" }).click();
// Verify no console errors occurred
expect(errors).toHaveLength(0); // => Fails if any errors
// => Detects JavaScript errors during test
});
// => End of block
test("capture specific console patterns", async ({ page }) => {
// => Test case or suite
const warnings: string[] = [];
// => warnings: assigned value
const apiCalls: string[] = [];
// => apiCalls: assigned value
page.on("console", (msg) => {
// => page.on() called
const text = msg.text();
// Capture warnings
if (msg.type() === "warn") {
// => Conditional check
warnings.push(text);
// => warnings.push() called
}
// Capture API call logs
if (text.includes("API:")) {
// => Conditional check
apiCalls.push(text); // => Track API-related logs
// => apiCalls.push() called
}
// => End of block
});
// => End of block
await page.goto("https://example.com");
// => Navigates to URL and waits
console.log(`Warnings: ${warnings.length}`); // => Log warning count
// => console.log() called
console.log(`API calls: ${apiCalls.length}`); // => Log API call count
// => Analyze specific console patterns
});
// => End of block
test("verify expected console output", async ({ page }) => {
// => Test case or suite
let initializationLogged = false;
// => initializationLogged: assigned value
page.on("console", (msg) => {
// => page.on() called
if (msg.text().includes("App initialized")) {
// => Conditional check
initializationLogged = true; // => Expected log message found
// => Statement executed
}
// => End of block
});
// => End of block
await page.goto("https://example.com");
// => Navigates to URL and waits
expect(initializationLogged).toBe(true); // => Verify app logged initialization
// => Tests that app produces expected logs
});
// => End of block
test("console with arguments and stack traces", async ({ page }) => {
// => Test case or suite
page.on("console", async (msg) => {
// Get console message type and text
console.log(`Type: ${msg.type()}, Text: ${msg.text()}`);
// Get arguments (for complex objects)
const args = msg.args();
// => args: assigned value
for (let i = 0; i < args.length; i++) {
// => Iteration
const argValue = await args[i].jsonValue();
// => argValue: awaits async operation
console.log(`Arg ${i}:`, argValue); // => Log each argument
// => console.log() called
}
// Get location (file, line number)
const location = msg.location();
// => location: assigned value
console.log(`Location: ${location.url}:${location.lineNumber}`);
// => Shows where console.log was called
});
// => End of block
await page.goto("https://example.com");
// => Detailed console information logged
});
// => End of block
test("fail test on console errors", async ({ page }) => {
// => Test case or suite
const errors: Array<{ text: string; location: any }> = [];
// => errors: assigned value
page.on("console", (msg) => {
// => page.on() called
if (msg.type() === "error") {
// => Conditional check
errors.push({
// => errors.push() called
text: msg.text(),
// => text: property value
location: msg.location(),
// => location: property value
}); // => Capture error details
// => Statement executed
}
// => End of block
});
// => End of block
await page.goto("https://example.com");
// => Navigates to URL and waits
await page.getByRole("button", { name: "Action" }).click();
// Fail test if any console errors
if (errors.length > 0) {
// => Conditional check
const errorDetails = errors.map((e) => `${e.text} at ${e.location.url}:${e.location.lineNumber}`).join("\n");
// => errorDetails: assigned value
throw new Error(`Console errors detected:\n${errorDetails}`);
// => Test fails with error details
}
// => End of block
});
// => End of blockKey Takeaway: Capture browser console messages to debug JavaScript errors, verify expected logs, and detect warnings. Use console listeners to fail tests on unexpected errors.
Why It Matters: Console errors indicate broken JavaScript that may not cause visible failures. Capturing console logs catches these issues early - Frontend tests can automatically fail on console errors, catching additional bugs than UI assertions alone.
Example 76: Parallel Execution with Workers
Parallel execution runs tests simultaneously across multiple worker processes. Drastically reduces total test suite execution time.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
A[Test Suite 100 tests]
B[Worker 1: 25 tests]
C[Worker 2: 25 tests]
D[Worker 3: 25 tests]
E[Worker 4: 25 tests]
A --> B
A --> C
A --> D
A --> E
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#DE8F05,stroke:#000,color:#fff
style D fill:#DE8F05,stroke:#000,color:#fff
style E fill:#DE8F05,stroke:#000,color:#fff
Configuration (playwright.config.ts):
import { defineConfig } from "@playwright/test";
// => Playwright imports
export default defineConfig({
// Parallel execution configuration
workers: 4, // => 4 parallel worker processes
// => Auto-detects CPU cores: workers: '50%' or workers: undefined
fullyParallel: true, // => Run tests in same file in parallel
// => Default: tests in same file run serially
// Retry configuration
retries: 2, // => Retry failed tests 2 times
// => Reduces flakiness impact
use: {
// Shared context for all workers
baseURL: "https://example.com",
// => baseURL: property value
screenshot: "only-on-failure",
// => screenshot: property value
video: "retain-on-failure",
// => video: property value
},
// => Statement executed
});
// => End of blockCode:
import { test, expect } from "@playwright/test";
// Tests in this file run in parallel (if fullyParallel: true)
test("parallel test 1", async ({ page }) => {
// => Function call result
await page.goto("/page1");
// => Awaits async operation
await expect(page.getByRole("heading")).toBeVisible();
// => Runs in worker 1
});
// => End of block
test("parallel test 2", async ({ page }) => {
// => Function call result
await page.goto("/page2");
// => Awaits async operation
await expect(page.getByRole("heading")).toBeVisible();
// => Runs in worker 2 (simultaneously)
});
// => End of block
test("parallel test 3", async ({ page }) => {
// => Function call result
await page.goto("/page3");
// => Awaits async operation
await expect(page.getByRole("heading")).toBeVisible();
// => Runs in worker 3 (simultaneously)
});
// Force serial execution for dependent tests
test.describe.configure({ mode: "serial" });
// => Statement executed
test.describe("serial tests", () => {
// These tests run one after another
test("setup test", async ({ page }) => {
// => Function call result
await page.goto("/setup");
// => Runs first
});
// => End of block
test("dependent test", async ({ page }) => {
// => Function call result
await page.goto("/dependent");
// => Runs second (depends on setup)
});
// => End of block
});
// Worker-scoped fixture for expensive setup
test.describe("worker-scoped database", () => {
// => Statement executed
test.beforeAll(async () => {
// Runs once per worker (not per test)
console.log("Setting up database for worker");
// => Expensive setup shared across tests in worker
});
// => End of block
test("test 1 with database", async ({ page }) => {
// => Function call result
await page.goto("/users");
// => Uses database setup
});
// => End of block
test("test 2 with database", async ({ page }) => {
// => Function call result
await page.goto("/products");
// => Reuses same database setup
});
// => End of block
});
// => End of blockRunning Parallel Tests:
# Run with default workers (auto-detect CPU cores)
npx playwright test
# Run with specific worker count
npx playwright test --workers=4
# Run with 50% of CPU cores
npx playwright test --workers=50%
# Run serially (debugging)
npx playwright test --workers=1
# Run specific file in parallel
npx playwright test tests/parallel.spec.ts --fully-parallelPerformance Comparison:
// Before parallel execution (serial):
// 100 tests × 5 seconds each = 500 seconds (8.3 minutes)
// After parallel execution (4 workers):
// 100 tests ÷ 4 workers × 5 seconds = 125 seconds (2.1 minutes)
// => 75% time reductionKey Takeaway: Parallel execution runs tests across multiple worker processes, reducing total execution time proportionally to worker count. Use fullyParallel for file-level parallelization and workers config for process count.
Why It Matters: Serial test execution becomes prohibitively slow as test suites grow. Parallel execution maintains fast feedback - Parallel workers can significantly reduce test suite execution time.
Example 77: Test Sharding for CI
Sharding splits test suite across multiple CI machines. Enables horizontal scaling for even faster execution in CI environments.
Code:
// No code changes needed in tests
// Sharding configured at runtime via CLI
import { test, expect } from "@playwright/test";
// => Playwright imports
test("test 1", async ({ page }) => {
// => Test case or suite
await page.goto("/page1");
// => Navigates to URL and waits
await expect(page).toHaveTitle(/Page 1/);
// => Asserts condition
});
test("test 2", async ({ page }) => {
// => Test case or suite
await page.goto("/page2");
// => Navigates to URL and waits
await expect(page).toHaveTitle(/Page 2/);
// => Asserts condition
});
// ... 100 more tests ...Sharding Commands:
# Split tests into 4 shards, run shard 1
npx playwright test --shard=1/4
# Run shard 2 of 4
npx playwright test --shard=2/4
# Run shard 3 of 4
npx playwright test --shard=3/4
# Run shard 4 of 4
npx playwright test --shard=4/4
# Playwright automatically divides tests evenly across shards
# => Shard 1: tests 1-25
# => Shard 2: tests 26-50
# => Shard 3: tests 51-75
# => Shard 4: tests 76-100GitHub Actions Configuration:
name: Playwright Tests (Sharded)
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
# => Runs 1/4 of tests on each matrix job
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-results-${{ matrix.shardIndex }}
path: test-results/Sharding Performance:
// Without sharding (1 CI machine, 4 workers):
// 100 tests ÷ 4 workers × 5 seconds = 125 seconds
// With sharding (4 CI machines, 4 workers each):
// 100 tests ÷ 4 shards ÷ 4 workers × 5 seconds = 31 seconds
// => 75% additional time reduction through horizontal scalingKey Takeaway: Sharding splits test suite across multiple machines for horizontal scaling in CI. Use --shard flag to distribute tests across CI matrix jobs.
Why It Matters: Single-machine parallelization has limits (CPU cores). Sharding enables unlimited horizontal scaling - Sharded tests can complete large test suites quickly.
Example 78: CI Configuration with GitHub Actions
GitHub Actions provides free CI/CD for open source projects. Configure Playwright tests to run automatically on every push and pull request.
GitHub Actions Workflow (.github/workflows/playwright.yml):
name: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
# => Installs Chromium, Firefox, WebKit
- name: Run Playwright tests
run: npx playwright test
# => Runs all tests
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
# => Uploads HTML report as artifact
- name: Upload test videos
uses: actions/upload-artifact@v3
if: failure()
with:
name: test-videos
path: test-results/**/video.webm
# => Uploads videos only on failureMulti-Browser Testing:
name: Cross-Browser Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps ${{ matrix.browser }}
# => Installs only specified browser
- name: Run tests on ${{ matrix.browser }}
run: npx playwright test --project=${{ matrix.browser }}
# => Runs browser-specific tests
- name: Upload results
if: always()
uses: actions/upload-artifact@v3
with:
name: results-${{ matrix.browser }}
path: playwright-report/Key Takeaway: Configure CI to run Playwright tests automatically on every push/PR. Upload test reports, videos, and traces as artifacts for debugging failures.
Why It Matters: Manual test runs are inconsistent and often skipped. Automated CI testing catches regressions before merge - Automated CI testing prevents broken code from reaching production.
Example 79: Docker for Consistent Test Environment
Docker provides consistent test environments across local development and CI. Eliminates "works on my machine" issues.
Dockerfile for Playwright:
FROM mcr.microsoft.com/playwright:v1.40.0-focal
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy test files
COPY . .
# Run tests
CMD ["npx", "playwright", "test"]Docker Compose (docker-compose.yml):
version: "3.8"
services:
playwright:
build: .
environment:
- CI=true
- PWTEST_SKIP_TEST_OUTPUT=1
volumes:
- ./test-results:/app/test-results
- ./playwright-report:/app/playwright-report
# => Mounts results directories for artifact accessRunning Tests in Docker:
# Build Docker image
docker build -t playwright-tests .
# Run tests in container
docker run -it playwright-tests
# Run with volume mounts for results
docker run -it \
-v $(pwd)/test-results:/app/test-results \
-v $(pwd)/playwright-report:/app/playwright-report \
playwright-tests
# Run with Docker Compose
docker-compose up --abort-on-container-exit
# Run specific browser
docker run -it playwright-tests npx playwright test --project=chromiumGitHub Actions with Docker:
name: Playwright Tests (Docker)
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build Docker image
run: docker build -t playwright-tests .
- name: Run tests in Docker
run: |
docker run -v $(pwd)/test-results:/app/test-results \
-v $(pwd)/playwright-report:/app/playwright-report \
playwright-tests
- name: Upload results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/Key Takeaway: Docker provides consistent test environments across local and CI. Use official Playwright Docker images to eliminate environment-specific issues.
Why It Matters: Environment differences cause "works locally, fails in CI" problems. Docker ensures consistency - Docker ensures consistency and eliminates most environment-related test failures.
Example 80: Performance Testing Basics
Playwright can measure performance metrics like page load time, Core Web Vitals, and resource timings. Basic performance testing catches regressions.
Code:
import { test, expect } from "@playwright/test";
// => Playwright imports
test("measure page load time", async ({ page }) => {
// => Test case or suite
const startTime = Date.now();
// => startTime: assigned value
await page.goto("https://example.com");
// => Navigates to URL and waits
await page.waitForLoadState("networkidle"); // => Wait for all network activity
// => Waits for condition
const loadTime = Date.now() - startTime;
// => loadTime: assigned value
console.log(`Page load time: ${loadTime}ms`); // => Log load time
// => console.log() called
expect(loadTime).toBeLessThan(3000); // => Fail if > 3 seconds
// => Performance regression detected
});
// => End of block
test("measure Core Web Vitals", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com");
// Get performance metrics
const metrics = await page.evaluate(() => {
// => metrics: awaits async operation
return new Promise((resolve) => {
// Wait for metrics to be available
new PerformanceObserver((list) => {
// => Statement executed
const entries = list.getEntries();
// => entries: assigned value
resolve(
// => Function call result
entries.map((entry) => ({
// => entries.map() called
name: entry.name,
// => name: property value
value: entry.startTime,
// => value: property value
})),
// => Statement executed
);
// => Statement executed
}).observe({ entryTypes: ["navigation", "paint"] });
// Fallback: use existing metrics
setTimeout(() => {
// => setTimeout() function defined
const navigation = performance.getEntriesByType("navigation")[0] as any;
// => navigation: assigned value
const paint = performance.getEntriesByType("paint");
// => paint: assigned value
resolve({
// Page load timing
domContentLoaded: navigation?.domContentLoadedEventEnd - navigation?.domContentLoadedEventStart,
// => domContentLoaded: property value
loadComplete: navigation?.loadEventEnd - navigation?.loadEventStart,
// Paint timing
firstPaint: paint.find((p) => p.name === "first-paint")?.startTime,
// => firstPaint: property value
firstContentfulPaint: paint.find((p) => p.name === "first-contentful-paint")?.startTime,
// => firstContentfulPaint: property value
});
// => End of block
}, 1000);
// => Statement executed
});
// => End of block
});
// => End of block
console.log("Performance metrics:", metrics);
// Assert on metrics
expect((metrics as any).firstContentfulPaint).toBeLessThan(2000);
// => First Contentful Paint < 2 seconds
});
// => End of block
test("measure resource loading", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com");
// => Navigates to URL and waits
const resourceTimings = await page.evaluate(() => {
// => resourceTimings: awaits async operation
const resources = performance.getEntriesByType("resource");
// => resources: assigned value
return resources.map((resource) => ({
// => Returns result
name: resource.name,
// => name: property value
duration: resource.duration,
// => duration: property value
size: (resource as any).transferSize,
// => size: property value
}));
// => Statement executed
});
// => End of block
console.log(`Loaded ${resourceTimings.length} resources`);
// Find slow resources
const slowResources = resourceTimings.filter((r) => r.duration > 1000);
// => slowResources: assigned value
console.log("Slow resources (>1s):", slowResources);
// => console.log() called
expect(slowResources.length).toBe(0); // => No slow resources
// => Performance bottleneck detection
});
// => End of block
test("compare performance over time", async ({ page }) => {
// Baseline metrics
const baseline = {
// => baseline: assigned value
pageLoad: 2500,
// => pageLoad: property value
fcp: 1800,
// => fcp: property value
};
// => End of block
await page.goto("https://example.com");
// => Navigates to URL and waits
await page.waitForLoadState("networkidle");
// => Waits for condition
const fcp = await page.evaluate(() => {
// => fcp: awaits async operation
const paint = performance.getEntriesByType("paint");
// => paint: assigned value
return paint.find((p) => p.name === "first-contentful-paint")?.startTime || 0;
// => Returns result
});
// => End of block
console.log(`FCP: ${fcp}ms (baseline: ${baseline.fcp}ms)`);
// Allow 10% performance regression
const threshold = baseline.fcp * 1.1;
// => threshold: assigned value
expect(fcp).toBeLessThan(threshold);
// => Detects performance regressions
});
// => End of blockKey Takeaway: Measure page load time, Core Web Vitals, and resource timings to catch performance regressions. Set thresholds and fail tests when metrics exceed acceptable limits.
Why It Matters: Performance regressions happen gradually without monitoring. Basic performance tests provide early warning - Performance tests can catch regressions before they reach production.
Example 81: Authentication Flows - Login Once Pattern
Login once pattern authenticates once and reuses session across tests. Dramatically reduces test execution time by avoiding repeated logins.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
A[Global Setup]
B[Login Once]
C[Save Auth State]
D[Test 1: Reuse State]
E[Test 2: Reuse State]
F[Test 3: Reuse State]
A --> B
B --> C
C --> D
C --> E
C --> F
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
style D fill:#CC78BC,stroke:#000,color:#fff
style E fill:#CC78BC,stroke:#000,color:#fff
style F fill:#CC78BC,stroke:#000,color:#fff
Global Setup (global-setup.ts):
import { chromium, FullConfig } from "@playwright/test";
// => Playwright imports
async function globalSetup(config: FullConfig) {
// => Statement executed
const browser = await chromium.launch();
// => browser: awaits async operation
const page = await browser.newPage();
// Login once
await page.goto("https://example.com/login");
// => Navigates to URL and waits
await page.getByLabel("Email").fill("test@example.com");
// => Fills input field
await page.getByLabel("Password").fill("SecurePassword123!");
// => Fills input field
await page.getByRole("button", { name: "Sign In" }).click();
// => Clicks element
await page.waitForURL("**/dashboard"); // => Wait for login redirect
// Save authentication state
await page.context().storageState({ path: "auth-state.json" });
// => Saves cookies, localStorage, sessionStorage
await browser.close();
// => Awaits async operation
console.log("Authentication state saved to auth-state.json");
// => console.log() called
}
// => End of block
export default globalSetup;
// => Statement executedPlaywright Config (playwright.config.ts):
import { defineConfig } from "@playwright/test";
// => Import dependencies
export default defineConfig({
// => Statement executed
globalSetup: require.resolve("./global-setup"),
// => Runs global setup before all tests
use: {
// => Statement executed
storageState: "auth-state.json",
// => All tests reuse saved authentication
},
// => Statement executed
});
// => End of blockTests Using Saved Auth:
import { test, expect } from "@playwright/test";
// All tests automatically authenticated
test("access protected page", async ({ page }) => {
// => Already logged in via storage state
await page.goto("https://example.com/dashboard");
// => Awaits async operation
const welcomeMessage = page.getByTestId("welcome-message");
// => welcomeMessage: variable defined
await expect(welcomeMessage).toBeVisible(); // => Access granted
// => No login needed
});
// => End of block
test("access user profile", async ({ page }) => {
// => Authentication state reused
await page.goto("https://example.com/profile");
// => Awaits async operation
const emailDisplay = page.getByText("test@example.com");
// => emailDisplay: variable defined
await expect(emailDisplay).toBeVisible(); // => User info shown
// => Session persisted across tests
});
// Tests without authentication (override storage state)
test.use({ storageState: undefined }); // => Clear storage state
// => Statement executed
test("login page accessible when not authenticated", async ({ page }) => {
// => No authentication state
await page.goto("https://example.com/dashboard");
// => Awaits async operation
await expect(page).toHaveURL(/.*login/); // => Redirected to login
// => Unauthenticated behavior tested
});
// => End of blockPerformance Impact:
// Without login once:
// 50 tests × 5 seconds login each = 250 seconds overhead
// With login once:
// 1 login × 5 seconds = 5 seconds overhead
// => 245 seconds saved (significant reduction)Key Takeaway: Login once pattern authenticates in global setup and saves session state. All tests reuse saved state instead of logging in repeatedly, drastically reducing execution time.
Why It Matters: Repeated logins waste time and increase failure points. Login once significantly reduces test suite execution time - The login once pattern can significantly reduce test suite time.
Example 82: Test Data Management Strategies
Test data management isolates test data to prevent interference between tests. Strategies include fixtures, factories, and cleanup hooks.
Code:
import { test, expect } from "@playwright/test";
// Strategy 1: Test-scoped fixtures create fresh data per test
type TestFixtures = {
// => TestFixtures: TypeScript fixture type definition
testUser: { id: string; email: string; password: string };
// => testUser: property value
};
// => End of block
const test = base.extend<TestFixtures>({
// => test: assigned value
testUser: async ({ page }, use) => {
// Create unique user for this test
const userId = `user-${Date.now()}-${Math.random()}`;
// => userId: assigned value
const user = {
// => user: assigned value
id: userId,
// => id: property value
email: `${userId}@example.com`,
// => email: property value
password: "TestPassword123!",
// => password: property value
};
// Create user via API
await page.request.post("/api/users", { data: user });
// => User created
await use(user); // => Provide to test
// Cleanup: delete user
await page.request.delete(`/api/users/${user.id}`);
// => User deleted after test
},
// => Statement executed
});
// => End of block
test("test with isolated user data", async ({ page, testUser }) => {
// => Fresh user for this test only
await page.goto("/login");
// => Navigates to URL and waits
await page.getByLabel("Email").fill(testUser.email);
// => Fills input field
await page.getByLabel("Password").fill(testUser.password);
// => Fills input field
await page.getByRole("button", { name: "Sign In" }).click();
// => Clicks element
await expect(page.getByText(testUser.email)).toBeVisible();
// => Test uses isolated data
});
// Strategy 2: Data factories generate test data
class UserFactory {
// => UserFactory class definition
static create(overrides = {}) {
// => Statement executed
const id = `user-${Date.now()}-${Math.random()}`;
// => id: assigned value
return {
// => Returns result
id,
// => Statement executed
name: "Test User",
// => name: property value
email: `${id}@example.com`,
// => email: property value
role: "user",
// => role: property value
createdAt: new Date().toISOString(),
// => createdAt: property value
...overrides, // => Override defaults
// => Statement executed
};
// => End of block
}
// => End of block
static createAdmin(overrides = {}) {
// => Statement executed
return UserFactory.create({ role: "admin", ...overrides });
// => Factory with preset
}
// => End of block
}
// => End of block
test("test with factory-generated data", async ({ page }) => {
// => Test case or suite
const user = UserFactory.create({ name: "Alice" });
// => Generate test data
// Use data in test
await page.request.post("/api/users", { data: user });
// => Awaits async operation
await page.goto("/users");
// => Navigates to URL and waits
const userRow = page.getByRole("row", { name: user.name });
// => userRow: assigned value
await expect(userRow).toBeVisible();
// Cleanup
await page.request.delete(`/api/users/${user.id}`);
// => Awaits async operation
});
// Strategy 3: Cleanup tracking
test.describe("automatic cleanup", () => {
// => Test case or suite
const createdResources: Array<{ type: string; id: string }> = [];
// => createdResources: assigned value
test.afterEach(async ({ page }) => {
// Cleanup all resources created during test
for (const resource of createdResources) {
// => Iteration
await page.request.delete(`/api/${resource.type}/${resource.id}`);
// => Awaits async operation
console.log(`Cleaned up ${resource.type}/${resource.id}`);
// => console.log() called
}
// => End of block
createdResources.length = 0; // => Clear tracking
// => Statement executed
});
// => End of block
test("test with tracked cleanup", async ({ page }) => {
// Create user
const user = UserFactory.create();
// => user: assigned value
await page.request.post("/api/users", { data: user });
// => Awaits async operation
createdResources.push({ type: "users", id: user.id }); // => Track for cleanup
// Create order
const order = { id: "order-123", userId: user.id, total: 100 };
// => order: assigned value
await page.request.post("/api/orders", { data: order });
// => Awaits async operation
createdResources.push({ type: "orders", id: order.id }); // => Track for cleanup
// Test actions...
// Cleanup happens automatically in afterEach
});
// => End of block
});
// Strategy 4: Database seeding with known state
test.describe("with database seed", () => {
// => Test case or suite
test.beforeAll(async ({ request }) => {
// Seed database with known state
await request.post("/api/test/seed", {
// => Awaits async operation
data: {
// => data: {
users: [
// => users: property value
{ id: "user-1", name: "Alice", email: "alice@example.com" },
// => Statement executed
{ id: "user-2", name: "Bob", email: "bob@example.com" },
// => Statement executed
],
// => Statement executed
products: [{ id: "product-1", name: "Widget", price: 10 }],
// => products: property value
},
// => Statement executed
});
// => Database populated with test data
});
// => End of block
test.afterAll(async ({ request }) => {
// Clean up seeded data
await request.post("/api/test/reset");
// => Database reset to clean state
});
// => End of block
test("test with seeded data", async ({ page }) => {
// Known data available
await page.goto("/users");
// => Navigates to URL and waits
await expect(page.getByText("Alice")).toBeVisible();
// => Asserts condition
await expect(page.getByText("Bob")).toBeVisible();
// => Predictable data state
});
// => End of block
});
// => End of blockKey Takeaway: Manage test data through fixtures for isolation, factories for generation, cleanup tracking for automatic removal, and database seeding for known state. Prevents test pollution and ensures reliability.
Why It Matters: Shared test data causes flaky tests through race conditions and state pollution. Isolated data management ensures test independence - Unique test data per test significantly reduces test flakiness.
Example 83: Environment Configuration Management
Environment configuration manages different settings for local, staging, and production environments. Use environment variables and config files.
Trade-off comparison: Playwright's built-in process.env handles environment variables natively without any packages, and CI tools (GitHub Actions secrets, Vercel environment variables) inject them directly. This example demonstrates dotenv to show the common pattern of loading .env files for local development convenience—Playwright also supports this natively via testInfo.project.env and the dotenv integration in playwright.config.ts. Use the built-in approach for CI; use dotenv only if you need a .env file workflow locally. Install: npm install dotenv.
Code:
import { test, expect } from "@playwright/test";
// => Playwright imports
import * as dotenv from "dotenv";
// Load environment variables
dotenv.config(); // => Reads .env file
// Environment configuration
const config = {
// => config: assigned value
baseURL: process.env.BASE_URL || "http://localhost:3000",
// => baseURL: property value
apiURL: process.env.API_URL || "http://localhost:3001",
// => apiURL: property value
testUser: {
// => testUser: {
email: process.env.TEST_USER_EMAIL || "test@example.com",
// => email: property value
password: process.env.TEST_USER_PASSWORD || "password",
// => password: property value
},
// => Statement executed
timeout: parseInt(process.env.TEST_TIMEOUT || "30000"),
// => timeout: property value
};
// Configure in playwright.config.ts
test.use({
// => test.use() called
baseURL: config.baseURL, // => All page.goto() calls use this base
// => baseURL: property value
});
// => End of block
test("test with environment config", async ({ page }) => {
// Uses baseURL from config
await page.goto("/login"); // => Resolves to config.baseURL/login
// => Navigates to URL and waits
await page.getByLabel("Email").fill(config.testUser.email);
// => Fills input field
await page.getByLabel("Password").fill(config.testUser.password);
// => Fills input field
await page.getByRole("button", { name: "Sign In" }).click();
// => Clicks element
await expect(page).toHaveURL(/.*dashboard/);
// => Works across environments
});
// => End of block
test("API request with environment URL", async ({ request }) => {
// => Test case or suite
const response = await request.get(`${config.apiURL}/api/health`);
// => Uses API URL from config
expect(response.ok()).toBeTruthy();
// => Health check across environments
});
// Environment-specific test configuration
test.describe("staging-only tests", () => {
// => Test case or suite
test.skip(config.baseURL.includes("localhost"), "Staging-only test");
// => Skip on local environment
test("test feature flags in staging", async ({ page }) => {
// => Test case or suite
await page.goto("/experimental-feature");
// => Only runs in staging/production
});
// => End of block
});
// => End of block
test.describe("production-only tests", () => {
// => Test case or suite
test.skip(!config.baseURL.includes("production.example.com"), "Production-only");
// => test.skip() called
test("verify production performance", async ({ page }) => {
// => Test case or suite
const startTime = Date.now();
// => startTime: assigned value
await page.goto("/");
// => Navigates to URL and waits
const loadTime = Date.now() - startTime;
// => loadTime: assigned value
expect(loadTime).toBeLessThan(2000); // => Stricter SLA in production
// => expect() function defined
});
// => End of block
});
// => End of blockEnvironment Files:
.env.local (local development):
BASE_URL=http://localhost:3000
API_URL=http://localhost:3001
TEST_USER_EMAIL=dev@example.com
TEST_USER_PASSWORD=dev123
TEST_TIMEOUT=30000.env.staging (staging environment):
BASE_URL=https://staging.example.com
API_URL=https://api-staging.example.com
TEST_USER_EMAIL=staging@example.com
TEST_USER_PASSWORD=staging_secure_password
TEST_TIMEOUT=60000.env.production (production - usually in CI secrets):
BASE_URL=https://example.com
API_URL=https://api.example.com
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=production_secure_password
TEST_TIMEOUT=120000Loading Environment-Specific Config:
# Local
npm test
# Staging
dotenv -e .env.staging -- npx playwright test
# Production (CI uses secrets)
npx playwright testKey Takeaway: Manage environment configuration through environment variables and .env files. Configure baseURL, API endpoints, credentials, and timeouts per environment for portable tests.
Why It Matters: Hardcoded URLs and credentials break across environments and expose secrets. Environment configuration enables running same tests across local, staging, and production - Vercel's Playwright tests use environment variables to run identical test suites across 10+ preview environments.
Example 84: Error Handling and Retry Patterns
Error handling patterns make tests resilient to transient failures. Use retries, waits, and error recovery strategies.
Code:
import { test, expect } from "@playwright/test";
// Configure retries in playwright.config.ts:
// retries: 2 => Retry failed tests 2 times
test("test with built-in retries", async ({ page }) => {
// Automatically retried up to 2 times on failure
await page.goto("https://example.com");
// => Navigates to URL and waits
await expect(page.getByRole("heading")).toBeVisible();
// => Retries on timeout or assertion failure
});
// => End of block
test("custom retry logic", async ({ page }) => {
// Retry specific action with custom logic
async function clickWithRetry(locator: Locator, maxRetries = 3) {
// => Statement executed
for (let attempt = 1; attempt <= maxRetries; attempt++) {
// => Iteration
try {
// => Statement executed
await locator.click({ timeout: 5000 }); // => Try click
// => Clicks element
return; // => Success, exit
// => Statement executed
} catch (error) {
// => Statement executed
if (attempt === maxRetries) {
// => Conditional check
throw error; // => Final attempt failed, throw
// => Statement executed
}
// => End of block
console.log(`Click failed (attempt ${attempt}), retrying...`);
// => console.log() called
await page.waitForTimeout(1000); // => Wait before retry
// => Waits for condition
}
// => End of block
}
// => End of block
}
// => End of block
await page.goto("https://example.com");
// => Navigates to URL and waits
const submitButton = page.getByRole("button", { name: "Submit" });
// => submitButton: assigned value
await clickWithRetry(submitButton); // => Retry up to 3 times
// => Awaits async operation
});
// => End of block
test("graceful degradation on error", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com");
// Try primary action, fall back to alternative
try {
// => Statement executed
await page.getByRole("button", { name: "Primary Action" }).click({ timeout: 3000 });
// => Try primary path
} catch (error) {
// => Statement executed
console.log("Primary action failed, trying alternative");
// => console.log() called
await page.getByRole("button", { name: "Alternative Action" }).click();
// => Fallback to alternative
}
// Continue test regardless of which path succeeded
await expect(page.getByText("Action Completed")).toBeVisible();
// => Asserts condition
});
// => End of block
test("conditional waits for dynamic content", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com/dynamic");
// Wait for loading indicator to disappear
const loadingSpinner = page.getByTestId("loading");
// => loadingSpinner: assigned value
await loadingSpinner.waitFor({ state: "hidden", timeout: 10000 });
// => Waits up to 10 seconds for loading to complete
// Or wait for content to appear
await page.waitForSelector(".content", { state: "visible", timeout: 10000 });
// => Waits for content before proceeding
});
// => End of block
test("network error recovery", async ({ page }) => {
// Handle network failures gracefully
page.on("pageerror", (error) => {
// => page.on() called
console.log(`Page error: ${error.message}`);
// => Log errors without failing test
});
// => End of block
page.on("requestfailed", (request) => {
// => page.on() called
console.log(`Request failed: ${request.url()}`);
// => Log failed requests
});
await page.goto("https://example.com");
// Test continues despite non-critical errors
await expect(page.getByRole("heading")).toBeVisible();
// => Asserts condition
});
test("soft assertions for partial validation", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com/dashboard");
// Soft assertions don't immediately fail test
await expect.soft(page.getByTestId("widget-1")).toBeVisible();
// => Continues even if fails
await expect.soft(page.getByTestId("widget-2")).toBeVisible();
// => Asserts condition
await expect.soft(page.getByTestId("widget-3")).toBeVisible();
// => All assertions checked
// Test fails at end if any soft assertion failed
// => Collects all failures instead of stopping at first
});
test("timeout configuration per action", async ({ page }) => {
// => Test case or suite
await page.goto("https://example.com");
// Short timeout for fast actions
await page.getByRole("button", { name: "Quick Action" }).click({ timeout: 3000 });
// => 3 second timeout
// Long timeout for slow operations
await page.getByText("Processing Complete").waitFor({ timeout: 60000 });
// => 60 second timeout for slow backend operation
// Default timeout from config used if not specified
await expect(page.getByRole("heading")).toBeVisible();
// => Uses default timeout (usually 30s)
});Key Takeaway: Handle errors gracefully through retries, fallback logic, conditional waits, and soft assertions. Configure timeouts per action based on expected duration.
Why It Matters: Transient failures (network hiccups, timing issues) cause flaky tests. Proper error handling reduces flakiness without masking real bugs - Retry logic and soft assertions can maintain high test reliability despite external dependencies.
Example 85: Reporting and Metrics Collection
Reporting provides visibility into test results, trends, and coverage. Use built-in reporters and custom metrics collection.
Playwright Config (playwright.config.ts):
import { defineConfig } from "@playwright/test";
// => Playwright imports
export default defineConfig({
// => Statement executed
reporter: [
// => reporter: property value
["html", { outputFolder: "playwright-report", open: "never" }],
// => HTML report with charts and traces
["json", { outputFile: "test-results/results.json" }],
// => JSON report for custom processing
["junit", { outputFile: "test-results/junit.xml" }],
// => JUnit XML for CI integration
["list"], // => Terminal output during execution
// => Statement executed
],
// => Statement executed
});
// => End of blockCode:
import { test, expect } from "@playwright/test";
// => Import dependencies
test("test with custom metrics", async ({ page }, testInfo) => {
// => Function call result
const startTime = Date.now();
// => startTime: variable defined
await page.goto("https://example.com");
// => Awaits async operation
const navigationTime = Date.now() - startTime;
// Attach custom metrics to test
testInfo.annotations.push({
// => Statement executed
type: "metric",
// => Statement executed
description: `Navigation time: ${navigationTime}ms`,
// => Statement executed
});
// => End of block
await expect(page.getByRole("heading")).toBeVisible();
// => Awaits async operation
const totalTime = Date.now() - startTime;
// => totalTime: variable defined
testInfo.annotations.push({
// => Statement executed
type: "metric",
// => Statement executed
description: `Total time: ${totalTime}ms`,
// => Statement executed
});
// => Metrics available in reports
});
// => End of block
test("test with screenshot evidence", async ({ page }, testInfo) => {
// => Function call result
await page.goto("https://example.com");
// Attach screenshot to report
const screenshot = await page.screenshot();
// => screenshot: variable defined
await testInfo.attach("page-screenshot", {
// => Awaits async operation
body: screenshot,
// => Statement executed
contentType: "image/png",
// => Statement executed
});
// => Screenshot embedded in HTML report
await expect(page.getByRole("heading")).toBeVisible();
// => Awaits async operation
});
// => End of block
test("test with trace attachment", async ({ page }, testInfo) => {
// Attach trace for debugging
await page.context().tracing.start({ screenshots: true, snapshots: true });
// => Awaits async operation
await page.goto("https://example.com");
// => Awaits async operation
await page.getByRole("button", { name: "Action" }).click();
// => Awaits async operation
const tracePath = "traces/" + testInfo.title.replace(/\s/g, "-") + ".zip";
// => tracePath: variable defined
await page.context().tracing.stop({ path: tracePath });
// Attach trace to report
await testInfo.attach("trace", { path: tracePath });
// => Trace available in HTML report
});
// Custom reporter for metrics aggregation
class MetricsReporter {
// => Statement executed
onTestEnd(test: any, result: any) {
// Extract custom metrics
const metrics = result.annotations.filter((a: any) => a.type === "metric").map((a: any) => a.description);
// => metrics: variable defined
if (metrics.length > 0) {
// => Function call result
console.log(`Test: ${test.title}`);
// => Statement executed
console.log(`Metrics: ${metrics.join(", ")}`);
// => Statement executed
}
// => End of block
}
// => End of block
onEnd(result: any) {
// Aggregate results
const total = result.allTests().length;
// => total: variable defined
const passed = result.allTests().filter((t: any) => t.outcome() === "expected").length;
// => passed: variable defined
const failed = total - passed;
// => failed: variable defined
const passRate = ((passed / total) * 100).toFixed(2);
// => passRate: variable defined
console.log("\n=== Test Summary ===");
// => Statement executed
console.log(`Total: ${total}`);
// => Statement executed
console.log(`Passed: ${passed}`);
// => Statement executed
console.log(`Failed: ${failed}`);
// => Statement executed
console.log(`Pass Rate: ${passRate}%`);
// => Statement executed
}
// => End of block
}
// Use custom reporter in config:
// reporter: [['list'], ['./metrics-reporter.ts']]Analyzing Reports:
# Open HTML report
npx playwright show-report
# Features:
# - Test results with pass/fail status
# - Screenshots and videos for failures
# - Trace viewer integration
# - Test duration and timing
# - Retry information
# - Custom attachments and annotationsCI Integration with JUnit:
# GitHub Actions with test reporting
- name: Run Playwright tests
run: npx playwright test
- name: Publish Test Results
uses: dorny/test-reporter@v1
if: always()
with:
name: Playwright Tests
path: test-results/junit.xml
reporter: java-junit
# => Test results visible in GitHub Actions UIKey Takeaway: Use built-in reporters (HTML, JSON, JUnit) for test visibility and CI integration. Attach screenshots, traces, and custom metrics to reports for comprehensive test evidence.
Why It Matters: Test results without reporting provide no visibility into failures, trends, or coverage. Comprehensive reporting enables data-driven quality decisions - Custom reporters can track test performance trends and catch regressions.
This completes the advanced level (Examples 61-85) covering production Playwright patterns including advanced POM, custom fixtures, visual regression, network mocking, debugging tools, CI/CD integration, parallel execution, authentication flows, and production testing strategies. Total coverage: 75-95% of Playwright capabilities needed for professional web testing.
Skill Mastery: Developers completing this advanced section can implement enterprise-scale Playwright test suites with production-ready patterns, efficient CI/CD integration, and comprehensive debugging capabilities.
Last updated February 1, 2026