E2E Test Writer Skill
Purpose
This skill provides comprehensive guidance for writing end-to-end tests using Playwright, following best practices including page object model pattern, proper test isolation, and maintainable test architecture.
When to Use
-
Implementing E2E tests for new features
-
Testing user workflows and journeys
-
Validating browser interactions
-
Testing responsive design across devices
-
Regression testing after changes
-
CI/CD test automation
E2E Testing Workflow
- Setup Playwright Project
Initialize Playwright:
Install Playwright
npm init playwright@latest
Or add to existing project
npm install -D @playwright/test npx playwright install
Project Structure:
tests/ ├── e2e/ │ ├── auth/ │ ├── features/ │ └── workflows/ ├── pages/ │ ├── BasePage.ts │ ├── LoginPage.ts │ └── DashboardPage.ts ├── fixtures/ │ ├── test-data.ts │ └── custom-fixtures.ts ├── utils/ │ ├── helpers.ts │ └── constants.ts └── playwright.config.ts
Playwright Configuration:
// playwright.config.ts 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'], ['json', { outputFile: 'test-results/results.json' }], ['junit', { outputFile: 'test-results/junit.xml' }], ], use: { baseURL: process.env.BASE_URL || 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, }, { name: 'mobile-chrome', use: { ...devices['Pixel 5'] }, }, { name: 'mobile-safari', use: { ...devices['iPhone 12'] }, }, ], webServer: { command: 'npm run start', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, });
Deliverable: Playwright project configured and ready
- Page Object Model Pattern
Base Page Class:
// pages/BasePage.ts import { Page, Locator } from '@playwright/test';
export class BasePage { readonly page: Page;
constructor(page: Page) { this.page = page; }
async goto(path: string) { await this.page.goto(path); }
async waitForPageLoad() { await this.page.waitForLoadState('networkidle'); }
async takeScreenshot(name: string) {
await this.page.screenshot({ path: screenshots/${name}.png });
}
async getTitle(): Promise<string> { return await this.page.title(); }
async clickElement(locator: Locator) { await locator.waitFor({ state: 'visible' }); await locator.click(); }
async fillInput(locator: Locator, value: string) { await locator.waitFor({ state: 'visible' }); await locator.fill(value); }
async getText(locator: Locator): Promise<string> { await locator.waitFor({ state: 'visible' }); return await locator.textContent() || ''; } }
Example Page Object:
// pages/LoginPage.ts import { Page, Locator } from '@playwright/test'; import { BasePage } from './BasePage';
export class LoginPage extends BasePage { // Locators readonly usernameInput: Locator; readonly passwordInput: Locator; readonly submitButton: Locator; readonly errorMessage: Locator; readonly forgotPasswordLink: Locator; readonly signUpLink: Locator;
constructor(page: Page) { super(page); this.usernameInput = page.locator('#username'); this.passwordInput = page.locator('#password'); this.submitButton = page.locator('button[type="submit"]'); this.errorMessage = page.locator('.error-message'); this.forgotPasswordLink = page.locator('a[href="/forgot-password"]'); this.signUpLink = page.locator('a[href="/signup"]'); }
// Actions async goto() { await super.goto('/login'); }
async login(username: string, password: string) { await this.fillInput(this.usernameInput, username); await this.fillInput(this.passwordInput, password); await this.clickElement(this.submitButton); }
async clickForgotPassword() { await this.clickElement(this.forgotPasswordLink); }
async clickSignUp() { await this.clickElement(this.signUpLink); }
// Assertions helpers async getErrorMessage(): Promise<string> { return await this.getText(this.errorMessage); }
async isLoginButtonEnabled(): Promise<boolean> { return await this.submitButton.isEnabled(); }
async waitForErrorMessage() { await this.errorMessage.waitFor({ state: 'visible' }); } }
Deliverable: Page objects for all application pages
- Writing Test Cases
Basic Test Structure:
// tests/e2e/auth/login.spec.ts import { test, expect } from '@playwright/test'; import { LoginPage } from '../../pages/LoginPage'; import { DashboardPage } from '../../pages/DashboardPage';
test.describe('User Login', () => { let loginPage: LoginPage;
test.beforeEach(async ({ page }) => { loginPage = new LoginPage(page); await loginPage.goto(); });
test('successful login with valid credentials', async ({ page }) => { await loginPage.login('user@example.com', 'ValidPassword123!');
const dashboardPage = new DashboardPage(page);
await expect(page).toHaveURL('/dashboard');
await expect(dashboardPage.welcomeMessage).toContainText('Welcome back');
});
test('failed login with invalid credentials shows error', async ({ page }) => { await loginPage.login('user@example.com', 'WrongPassword');
await loginPage.waitForErrorMessage();
const errorMsg = await loginPage.getErrorMessage();
expect(errorMsg).toContain('Invalid username or password');
await expect(page).toHaveURL('/login');
});
test('login button disabled with empty fields', async ({ page }) => { const isEnabled = await loginPage.isLoginButtonEnabled(); expect(isEnabled).toBe(false); });
test('forgot password link navigates correctly', async ({ page }) => { await loginPage.clickForgotPassword(); await expect(page).toHaveURL('/forgot-password'); });
test('sign up link navigates correctly', async ({ page }) => { await loginPage.clickSignUp(); await expect(page).toHaveURL('/signup'); }); });
Testing User Workflows:
// tests/e2e/workflows/checkout.spec.ts import { test, expect } from '@playwright/test'; import { ProductPage } from '../../pages/ProductPage'; import { CartPage } from '../../pages/CartPage'; import { CheckoutPage } from '../../pages/CheckoutPage';
test.describe('Checkout Workflow', () => { test('complete purchase from product to confirmation', async ({ page }) => { // 1. Browse product const productPage = new ProductPage(page); await productPage.goto('/products/item-123'); await expect(productPage.productTitle).toBeVisible();
// 2. Add to cart
await productPage.addToCart();
await expect(productPage.cartBadge).toHaveText('1');
// 3. View cart
await productPage.goToCart();
const cartPage = new CartPage(page);
await expect(cartPage.cartItems).toHaveCount(1);
// 4. Proceed to checkout
await cartPage.proceedToCheckout();
const checkoutPage = new CheckoutPage(page);
// 5. Fill shipping info
await checkoutPage.fillShippingInfo({
name: 'John Doe',
address: '123 Main St',
city: 'San Francisco',
zip: '94102',
country: 'US',
});
// 6. Fill payment info
await checkoutPage.fillPaymentInfo({
cardNumber: '4242424242424242',
expiry: '12/25',
cvv: '123',
});
// 7. Submit order
await checkoutPage.submitOrder();
// 8. Verify confirmation
await expect(page).toHaveURL(/\/order-confirmation/);
await expect(page.locator('.success-message')).toContainText('Order placed successfully');
}); });
Deliverable: Comprehensive test suite covering user journeys
- Test Data Management
Test Fixtures:
// fixtures/test-data.ts export const testUsers = { validUser: { email: 'user@example.com', password: 'ValidPassword123!', }, adminUser: { email: 'admin@example.com', password: 'AdminPassword123!', }, newUser: { email: 'newuser@example.com', password: 'NewPassword123!', firstName: 'John', lastName: 'Doe', }, };
export const testProducts = { product1: { id: 'item-123', name: 'Test Product', price: 29.99, }, };
Custom Fixtures:
// fixtures/custom-fixtures.ts import { test as base } from '@playwright/test'; import { LoginPage } from '../pages/LoginPage'; import { DashboardPage } from '../pages/DashboardPage';
type MyFixtures = { loginPage: LoginPage; dashboardPage: DashboardPage; authenticatedPage: Page; };
export const test = base.extend<MyFixtures>({ loginPage: async ({ page }, use) => { const loginPage = new LoginPage(page); await use(loginPage); },
dashboardPage: async ({ page }, use) => { const dashboardPage = new DashboardPage(page); await use(dashboardPage); },
authenticatedPage: async ({ page }, use) => { // Auto-login before test const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('user@example.com', 'ValidPassword123!'); await page.waitForURL('/dashboard'); await use(page); }, });
export { expect } from '@playwright/test';
Using Custom Fixtures:
// tests/e2e/features/profile.spec.ts import { test, expect } from '../../fixtures/custom-fixtures';
test.describe('User Profile', () => { test('user can update profile information', async ({ authenticatedPage, dashboardPage }) => { // Already logged in via authenticatedPage fixture await dashboardPage.goToProfile();
// Test continues with authenticated context
await dashboardPage.updateProfile({
firstName: 'Jane',
lastName: 'Smith',
});
await expect(dashboardPage.profileName).toHaveText('Jane Smith');
}); });
Deliverable: Reusable test data and fixtures
- Responsive Design Testing
Test Multiple Viewports:
// tests/e2e/responsive/layout.spec.ts import { test, expect, devices } from '@playwright/test';
const viewports = [ { name: 'Desktop', device: devices['Desktop Chrome'] }, { name: 'Tablet', device: devices['iPad Pro'] }, { name: 'Mobile', device: devices['iPhone 12'] }, ];
viewports.forEach(({ name, device }) => {
test.describe(${name} Layout, () => {
test.use(device);
test('navigation menu displays correctly', async ({ page }) => {
await page.goto('/');
if (name === 'Mobile') {
// Mobile should show hamburger menu
await expect(page.locator('.hamburger-menu')).toBeVisible();
await expect(page.locator('.desktop-nav')).not.toBeVisible();
} else {
// Desktop/Tablet should show full navigation
await expect(page.locator('.desktop-nav')).toBeVisible();
await expect(page.locator('.hamburger-menu')).not.toBeVisible();
}
});
test('images are responsive', async ({ page }) => {
await page.goto('/products');
const images = page.locator('img');
const count = await images.count();
for (let i = 0; i < count; i++) {
const img = images.nth(i);
const bbox = await img.boundingBox();
if (bbox) {
// Images should not overflow viewport
expect(bbox.width).toBeLessThanOrEqual(device.viewport.width);
}
}
});
}); });
Deliverable: Tests covering responsive design
- Visual Regression Testing
Screenshot Comparison:
// tests/e2e/visual/snapshot.spec.ts import { test, expect } from '@playwright/test';
test.describe('Visual Regression', () => { test('homepage matches baseline', async ({ page }) => { await page.goto('/');
// Take full page screenshot
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixels: 100,
});
});
test('modal dialog matches baseline', async ({ page }) => { await page.goto('/'); await page.click('[data-testid="open-modal"]');
// Screenshot of specific element
const modal = page.locator('.modal');
await expect(modal).toHaveScreenshot('modal.png');
});
test('dark mode matches baseline', async ({ page }) => { await page.goto('/');
// Enable dark mode
await page.evaluate(() => {
document.documentElement.setAttribute('data-theme', 'dark');
});
await expect(page).toHaveScreenshot('homepage-dark.png', {
fullPage: true,
});
}); });
Deliverable: Visual regression test suite
- API Mocking and Network Testing
Mock API Responses:
// tests/e2e/network/api-mocking.spec.ts import { test, expect } from '@playwright/test';
test.describe('API Mocking', () => { test('handles slow API response gracefully', async ({ page }) => { // Mock slow API await page.route('**/api/products', async (route) => { await new Promise(resolve => setTimeout(resolve, 3000)); await route.fulfill({ status: 200, body: JSON.stringify({ products: [] }), }); });
await page.goto('/products');
// Should show loading state
await expect(page.locator('.loading-spinner')).toBeVisible();
// Should eventually show products
await expect(page.locator('.product-list')).toBeVisible({ timeout: 5000 });
});
test('handles API error gracefully', async ({ page }) => { // Mock API error await page.route('**/api/products', (route) => { route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal server error' }), }); });
await page.goto('/products');
// Should show error message
await expect(page.locator('.error-message')).toBeVisible();
await expect(page.locator('.error-message')).toContainText('Failed to load products');
});
test('handles network timeout', async ({ page }) => { await page.route('**/api/products', (route) => { // Never fulfill, causing timeout });
await page.goto('/products');
// Should show timeout message
await expect(page.locator('.timeout-message')).toBeVisible({ timeout: 10000 });
}); });
Deliverable: Tests for API interactions and error states
- Authentication State Management
Reuse Authentication State:
// tests/auth.setup.ts import { test as setup } from '@playwright/test'; import { LoginPage } from './pages/LoginPage';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('user@example.com', 'ValidPassword123!');
await page.waitForURL('/dashboard');
// Save authentication state await page.context().storageState({ path: authFile }); });
Use Saved Auth State:
// playwright.config.ts export default defineConfig({ // ... other config
projects: [ { name: 'setup', testMatch: /.*.setup.ts/, }, { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json', }, dependencies: ['setup'], }, ], });
Deliverable: Efficient authentication handling
Best Practices
Test Organization:
-
Group related tests with test.describe()
-
Use meaningful test names (describe behavior, not implementation)
-
One assertion per test (or closely related assertions)
-
Isolate tests (no dependencies between tests)
Locator Strategy:
-
Prefer test IDs: page.locator('[data-testid="submit"]')
-
Use semantic selectors: page.locator('button:has-text("Submit")')
-
Avoid CSS selectors tied to styling
-
Never use XPath unless absolutely necessary
Waiting Strategy:
-
Use auto-waiting (built into Playwright)
-
Avoid fixed waits (page.waitForTimeout() )
-
Use waitForLoadState() for page loads
-
Use waitFor() for specific elements
Error Handling:
-
Use try-catch for expected errors
-
Add screenshots on failure
-
Capture network logs
-
Record video on failure
Performance:
-
Run tests in parallel when possible
-
Use test.describe.configure({ mode: 'parallel' })
-
Share browser contexts when safe
-
Reuse authentication state
-
Mock slow external APIs
Maintainability:
-
Page Object Model for all pages
-
Extract common logic to helpers
-
Use constants for repeated values
-
Keep tests DRY (Don't Repeat Yourself)
-
Regular refactoring
Integration with Playwright MCP
Using MCP Tools:
// Example: Using Playwright MCP for navigation test('navigate using MCP', async ({ page }) => { // Use mcp__playwright-mcp__playwright_navigate await page.evaluate(async () => { // MCP navigation call would go here });
await expect(page).toHaveURL('/expected-page'); });
// Example: Using MCP for screenshots test('capture screenshot with MCP', async ({ page }) => { await page.goto('/');
// Use mcp__playwright-mcp__playwright_screenshot await page.evaluate(async () => { // MCP screenshot call would go here }); });
Remember
-
Test behavior, not implementation: Tests should survive refactoring
-
User perspective: Test what users do, not how code works
-
Isolation: Each test should run independently
-
Fast feedback: Keep tests fast (< 5 min total suite)
-
Flake-free: No intermittent failures
-
Clear failures: Easy to debug when tests fail
-
Maintainable: Easy to update when app changes
-
Comprehensive: Cover happy paths and edge cases
-
CI/CD ready: Tests run reliably in CI environment
Your goal is to create robust, maintainable E2E tests that provide confidence in application functionality across browsers and devices.