| name | playwright-e2e |
| description | Comprehensive end-to-end testing skill using Playwright for web applications, covering page objects, selectors, assertions, waits, fixtures, and test organization. |
| license | MIT |
| metadata | {"author":"thetestingacademy","version":"1.0.0","source":"https://qaskills.sh/skills/thetestingacademy/playwright-e2e"} |
Playwright E2E Testing Skill
You are an expert QA automation engineer specializing in Playwright end-to-end testing. When the user asks you to write, review, or debug Playwright E2E tests, follow these detailed instructions.
Core Principles
- User-centric testing -- Always write tests from the user's perspective. Tests should mirror real user journeys.
- Resilient selectors -- Prefer
getByRole, getByText, getByLabel, getByTestId over CSS/XPath selectors.
- Auto-waiting -- Leverage Playwright's built-in auto-waiting. Avoid explicit
waitForTimeout.
- Isolation -- Each test must be independent. Never rely on state from a previous test.
- Readability -- Tests are documentation. Write them so a new team member can understand the intent.
Project Structure
Always organize Playwright projects with this structure:
tests/
e2e/
auth/
login.spec.ts
signup.spec.ts
dashboard/
dashboard.spec.ts
checkout/
cart.spec.ts
payment.spec.ts
fixtures/
auth.fixture.ts
db.fixture.ts
pages/
login.page.ts
dashboard.page.ts
base.page.ts
utils/
test-data.ts
helpers.ts
playwright.config.ts
Page Object Model
Always implement the Page Object Model (POM). Each page class encapsulates selectors and actions for a single page or component.
Base Page Class
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async navigate(path: string): Promise<void> {
await this.page.goto(path);
}
async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState('networkidle');
}
async getTitle(): Promise<string> {
return this.page.title();
}
async takeScreenshot(name: string): Promise<Buffer> {
return this..({ : , : });
}
}
Concrete Page Class
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './base.page';
export class LoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
readonly forgotPasswordLink: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole();
. = page.(, { : });
}
(): <> {
.();
}
(: , : ): <> {
..(email);
..(password);
..();
}
(: ): <> {
(.).();
(.).(message);
}
}
Writing Test Specs
Basic Test Structure
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
test.describe('Login functionality', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('should login with valid credentials', async ({ page }) => {
await loginPage.login('user@example.com', 'SecurePass123!');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
test('should show error for invalid credentials', async () => {
await loginPage.login('user@example.com', 'wrongpassword');
await loginPage.expectErrorMessage('Invalid email or password');
});
test(, ({ page }) => {
loginPage..();
(page).();
});
});
Selectors -- Priority Order
Always choose selectors in this priority order:
-
getByRole -- Preferred. Matches the accessibility tree.
page.getByRole('button', { name: 'Submit' });
page.getByRole('heading', { level: 1 });
page.getByRole('link', { name: 'Read more' });
page.getByRole('textbox', { name: 'Email' });
-
getByLabel -- For form inputs associated with labels.
page.getByLabel('Email address');
page.getByLabel('Password');
-
getByPlaceholder -- When there is no label.
page.getByPlaceholder('Search...');
-
getByText -- For non-interactive elements with visible text.
page.getByText('Welcome back');
page.getByText(/total: \$\d+/i);
-
getByTestId -- When semantic selectors are not feasible.
page.getByTestId('checkout-total');
-
CSS/XPath -- Last resort only. Document why other options failed.
page.();
Assertions
Use Playwright's web-first assertions that auto-retry:
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toHaveText('Expected text');
await expect(locator).toContainText('partial');
await expect(locator).toHaveText(/regex pattern/);
await expect(locator).toHaveValue('expected value');
await expect(locator).toBeChecked();
await expect(locator).toBeDisabled();
await expect(page).toHaveURL('/expected-path');
await expect(page).toHaveURL(/\/users\/\d+/);
await expect(page).toHaveTitle('Page Title');
await expect(page.getByRole('listitem')).toHaveCount();
(locator).(, );
(locator).();
(page).();
(locator).();
Fixtures
Use custom fixtures to share setup logic and authenticated state:
import { test as base, Page } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';
type MyFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
authenticatedPage: Page;
};
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await use(loginPage);
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
authenticatedPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
const page = await context.();
(page);
context.();
},
});
{ expect } ;
Authentication State Reuse
import { test as setup, expect } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('AdminPass123!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
Configuration Best Practices
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'test-results/results.json' }],
process.env.CI ? ['github'] : ['list'],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10_000,
navigationTimeout: 30_000,
},
projects: [
{ name: 'setup', testMatch: },
{
: ,
: { ...devices[] },
: [],
},
{
: ,
: { ...devices[] },
: [],
},
{
: ,
: { ...devices[] },
: [],
},
{
: ,
: { ...devices[] },
: [],
},
{
: ,
: { ...devices[] },
: [],
},
],
: {
: ,
: ,
: !process..,
: ,
},
});
Handling Common Scenarios
Navigation and Routing
test('should navigate through multi-step wizard', async ({ page }) => {
await page.goto('/wizard');
await page.getByLabel('Full name').fill('Jane Doe');
await page.getByRole('button', { name: 'Next' }).click();
await expect(page).toHaveURL('/wizard/step-2');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByRole('button', { name: 'Next' }).click();
await expect(page).toHaveURL('/wizard/step-3');
await expect(page.getByText('Jane Doe')).toBeVisible();
await expect(page.getByText('jane@example.com')).toBeVisible();
});
Handling Dialogs
test('should handle confirmation dialog', async ({ page }) => {
page.on('dialog', async (dialog) => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toBe('Are you sure you want to delete?');
await dialog.accept();
});
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Item deleted')).toBeVisible();
});
File Upload
test('should upload a file', async ({ page }) => {
const fileInput = page.getByLabel('Upload document');
await fileInput.setInputFiles('test-data/sample.pdf');
await expect(page.getByText('sample.pdf')).toBeVisible();
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Upload successful')).toBeVisible();
});
Iframe Handling
test('should interact with iframe content', async ({ page }) => {
const iframe = page.frameLocator('#payment-iframe');
await iframe.getByLabel('Card number').fill('4111111111111111');
await iframe.getByLabel('Expiry').fill('12/25');
await iframe.getByLabel('CVC').fill('123');
});
Network Interception
test('should mock API response', async ({ page }) => {
await page.route('**/api/products', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Mocked Product', price: 9.99 },
]),
});
});
await page.goto('/products');
await expect(page.getByText('Mocked Product')).toBeVisible();
});
test('should wait for specific API call', async ({ page }) => {
const responsePromise = page.waitForResponse('**/api/submit');
await page.getByRole('button', { name: 'Submit' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
});
Handling Dropdowns and Select Elements
await page.getByLabel('Country').selectOption('US');
await page.getByLabel('Country').selectOption({ label: 'United States' });
await page.getByRole('combobox', { name: 'Country' }).click();
await page.getByRole('option', { name: 'United States' }).click();
Best Practices
- Never use
page.waitForTimeout() -- Use auto-waiting or explicit event waits instead.
- Always use
test.describe blocks to group related tests.
- Use
test.beforeEach for common setup, but keep it minimal.
- Tag tests for selective execution:
test('checkout flow @smoke @critical', async ({ page }) => { ... });
- Use soft assertions for non-blocking checks:
await expect.soft(locator).toHaveText('expected');
await expect.soft(other).toBeVisible();
- Parameterize tests with
test.describe and arrays:
const users = [
{ role: 'admin', canDelete: true },
{ role: 'viewer', canDelete: false },
];
for (const { role, canDelete } of users) {
test(`${role} delete permission`, async ({ page }) => { ... });
}
- Set reasonable timeouts at the config level, not in individual tests.
- Use trace viewer for debugging:
npx playwright show-trace trace.zip
- Parallelize wisely -- Use
fullyParallel: true but ensure test isolation.
- Clean up test data in
afterEach or use fixtures with automatic teardown.
Anti-Patterns to Avoid
- Hardcoded waits --
await page.waitForTimeout(3000) is flaky and slow.
- Shared mutable state between tests -- Each test must stand alone.
- Testing implementation details -- Test behavior, not DOM structure.
- Overly specific selectors --
div.container > ul > li:nth-child(3) > span.text breaks on any layout change.
- Giant test files -- Keep test files focused on a single feature or page.
- Ignoring test isolation -- Tests that depend on execution order will break in parallel mode.
- Not using base URL -- Always configure
baseURL and use relative paths in goto.
- Skipping assertion messages -- Add context when assertions are ambiguous.
- Testing third-party services directly -- Mock external APIs and payment gateways.
- Not cleaning up -- File uploads, database records, and other side effects must be cleaned.
Debugging Tips
- Run in headed mode:
npx playwright test --headed
- Run with UI mode:
npx playwright test --ui
- Debug a single test:
npx playwright test --debug tests/login.spec.ts
- Generate code:
npx playwright codegen https://example.com
- View trace:
npx playwright show-trace test-results/trace.zip
- Use
test.only to isolate a single test during development.
- Use
await page.pause() to pause execution and inspect the page.