Playwright Setup and Configuration
Playwright Setup and Configuration
Installation
npm install -D @playwright/test
npx playwright install
npx playwright install-deps
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['junit', { outputFile: 'test-results/junit.xml' }],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10000,
navigationTimeout: 30000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});
Page Object Model
// e2e/pages/LoginPage.ts
import { Page, expect } from '@playwright/test';
export class LoginPage {
private emailInput = '[data-testid="email-input"]';
private passwordInput = '[data-testid="password-input"]';
private loginButton = '[data-testid="login-button"]';
private errorAlert = '[role="alert"]';
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.page.fill(this.emailInput, email);
await this.page.fill(this.passwordInput, password);
await this.page.click(this.loginButton);
}
async expectError(message: string) {
await expect(this.page.locator(this.errorAlert)).toContainText(message);
}
async expectLoggedIn() {
await expect(this.page).toHaveURL('/dashboard');
await expect(this.page.getByText('Welcome')).toBeVisible();
}
}
// e2e/pages/DashboardPage.ts
import { Page, expect } from '@playwright/test';
export class DashboardPage {
constructor(private page: Page) {}
async navigateToPosts() {
await this.page.click('[data-testid="nav-posts"]');
}
async getWelcomeMessage() {
return this.page.locator('[data-testid="welcome-msg"]').textContent();
}
async logout() {
await this.page.click('[data-testid="logout-button"]');
await expect(this.page).toHaveURL('/login');
}
}
Page Objects encapsulate selectors and page interactions, making tests readable and maintainable. When a selector changes, you update it in one place instead of across dozens of tests.
Writing E2E Tests
Writing E2E Tests
Authentication Flow
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test.describe('Authentication', () => {
test('should login with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await loginPage.expectLoggedIn();
});
test('should show error for invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('wrong@example.com', 'wrongpass');
await loginPage.expectError('Invalid credentials');
});
test('should redirect to login when accessing protected route', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL('/login?from=%2Fdashboard');
});
});
CRUD Operations
// e2e/posts.spec.ts
test.describe('Posts Management', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email-input"]', 'admin@example.com');
await page.fill('[data-testid="password-input"]', 'admin123');
await page.click('[data-testid="login-button"]');
await expect(page).toHaveURL('/dashboard');
});
test('should create a new post', async ({ page }) => {
await page.click('[data-testid="nav-posts"]');
await page.click('[data-testid="new-post-btn"]');
await page.fill('[data-testid="post-title"]', 'My E2E Test Post');
await page.fill('[data-testid="post-content"]', 'Content created during E2E testing.');
await page.click('[data-testid="save-post"]');
await expect(page.locator('[data-testid="success-toast"]')).toBeVisible();
await expect(page.getByText('My E2E Test Post')).toBeVisible();
});
test('should delete a post', async ({ page }) => {
await page.click('[data-testid="nav-posts"]');
await page.locator('[data-testid="post-row"]').first().click();
await page.click('[data-testid="delete-post"]');
await page.click('[data-testid="confirm-delete"]');
await expect(page.locator('[data-testid="success-toast"]')).toBeVisible();
});
});
Network Mocking and Assertions
// e2e/api-mocking.spec.ts
test('should show error when API fails', async ({ page }) => {
await page.route('**/api/posts', route => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
await page.goto('/posts');
await expect(page.getByText('Failed to load posts')).toBeVisible();
});
test('should mock post creation response', async ({ page }) => {
await page.route('**/api/posts', route => {
route.fulfill({
status: 201,
body: JSON.stringify({
id: 'mock-123',
title: 'Mocked Post',
createdAt: new Date().toISOString(),
}),
});
});
await page.goto('/posts/new');
await page.fill('[data-testid="post-title"]', 'Mocked Post');
await page.click('[data-testid="save-post"]');
await expect(page.getByText('Mocked Post')).toBeVisible();
});
Cypress Testing Approach
Cypress Testing Approach
Cypress Configuration
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.{js,ts}',
supportFile: 'cypress/support/e2e.js',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
retries: { runMode: 2, openMode: 0 },
env: { apiUrl: 'http://localhost:4000' },
setupNodeEvents(on, config) {
on('task', {
resetDb() {
return require('./scripts/reset-test-db')();
},
});
return config;
},
},
});
Custom Commands
// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.session([email, password], () => {
cy.visit('/login');
cy.get('[data-testid="email-input"]').type(email);
cy.get('[data-testid="password-input"]').type(password);
cy.get('[data-testid="login-button"]').click();
cy.url().should('include', '/dashboard');
});
});
Cypress.Commands.add('createPost', (title, content) => {
cy.intercept('POST', '/api/posts').as('createPost');
cy.visit('/posts/new');
cy.get('[data-testid="post-title"]').type(title);
cy.get('[data-testid="post-content"]').type(content);
cy.get('[data-testid="save-post"]').click();
cy.wait('@createPost').its('response.statusCode').should('eq', 201);
});
Cypress E2E Test
// cypress/e2e/dashboard.cy.js
describe('Dashboard', () => {
beforeEach(() => {
cy.login('admin@example.com', 'admin123');
cy.visit('/dashboard');
});
it('should display welcome message', () => {
cy.get('[data-testid="welcome-msg"]').should('contain', 'Welcome, Admin');
});
it('should navigate to posts list', () => {
cy.get('[data-testid="nav-posts"]').click();
cy.url().should('include', '/posts');
cy.get('[data-testid="post-list"]').should('be.visible');
});
it('should create and verify a new post', () => {
const title = `Test Post ${Date.now()}`;
cy.createPost(title, 'Automated test content');
cy.visit('/posts');
cy.get('[data-testid="post-list"]').should('contain', title);
});
it('should handle logout', () => {
cy.get('[data-testid="logout-button"]').click();
cy.url().should('include', '/login');
cy.visit('/dashboard');
cy.url().should('include', '/login');
});
});
CI/CD Integration
CI/CD Integration
GitHub Actions Workflow
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps
- name: Start services
run: |
docker-compose -f docker-compose.test.yml up -d
npx wait-on http://localhost:3000 http://localhost:5432
- name: Run E2E tests
run: npx playwright test --project=chromium
env:
CI: true
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Docker Compose for Testing
# docker-compose.test.yml
services:
db:
image: postgres:15
environment:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports: ['5432:5432']
healthcheck:
test: pg_isready -U test
interval: 2s
retries: 10
app:
build: .
environment:
DATABASE_URL: postgresql://test:test@db:5432/testdb
JWT_SECRET: test-secret-key-for-testing-only
NODE_ENV: test
ports: ['3000:3000']
depends_on:
db: { condition: service_healthy }
Test Data Setup in CI
// e2e/helpers/setup.ts
import { db } from '../../src/config/database.js';
export async function setupTestData() {
await db.raw('TRUNCATE users, posts, comments CASCADE');
const [admin] = await db('users').insert({
name: 'Test Admin',
email: 'admin@example.com',
passwordHash: 'hashed-admin123',
role: 'admin',
}).returning('*');
const [user] = await db('users').insert({
name: 'Test User',
email: 'user@example.com',
passwordHash: 'hashed-pass123',
role: 'user',
}).returning('*');
await db('posts').insert({
title: 'Seeded Post',
content: 'Content for testing',
authorId: user.id,
slug: 'seeded-post',
published: true,
});
return { admin, user };
}
Quiz
1. What is the primary benefit of using the Page Object Model pattern in E2E tests?
2. In Playwright, what does the page.route() method allow you to do during E2E tests?
3. Why should E2E tests run with workers: 1 in CI but potentially parallel locally?
Flashcards
Question
What is the Playwright Page Object Model and why use it?
Click to reveal answer
Answer
A pattern where page selectors and interactions are encapsulated into classes. Each page gets a class (e.g., LoginPage) that exposes high-level methods (login, expectError). This centralizes selector changes and improves readability.
Question
How do you mock API responses in Playwright E2E tests?
Click to reveal answer
Answer
Use page.route('**/api/endpoint', route => route.fulfill({...})) to intercept matching requests. You can return custom status codes, response bodies, and headers to test error states without depending on a real backend.
Question
What CI configuration ensures reliable E2E test execution?
Click to reveal answer
Answer
Use a single worker (workers: 1) to avoid database conflicts, Docker Compose for isolated services, webServer config to wait for the app, retries for flaky tests, and artifact uploads for reports and videos.
Revision Notes
Key Takeaways
- 1. E2E tests validate complete user journeys through the application in a real browser environment
- 2. The Page Object Model pattern encapsulates selectors and interactions for maintainable test suites
- 3. Playwright offers cross-browser testing with automatic waiting, network interception, and code generation
- 4. Cypress provides time-travel debugging with command chaining and real-time reloading during development
- 5. Network mocking via page.route() or cy.intercept() enables testing error states independently
- 6. CI/CD pipelines should use single workers, Docker Compose for services, and artifact uploads for debugging
Interview Tips
- • Explain the difference between unit, integration, and E2E tests and when to use each
- • Discuss how Page Objects reduce flakiness and improve test maintainability
- • Describe strategies for handling async operations like loading states and API responses
- • Explain how network mocking helps test edge cases without backend dependencies
- • Discuss CI/CD best practices for running E2E tests efficiently at scale
- • Compare Playwright and Cypress: architecture, debugging, and ecosystem tradeoffs
Cheat Sheet
E2E Testing: Playwright config key settings: testDir, retries, workers, webServer. Page Objects: encapsulate selectors into classes with high-level methods. Network mocking: page.route() for intercepting API calls. Cypress: cy.session() for reusable login, cy.intercept() for API mocking. CI: workers:1, Docker Compose, wait-on for service readiness. Artifacts: traces, screenshots, videos on failure for debugging.