Skip to content
advanced Phase 15 · Testing & Quality

End-to-End Testing

Automate browser tests with Playwright or Cypress for full user flow validation.

1h 30m
0 problems
Topic Progress 0%

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?

Question 1 options

2. In Playwright, what does the page.route() method allow you to do during E2E tests?

Question 2 options

3. Why should E2E tests run with workers: 1 in CI but potentially parallel locally?

Question 3 options

Flashcards

Question

What is the Playwright Page Object Model and why use it?

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?

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?

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.