Episode 3 — NodeJS MongoDB Backend Architecture / 3.18 — Testing Tools

3.18.d — Cross-Browser & End-to-End Web Testing

End-to-end testing validates your entire application stack by simulating real user interactions in a browser -- tools like Cypress and Playwright automate clicks, form fills, and navigation across multiple browsers to catch bugs that unit and API tests cannot.


← 3.18.c · 3.18 Overview


1. What is E2E Testing?

End-to-end (E2E) testing simulates a real user interacting with your application through an actual browser. Unlike unit tests that verify a single function in isolation or API tests that verify a single HTTP endpoint, E2E tests exercise the entire stack -- the frontend UI, the backend API, the database, the network layer, authentication providers, and every piece of middleware in between. The question an E2E test answers is not "does this function return the right value?" but "can a real person actually accomplish this task in our application?"

E2E Test Scope:

  Automated Browser (Cypress / Playwright)
    │
    │  1. Navigate to /login
    │  2. Type email into input field
    │  3. Type password into input field
    │  4. Click the "Sign In" button
    │  5. Wait for redirect to /dashboard
    │  6. Assert "Welcome, Alice" is visible on the page
    │
    ▼
  Frontend (React, Vue, Angular, Vanilla JS)
    │
    ▼
  Backend (Express API, middleware, auth)
    │
    ▼
  Database (MongoDB, PostgreSQL, Redis)
    │
    ▼
  External Services (Stripe, SendGrid, S3)

ALL layers are tested together, exactly as a real user would experience them.

Where E2E Fits in the Testing Pyramid

                 /\
                /  \
               / E2E \          ← YOU ARE HERE
              / Tests \           Few tests, slow, expensive, high confidence
             /----------\
            / Integration \     ← API + DB together (Supertest)
           /    Tests      \
          /------------------\
         /    Unit Tests      \  ← Individual functions (Jest)
        /  (fast, cheap, many) \
       /________________________\

E2E tests sit at the very top of the pyramid. You write fewer of them, they run slower, and they cost more to maintain -- but they provide the highest confidence that your application works as a user expects.

When E2E Tests Are the Right Choice

ScenarioWhy E2E
User registration and loginFull auth flow: form validation, API call, token storage, redirect, session persistence
Checkout and paymentCritical revenue path that spans multiple pages and services
Multi-step forms or wizardsState must persist across pages; only E2E captures this
OAuth and SSO flowsInvolves redirects to external identity providers and back
Smoke tests after deploymentQuick sanity check that the application boots and key features work

When E2E Tests Are NOT the Right Choice

ScenarioBetter Alternative
Testing a single utility functionUnit test with Jest
Testing API response shape and status codesAPI test with Supertest
Checking every form validation ruleUnit test the validator function
Testing 50 edge cases of a pricing calculationUnit test
Verifying database query correctnessIntegration test with mongodb-memory-server

Rule of thumb: if you can test it at a lower level of the pyramid, do so. Reserve E2E tests for flows that inherently involve multiple layers working together.


2. Cypress

Cypress is a JavaScript-based E2E testing framework built specifically for the modern web. It runs directly inside the browser alongside your application, which gives it unique capabilities: it has access to the DOM, the network layer, and the application's JavaScript context simultaneously.

2.1 Installation

# Install Cypress as a dev dependency
npm install -D cypress

# Open the Cypress interactive runner (first run creates the folder structure)
npx cypress open

After the first run, Cypress creates this project structure:

cypress/
  e2e/                    ← Your test files live here (*.cy.js)
    spec.cy.js
  fixtures/               ← Static test data (JSON files, images)
    example.json
  support/
    commands.js           ← Custom commands (extend the cy object)
    e2e.js                ← Runs before every spec file (global hooks)
  downloads/              ← Files downloaded during tests
cypress.config.js         ← Cypress configuration file

2.2 Configuration

// cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    // Base URL — prepended to every cy.visit() call
    baseUrl: 'http://localhost:3000',

    // Browser viewport dimensions
    viewportWidth: 1280,
    viewportHeight: 720,

    // Timeout settings (in milliseconds)
    defaultCommandTimeout: 10000,    // Timeout for DOM-based commands
    requestTimeout: 10000,           // Timeout for cy.request()
    responseTimeout: 30000,          // Timeout for server responses
    pageLoadTimeout: 60000,          // Timeout for cy.visit() page loads

    // Media capture
    video: true,                     // Record video of every test run
    screenshotOnRunFailure: true,    // Auto-capture screenshot on failure
    videosFolder: 'cypress/videos',
    screenshotsFolder: 'cypress/screenshots',

    // Retry configuration
    retries: {
      runMode: 2,                    // Retries in CI (cypress run)
      openMode: 0                    // No retries in interactive mode
    },

    // Spec file pattern
    specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',

    // Node event listeners (plugins)
    setupNodeEvents(on, config) {
      // Register plugins here
    }
  }
});

2.3 Writing Tests

Every Cypress test file uses the familiar describe/it structure from Mocha (bundled with Cypress):

// cypress/e2e/homepage.cy.js
describe('Homepage', () => {
  beforeEach(() => {
    // Runs before each test in this block
    cy.visit('/');  // Uses baseUrl from config → http://localhost:3000/
  });

  it('should display the application title', () => {
    cy.get('h1').should('contain', 'Welcome to MyApp');
  });

  it('should have a visible navigation bar with at least 3 links', () => {
    cy.get('nav').should('be.visible');
    cy.get('nav a').should('have.length.at.least', 3);
  });

  it('should navigate to the about page when the link is clicked', () => {
    cy.get('a[href="/about"]').click();
    cy.url().should('include', '/about');
    cy.get('h1').should('contain', 'About Us');
  });

  it('should display the footer with the current year', () => {
    const currentYear = new Date().getFullYear();
    cy.get('footer').should('contain', currentYear.toString());
  });
});

2.4 Selectors

How you select elements in E2E tests is one of the most important decisions you will make. Brittle selectors are the number one cause of test maintenance headaches.

// ---- BAD: Fragile selectors that break when CSS or structure changes ----
cy.get('.btn-primary');                   // Breaks if class name changes
cy.get('#submit');                        // Breaks if ID changes
cy.get('div > form > button:nth(2)');    // Breaks if DOM structure changes
cy.get('button').contains('Submit');      // Breaks if text is translated

// ---- GOOD: Dedicated test selectors, stable and explicit ----
cy.get('[data-testid="submit-button"]');          // Explicit, stable
cy.get('[data-testid="email-input"]');             // Clear intent
cy.get('[data-testid="user-list"] li');            // Scoped within test ID
cy.get('[data-testid="error-message"]');           // Purpose-driven
cy.get('[data-cy="login-form"]');                  // Cypress convention
Selector StrategyResilienceReadabilityRecommended?
data-testid or data-cyHighHighYes -- best practice
Semantic role/aria (role, aria-label)HighMediumYes -- also tests accessibility
placeholder, name attributesMediumMediumSometimes -- for form inputs
CSS class namesLowMediumNo -- coupled to styling
Element IDsMediumMediumRarely -- IDs may change
Tag + nth-child / XPathVery LowLowNever

2.5 Assertions

Cypress uses Chai assertions through the .should() and .and() commands. Assertions automatically retry until they pass or time out.

// ---- Visibility and existence ----
cy.get('[data-testid="header"]').should('be.visible');
cy.get('[data-testid="modal"]').should('not.exist');
cy.get('[data-testid="loader"]').should('not.be.visible');

// ---- Text content ----
cy.get('[data-testid="greeting"]').should('contain', 'Hello');
cy.get('[data-testid="greeting"]').should('have.text', 'Hello, Alice');
cy.get('[data-testid="counter"]').should('have.text', '42');

// ---- CSS and attributes ----
cy.get('[data-testid="error"]').should('have.class', 'text-red-500');
cy.get('[data-testid="link"]').should('have.attr', 'href', '/dashboard');
cy.get('[data-testid="email"]').should('have.value', 'alice@test.com');
cy.get('[data-testid="submit"]').should('be.disabled');
cy.get('[data-testid="submit"]').should('not.be.disabled');

// ---- Length and count ----
cy.get('[data-testid="user-list"] li').should('have.length', 5);
cy.get('[data-testid="results"]').should('have.length.greaterThan', 0);

// ---- Chaining multiple assertions with .and() ----
cy.get('[data-testid="profile-card"]')
  .should('be.visible')
  .and('contain', 'Alice')
  .and('contain', 'alice@test.com')
  .and('not.contain', 'password');

// ---- URL assertions ----
cy.url().should('include', '/dashboard');
cy.url().should('eq', 'http://localhost:3000/dashboard');
cy.url().should('match', /\/users\/\w+/);

// ---- Cookie assertions ----
cy.getCookie('session').should('exist');
cy.getCookie('session').should('have.property', 'value');

2.6 Network Interception and Stubbing

cy.intercept() is one of Cypress's most powerful features. It lets you intercept HTTP requests, stub responses, wait for requests, and assert on request/response data.

// ---- Stub an API response with mock data ----
describe('User Dashboard', () => {
  it('should display users from the API', () => {
    cy.intercept('GET', '/api/users', {
      statusCode: 200,
      body: {
        data: [
          { _id: '1', name: 'Alice', email: 'alice@test.com' },
          { _id: '2', name: 'Bob', email: 'bob@test.com' }
        ]
      }
    }).as('getUsers');  // Alias the intercept for later reference

    cy.visit('/dashboard');
    cy.wait('@getUsers');  // Wait for the intercepted request to complete

    cy.get('[data-testid="user-list"] li').should('have.length', 2);
    cy.get('[data-testid="user-list"]').should('contain', 'Alice');
  });

  // ---- Test error states by stubbing a failure ----
  it('should show an error message when the API fails', () => {
    cy.intercept('GET', '/api/users', {
      statusCode: 500,
      body: { message: 'Internal Server Error' }
    }).as('getUsersFail');

    cy.visit('/dashboard');
    cy.wait('@getUsersFail');

    cy.get('[data-testid="error-banner"]')
      .should('be.visible')
      .and('contain', 'Something went wrong');
  });

  // ---- Assert on the request body of a POST ----
  it('should send the correct data when creating a user', () => {
    cy.intercept('POST', '/api/users', (req) => {
      expect(req.body).to.have.property('name', 'Alice');
      expect(req.body).to.have.property('email', 'alice@test.com');

      req.reply({
        statusCode: 201,
        body: { data: { _id: '123', ...req.body } }
      });
    }).as('createUser');

    cy.visit('/users/new');
    cy.get('[data-testid="name-input"]').type('Alice');
    cy.get('[data-testid="email-input"]').type('alice@test.com');
    cy.get('[data-testid="submit-button"]').click();

    cy.wait('@createUser');
    cy.get('[data-testid="success-toast"]').should('be.visible');
  });

  // ---- Delay a response to test loading states ----
  it('should show a loading spinner while the API responds', () => {
    cy.intercept('GET', '/api/users', (req) => {
      req.reply({
        statusCode: 200,
        body: { data: [] },
        delay: 2000  // Simulate 2-second delay
      });
    }).as('slowRequest');

    cy.visit('/dashboard');
    cy.get('[data-testid="loading-spinner"]').should('be.visible');
    cy.wait('@slowRequest');
    cy.get('[data-testid="loading-spinner"]').should('not.exist');
  });

  // ---- Spy on a request without modifying it ----
  it('should send analytics event on page load', () => {
    cy.intercept('POST', '/api/analytics').as('analyticsEvent');

    cy.visit('/dashboard');
    cy.wait('@analyticsEvent').then((interception) => {
      expect(interception.request.body).to.have.property('event', 'page_view');
      expect(interception.request.body).to.have.property('page', '/dashboard');
    });
  });
});

2.7 Fixtures

Fixtures are static data files (usually JSON) stored in the cypress/fixtures/ directory. They keep mock data out of your test files and make it reusable.

// cypress/fixtures/users.json
[
  { "_id": "1", "name": "Alice", "email": "alice@test.com", "role": "admin" },
  { "_id": "2", "name": "Bob", "email": "bob@test.com", "role": "user" },
  { "_id": "3", "name": "Charlie", "email": "charlie@test.com", "role": "user" }
]
// cypress/fixtures/emptyState.json
{
  "data": [],
  "pagination": { "page": 1, "limit": 10, "total": 0 }
}
// Using fixtures in tests
describe('User List', () => {
  it('should display fixture data', () => {
    cy.fixture('users.json').then((users) => {
      cy.intercept('GET', '/api/users', { body: { data: users } }).as('getUsers');
    });

    cy.visit('/users');
    cy.wait('@getUsers');
    cy.get('[data-testid="user-list"] li').should('have.length', 3);
  });

  // Shorthand: cy.intercept can load a fixture directly by name
  it('should display users (shorthand fixture syntax)', () => {
    cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers');
    cy.visit('/users');
    cy.wait('@getUsers');
    cy.get('[data-testid="user-list"] li').should('have.length', 3);
  });

  it('should handle empty state', () => {
    cy.intercept('GET', '/api/users', { fixture: 'emptyState.json' }).as('getUsers');
    cy.visit('/users');
    cy.wait('@getUsers');
    cy.get('[data-testid="empty-message"]').should('contain', 'No users found');
  });
});

2.8 Custom Commands

Custom commands extend the cy object with reusable operations. They eliminate duplication and make tests more expressive.

// cypress/support/commands.js

// ---- Login via API (bypasses the UI for speed) ----
Cypress.Commands.add('login', (email = 'test@test.com', password = 'Pass123') => {
  cy.request('POST', '/api/auth/login', { email, password }).then((res) => {
    window.localStorage.setItem('token', res.body.token);
  });
});

// ---- Login via the UI (for testing the login flow itself) ----
Cypress.Commands.add('loginViaUI', (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');
});

// ---- Fill a form by mapping data-testid to values ----
Cypress.Commands.add('fillForm', (formData) => {
  Object.entries(formData).forEach(([testId, value]) => {
    cy.get(`[data-testid="${testId}"]`).clear().type(value);
  });
});

// ---- Assert a toast notification appears ----
Cypress.Commands.add('expectToast', (message) => {
  cy.get('[data-testid="toast"]')
    .should('be.visible')
    .and('contain', message);
});

// ---- Seed the database via a test API endpoint ----
Cypress.Commands.add('resetDatabase', () => {
  cy.request('POST', '/api/test/reset');
});

Cypress.Commands.add('seedDatabase', (fixture) => {
  cy.fixture(fixture).then((data) => {
    cy.request('POST', '/api/test/seed', { data });
  });
});
// Using custom commands in tests
describe('Admin Dashboard', () => {
  beforeEach(() => {
    cy.resetDatabase();
    cy.seedDatabase('users.json');
    cy.login('admin@test.com', 'AdminPass123');
    cy.visit('/admin/dashboard');
  });

  it('should display all users', () => {
    cy.get('[data-testid="user-list"] li').should('have.length', 3);
  });

  it('should create a new user', () => {
    cy.get('[data-testid="add-user-button"]').click();
    cy.fillForm({
      'name-input': 'Diana',
      'email-input': 'diana@test.com',
      'password-input': 'SecurePass123'
    });
    cy.get('[data-testid="submit-button"]').click();
    cy.expectToast('User created successfully');
  });
});

2.9 CI Integration for Cypress

# .github/workflows/cypress.yml
name: Cypress E2E Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  cypress-run:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run Cypress tests
        uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm start
          wait-on: 'http://localhost:3000'
          wait-on-timeout: 120
          browser: chrome

      - name: Upload screenshots on failure
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: cypress-screenshots
          path: cypress/screenshots
          retention-days: 7

      - name: Upload videos
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: cypress-videos
          path: cypress/videos
          retention-days: 7

3. Playwright

Playwright is a browser automation library created by Microsoft. It supports Chromium (Chrome, Edge), Firefox, and WebKit (Safari) from a single unified API. Playwright was designed from the ground up for reliability: it features auto-waiting, browser context isolation, and powerful parallel execution -- all for free.

3.1 Installation

# Option 1: Interactive setup (creates config, example tests, GitHub Actions workflow)
npm init playwright@latest

# Option 2: Manual installation
npm install -D @playwright/test
npx playwright install              # Downloads Chromium, Firefox, and WebKit binaries
npx playwright install --with-deps  # Also installs OS-level dependencies (for CI)

After setup you have:

playwright.config.js        ← Configuration
tests/
  example.spec.js           ← Your test files
tests-examples/
  demo-todo-app.spec.js     ← Example tests to learn from

3.2 Configuration

// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
  // Where test files live
  testDir: './tests',

  // Parallelism
  fullyParallel: true,               // Run tests within files in parallel
  workers: process.env.CI ? 1 : undefined,  // Single worker on CI for stability

  // Safety
  forbidOnly: !!process.env.CI,      // Fail CI build if test.only is left in code

  // Retries
  retries: process.env.CI ? 2 : 0,   // Retry failed tests in CI

  // Reporting
  reporter: [
    ['html', { open: 'never' }],     // HTML report
    ['list']                          // Console output
  ],

  // Timeout
  timeout: 30000,                    // Per-test timeout (30 seconds)

  // Shared settings for all projects
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',        // Capture detailed trace on first retry
    screenshot: 'only-on-failure',  // Capture screenshot when test fails
    video: 'retain-on-failure',     // Keep video only for failed tests
    actionTimeout: 10000,           // Timeout for each action (click, fill)
  },

  // Browser projects — test against multiple browsers
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] }
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] }
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] }
    },
    // Mobile viewports
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] }
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 13'] }
    }
  ],

  // Automatically start the dev server before running tests
  webServer: {
    command: 'npm start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120000
  }
});

3.3 Writing Tests

// tests/homepage.spec.js
const { test, expect } = require('@playwright/test');

test.describe('Homepage', () => {
  test('should display the application title', async ({ page }) => {
    await page.goto('/');
    await expect(page).toHaveTitle(/MyApp/);
    await expect(page.locator('h1')).toContainText('Welcome');
  });

  test('should navigate to the about page', async ({ page }) => {
    await page.goto('/');
    await page.click('a[href="/about"]');
    await expect(page).toHaveURL(/\/about/);
    await expect(page.locator('h1')).toContainText('About Us');
  });

  test('should display a list of featured products', async ({ page }) => {
    await page.goto('/');
    const productCards = page.locator('[data-testid="product-card"]');
    await expect(productCards).toHaveCount(6);
    await expect(productCards.first()).toBeVisible();
  });
});
// tests/login.spec.js
const { test, expect } = require('@playwright/test');

test.describe('User Login', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('should login with valid credentials', async ({ page }) => {
    await page.fill('[data-testid="email-input"]', 'alice@example.com');
    await page.fill('[data-testid="password-input"]', 'SecurePass123');
    await page.click('[data-testid="login-button"]');

    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.locator('[data-testid="welcome-message"]'))
      .toContainText('Welcome, Alice');
  });

  test('should show an error for invalid credentials', async ({ page }) => {
    await page.fill('[data-testid="email-input"]', 'wrong@example.com');
    await page.fill('[data-testid="password-input"]', 'wrongpassword');
    await page.click('[data-testid="login-button"]');

    await expect(page.locator('[data-testid="error-message"]'))
      .toContainText('Invalid email or password');
    await expect(page).toHaveURL(/\/login/);
  });

  test('should disable the submit button while loading', async ({ page }) => {
    await page.fill('[data-testid="email-input"]', 'alice@example.com');
    await page.fill('[data-testid="password-input"]', 'SecurePass123');
    await page.click('[data-testid="login-button"]');

    // Button should be disabled during the request
    await expect(page.locator('[data-testid="login-button"]')).toBeDisabled();
  });
});

3.4 Browser Contexts and Test Isolation

Every Playwright test gets a fresh browser context by default. A context is like an incognito window -- it has its own cookies, local storage, session storage, and cache. This means tests cannot leak state to each other.

test.describe('Session Isolation', () => {
  test('user A logs in and sees their dashboard', async ({ page }) => {
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'alice@test.com');
    await page.fill('[data-testid="password"]', 'Pass123');
    await page.click('[data-testid="login-button"]');
    await expect(page.locator('[data-testid="welcome"]')).toContainText('Alice');
    // Alice's session does NOT carry over to the next test
  });

  test('user B sees the login page, not Alice\'s dashboard', async ({ page }) => {
    await page.goto('/dashboard');
    // Redirected to login because the context is fresh -- no session from test above
    await expect(page).toHaveURL(/\/login/);
  });
});

You can also create multiple contexts within a single test, which is essential for testing multi-user scenarios like chat applications:

test('two users can send messages to each other', async ({ browser }) => {
  // Create two independent browser contexts (two separate "users")
  const aliceContext = await browser.newContext();
  const bobContext = await browser.newContext();
  const alicePage = await aliceContext.newPage();
  const bobPage = await bobContext.newPage();

  // Alice logs in
  await alicePage.goto('/login');
  await alicePage.fill('[data-testid="email"]', 'alice@test.com');
  await alicePage.fill('[data-testid="password"]', 'Pass123');
  await alicePage.click('[data-testid="login-button"]');

  // Bob logs in
  await bobPage.goto('/login');
  await bobPage.fill('[data-testid="email"]', 'bob@test.com');
  await bobPage.fill('[data-testid="password"]', 'Pass123');
  await bobPage.click('[data-testid="login-button"]');

  // Alice sends a message
  await alicePage.goto('/chat');
  await alicePage.fill('[data-testid="message-input"]', 'Hello Bob!');
  await alicePage.click('[data-testid="send-button"]');

  // Bob sees the message
  await bobPage.goto('/chat');
  await expect(bobPage.locator('[data-testid="messages"]'))
    .toContainText('Hello Bob!');

  // Clean up
  await aliceContext.close();
  await bobContext.close();
});

3.5 Auto-Waiting

Auto-waiting is one of Playwright's most important features and the primary reason it produces fewer flaky tests than older frameworks. Every action (click, fill, check, etc.) automatically waits for the element to be:

  1. Attached to the DOM
  2. Visible (not hidden by CSS)
  3. Stable (not mid-animation)
  4. Able to receive events (not blocked by an overlay)
  5. Enabled (not disabled)
test('auto-waiting eliminates the need for manual waits', async ({ page }) => {
  await page.goto('/dashboard');

  // This click auto-waits for the button to appear and become clickable.
  // No explicit wait needed even if the button is rendered asynchronously.
  await page.click('[data-testid="load-data-button"]');

  // This assertion auto-waits for the element to contain the expected text.
  // If the data takes 3 seconds to load, Playwright waits up to the timeout.
  await expect(page.locator('[data-testid="data-table"]'))
    .toContainText('Results loaded');
});

// ---- When you DO need explicit waits (rare) ----
test('waiting for specific conditions', async ({ page }) => {
  await page.goto('/dashboard');

  // Wait for a specific element to appear
  await page.waitForSelector('[data-testid="async-widget"]', {
    state: 'visible',
    timeout: 15000
  });

  // Wait for a network response
  const responsePromise = page.waitForResponse('**/api/users');
  await page.click('[data-testid="refresh-button"]');
  const response = await responsePromise;
  expect(response.status()).toBe(200);

  // Wait for navigation
  await Promise.all([
    page.waitForNavigation(),
    page.click('[data-testid="nav-link"]')
  ]);

  // Wait for the page to reach a specific load state
  await page.waitForLoadState('networkidle');
});

3.6 Codegen: Auto-Generate Tests

Playwright includes a powerful code generator that records your browser interactions and writes test code automatically:

# Open codegen -- interact with the browser, Playwright writes the test
npx playwright codegen http://localhost:3000

# Generate code for a specific device
npx playwright codegen --device="iPhone 13" http://localhost:3000

# Save generated code to a file
npx playwright codegen -o tests/generated.spec.js http://localhost:3000

This opens two windows: a browser and an inspector. As you click, type, and navigate, the inspector generates working Playwright test code in real time. Codegen is invaluable for:

  • Learning the Playwright API quickly by example
  • Discovering the correct locator for tricky elements
  • Generating boilerplate for complex multi-step flows
  • Creating a starting point that you then refine and add assertions to

3.7 Parallel Execution

Playwright runs test files in parallel by default, using worker processes. Each worker runs a different test file (or, with fullyParallel: true, different tests within the same file).

// playwright.config.js
module.exports = defineConfig({
  fullyParallel: true,      // Run individual tests in parallel (not just files)
  workers: 4,               // Number of parallel workers, or use 'auto'
});
// Force sequential execution when tests in a file depend on each other
test.describe.configure({ mode: 'serial' });

test.describe('Multi-step checkout flow', () => {
  test('step 1: add items to cart', async ({ page }) => { /* ... */ });
  test('step 2: enter shipping info', async ({ page }) => { /* ... */ });
  test('step 3: process payment', async ({ page }) => { /* ... */ });
  test('step 4: confirm order', async ({ page }) => { /* ... */ });
});

For CI, you can shard tests across multiple machines:

# Split tests across 4 CI machines
npx playwright test --shard=1/4   # Machine 1 runs shard 1 of 4
npx playwright test --shard=2/4   # Machine 2 runs shard 2 of 4
npx playwright test --shard=3/4   # Machine 3 runs shard 3 of 4
npx playwright test --shard=4/4   # Machine 4 runs shard 4 of 4

3.8 Screenshots and Videos

test('capture screenshots programmatically', async ({ page }) => {
  await page.goto('/dashboard');

  // Full page screenshot (including content below the fold)
  await page.screenshot({
    path: 'screenshots/dashboard-full.png',
    fullPage: true
  });

  // Screenshot of a specific element
  await page.locator('[data-testid="chart"]').screenshot({
    path: 'screenshots/chart.png'
  });

  // Screenshot with clipping (specific region of the page)
  await page.screenshot({
    path: 'screenshots/header-region.png',
    clip: { x: 0, y: 0, width: 1280, height: 100 }
  });
});

Automatic screenshot and video capture (configured in playwright.config.js) provides debugging evidence for CI failures:

screenshot: 'only-on-failure'    → Captures screenshot of failed tests
screenshot: 'on'                 → Captures screenshot of every test
video: 'retain-on-failure'       → Records video but only keeps it for failures
video: 'on'                      → Records video for every test
trace: 'on-first-retry'          → Captures detailed trace when test is retried

3.9 API Testing with Playwright

Playwright is not limited to browser automation. It includes a built-in request fixture for making HTTP calls directly, which is useful for setting up test data or testing APIs alongside UI tests:

const { test, expect } = require('@playwright/test');

test.describe('API Tests with Playwright', () => {
  test('GET /api/users returns a list of users', async ({ request }) => {
    const response = await request.get('/api/users');
    expect(response.ok()).toBeTruthy();
    expect(response.status()).toBe(200);

    const body = await response.json();
    expect(body.data).toBeInstanceOf(Array);
    expect(body.data.length).toBeGreaterThan(0);
  });

  test('POST /api/users creates a new user', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: {
        name: 'Alice',
        email: 'alice@test.com',
        password: 'SecurePass123'
      }
    });

    expect(response.status()).toBe(201);
    const body = await response.json();
    expect(body.data.name).toBe('Alice');
    expect(body.data).not.toHaveProperty('password');
  });

  test('set up test data via API, then verify via UI', async ({ page, request }) => {
    // Use the API to create a user (fast, reliable setup)
    await request.post('/api/users', {
      data: { name: 'Bob', email: 'bob@test.com', password: 'Pass123' }
    });

    // Verify the user appears in the UI
    await page.goto('/users');
    await expect(page.locator('[data-testid="user-list"]')).toContainText('Bob');
  });
});

4. Comparing Cypress vs Playwright vs Selenium

FeatureCypressPlaywrightSelenium
LanguageJavaScript / TypeScript onlyJS, TS, Python, Java, C#Java, Python, JS, C#, Ruby, and more
Browser SupportChromium, Firefox, Edge, WebKit (experimental)Chromium, Firefox, WebKit (full native support)All major browsers via WebDriver
ArchitectureRuns inside the browserControls browser via DevTools ProtocolControls browser via WebDriver protocol
Auto-WaitingBuilt-in, implicit in assertionsBuilt-in, explicit in actions and assertionsManual waits required (WebDriverWait)
Parallel ExecutionPaid via Cypress Cloud, or manual splittingBuilt-in and free, sharding across machinesVia Selenium Grid
Multi-Tab / Multi-DomainLimited (single origin historically)Full support (multiple pages, contexts)Full support
Mobile TestingViewport emulation onlyViewport emulation + device profilesReal device support via Appium
SpeedFastVery fastSlower (WebDriver protocol overhead)
CommunityVery large, matureGrowing rapidly, Microsoft-backedLargest (oldest framework, 2004)
DebuggingTime-travel debugger (excellent)Trace viewer, VS Code extensionBrowser DevTools
SetupSingle npm installSingle install + browser downloadsRequires WebDriver binaries, more config
Test IsolationManual (clear cookies/storage)Automatic (fresh context per test)Manual
Network Mockingcy.intercept()page.route()Requires external proxy
Code GenerationCypress Studio (limited)npx playwright codegen (powerful)Selenium IDE (browser extension)
CostFree (parallel requires paid Cloud)Completely freeFree (Grid hosting costs)

Decision Framework

START
  │
  ├── Need to test on Safari (WebKit)?
  │   ├── Yes → Playwright (only framework with full native WebKit support)
  │   └── No ↓
  │
  ├── Need multi-tab, multi-user, or multi-domain testing?
  │   ├── Yes → Playwright (browser contexts make this natural)
  │   └── No ↓
  │
  ├── Team uses Python, Java, or C#?
  │   ├── Yes → Playwright (multi-language) or Selenium (widest language support)
  │   └── No ↓
  │
  ├── Need free parallel execution in CI?
  │   ├── Yes → Playwright (built-in sharding) or Selenium Grid
  │   └── No ↓
  │
  ├── Prioritize developer experience and interactive debugging?
  │   ├── Yes → Cypress (time-travel debugger is best-in-class)
  │   └── No ↓
  │
  ├── Existing Selenium test suite?
  │   ├── Yes → Keep Selenium, migrate gradually if needed
  │   └── No → Playwright (best overall balance for new projects)
  │
  └── For most new Node.js projects → Playwright

5. CI/CD Integration

Running E2E tests in CI/CD ensures that every pull request and deployment is validated automatically. The key challenge is that E2E tests are slow, so you need strategies to run them efficiently.

GitHub Actions: Playwright (Recommended Setup)

# .github/workflows/e2e.yml
name: E2E Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  e2e-tests:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        run: npx playwright test

      - name: Upload HTML report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

      - name: Upload test results (traces, screenshots, videos)
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: test-results/
          retention-days: 7

Sharding Tests Across Multiple CI Jobs

For large test suites, split tests across parallel CI machines:

# .github/workflows/e2e-sharded.yml
name: E2E Tests (Sharded)

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        shardIndex: [1, 2, 3, 4]
        shardTotal: [4]
    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: Run shard ${{ matrix.shardIndex }} of ${{ matrix.shardTotal }}
        run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: report-shard-${{ matrix.shardIndex }}
          path: playwright-report/

This turns a 20-minute E2E suite into a 5-minute parallel run across 4 machines.

Full Pipeline: Unit → Integration → E2E

# .github/workflows/full-pipeline.yml
name: Full Test Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  # Step 1: Fast tests first (fail fast)
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run lint
      - run: npx jest --coverage --testPathPattern='unit'

  # Step 2: API/integration tests (only if unit tests pass)
  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    services:
      mongodb:
        image: mongo:7
        ports: ['27017:27017']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npx jest --testPathPattern='integration'
        env:
          MONGODB_URI: mongodb://localhost:27017/test

  # Step 3: E2E tests (only if integration tests pass)
  e2e-tests:
    runs-on: ubuntu-latest
    needs: integration-tests
    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
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: e2e-report
          path: playwright-report/

CI/CD Best Practices

PracticeWhy
Run E2E tests AFTER unit and API tests passFail fast -- do not waste 20 minutes on slow E2E tests if fast tests already fail
Use separate CI jobs for each test tierDifferent timeouts, resources, and dependencies
Always capture artifacts (screenshots, videos, traces)Essential for debugging failures that cannot be reproduced locally
Use sharding for suites over 10 minutesKeep feedback time under 10 minutes
Set retries to 1-2 in CIHandle transient failures without masking real bugs
Run full browser matrix on nightly schedule, Chromium only on PRsBalance thoroughness with PR speed
Use webServer in Playwright configAutomatically starts and stops the dev server

6. Visual Regression Testing

Visual regression testing captures screenshots of your UI and compares them pixel-by-pixel against approved baseline images. If the difference exceeds a threshold, the test fails. This catches CSS regressions, layout shifts, and unintended visual changes that functional tests miss entirely.

How It Works

1. FIRST RUN:     Capture baseline screenshots (approved "golden" images)
2. SUBSEQUENT:    Capture new screenshots of the same pages/components
3. COMPARE:       Diff new screenshots against baselines pixel by pixel
4. IF DIFFERENT:  Test fails → developer reviews the visual diff
5. INTENTIONAL:   Update the baseline → new image becomes the golden standard
6. UNINTENTIONAL: Fix the CSS/code → re-run, screenshot should now match baseline

Playwright Visual Comparisons (Built-In)

const { test, expect } = require('@playwright/test');

test('homepage visual regression', async ({ page }) => {
  await page.goto('/');

  // Compare the full page against the saved baseline screenshot
  await expect(page).toHaveScreenshot('homepage.png', {
    maxDiffPixelRatio: 0.01,   // Allow up to 1% of pixels to differ
    threshold: 0.2              // Per-pixel color sensitivity (0 = exact, 1 = anything)
  });
});

test('header component visual regression', async ({ page }) => {
  await page.goto('/');

  // Compare just a specific element
  const header = page.locator('[data-testid="header"]');
  await expect(header).toHaveScreenshot('header.png');
});

test('responsive visual regression', async ({ page }) => {
  await page.goto('/');

  // Desktop
  await page.setViewportSize({ width: 1280, height: 720 });
  await expect(page).toHaveScreenshot('homepage-desktop.png');

  // Tablet
  await page.setViewportSize({ width: 768, height: 1024 });
  await expect(page).toHaveScreenshot('homepage-tablet.png');

  // Mobile
  await page.setViewportSize({ width: 375, height: 667 });
  await expect(page).toHaveScreenshot('homepage-mobile.png');
});
# First run: creates baseline screenshots (stored in tests/*.spec.js-snapshots/)
npx playwright test

# When visual changes are intentional, update the baselines
npx playwright test --update-snapshots

Dedicated Visual Testing Tools

ToolDescriptionCost
Percy (BrowserStack)Cloud-based visual testing, renders across browsers, smart diffingPaid (free tier available)
Chromatic (Storybook)Visual testing for component libraries, compares storiesPaid (free tier available)
Applitools EyesAI-powered visual testing, ignores irrelevant differencesPaid
BackstopJSOpen-source visual regression with configurable viewportsFree
Playwright built-inLocal screenshot comparison, no cloud neededFree

7. Best Practices

Test Isolation

Every E2E test must be completely independent. It must not rely on state created by a previous test.

// BAD — test 2 depends on test 1 having created Alice
test('create a user', async ({ page }) => { /* creates Alice */ });
test('edit the user', async ({ page }) => { /* tries to edit Alice -- fails if test 1 didn't run */ });

// GOOD — each test sets up its own state
test('create a user', async ({ page }) => {
  await page.goto('/users/new');
  await page.fill('[data-testid="name"]', 'Alice');
  await page.click('[data-testid="submit"]');
  await expect(page.locator('[data-testid="success"]')).toBeVisible();
});

test('edit a user', async ({ page, request }) => {
  // Create the user via API (fast, reliable, bypasses UI)
  await request.post('/api/users', {
    data: { name: 'Alice', email: 'alice@test.com', password: 'Pass123' }
  });

  // Now test the edit flow
  await page.goto('/users');
  await page.click('[data-testid="edit-alice"]');
  await page.fill('[data-testid="name"]', 'Alice Updated');
  await page.click('[data-testid="save"]');
  await expect(page.locator('[data-testid="name"]')).toContainText('Alice Updated');
});

Selector Strategy

RECOMMENDED priority order for choosing selectors:

1. data-testid="submit-button"       ← Best: explicit, stable, no false positives
2. role="button", aria-label="Save"  ← Good: tests accessibility simultaneously
3. placeholder="Enter email"         ← Acceptable: for form inputs
4. text="Submit"                     ← Risky: breaks with i18n or copy changes
5. .btn-primary                      ← Avoid: coupled to CSS styling
6. #submit                           ← Avoid: IDs change, shared with JS logic
7. div > form > button:nth-child(2)  ← Never: extremely fragile

Retry Logic and Flaky Tests

E2E tests are inherently more flaky than unit tests because they depend on timing, network latency, rendering speed, and animations. Use retries strategically, but always investigate root causes.

Flaky CauseSolution
Element not yet visibleUse auto-waiting (Playwright) or increase timeout (Cypress)
CSS animation in progressDisable animations in test mode: * { transition: none !important; animation: none !important; }
Race condition with API responseWait for the network response before asserting: cy.wait('@apiCall') or page.waitForResponse()
Shared database state between testsReset database before each test (API call or beforeEach hook)
Hardcoded sleep(2000) callsReplace with explicit waits for specific conditions
Random test data collisionsUse unique identifiers per test (timestamps, UUIDs)
Third-party service unreliableStub external API calls in tests
Browser window not focusedUse headless mode in CI

Use API for Setup, Browser for Verification

The fastest E2E test uses the browser only for the flow being tested. Everything else (creating users, seeding data, authenticating) should happen via direct API calls.

// SLOW — 15 seconds of browser navigation just for setup
test('edit user profile', async ({ page }) => {
  await page.goto('/register');                      // 2s page load
  await page.fill('[data-testid="name"]', 'Alice');  // 0.5s
  await page.fill('[data-testid="email"]', 'alice@test.com');
  await page.fill('[data-testid="password"]', 'Pass123');
  await page.click('[data-testid="submit"]');        // 2s API + redirect
  await page.goto('/login');                         // 2s page load
  await page.fill('[data-testid="email"]', 'alice@test.com');
  await page.fill('[data-testid="password"]', 'Pass123');
  await page.click('[data-testid="login-button"]');  // 2s API + redirect
  // ... finally start the actual test 10+ seconds later
});

// FAST — 100ms API calls for setup, browser only for the flow under test
test('edit user profile', async ({ page, request }) => {
  // Create user via API (instant)
  await request.post('/api/auth/register', {
    data: { name: 'Alice', email: 'alice@test.com', password: 'Pass123' }
  });
  const loginRes = await request.post('/api/auth/login', {
    data: { email: 'alice@test.com', password: 'Pass123' }
  });
  const token = (await loginRes.json()).token;

  // Set auth state directly (bypass login UI)
  await page.goto('/');
  await page.evaluate((t) => localStorage.setItem('token', t), token);

  // NOW test the actual edit flow (this is what we care about)
  await page.goto('/profile/edit');
  await page.fill('[data-testid="name"]', 'Alice Updated');
  await page.click('[data-testid="save"]');
  await expect(page.locator('[data-testid="success"]')).toBeVisible();
});

Keep Tests Focused

Each E2E test should verify ONE user flow, not five.

BAD (one giant test):
  "should register, login, create a post, edit the post, delete the post, and logout"
  → 2 minutes to run, impossible to debug when it fails at step 4

GOOD (focused tests with API-based setup):
  "should register a new user"
  "should login with valid credentials"
  "should create a new blog post"         ← login via API in beforeEach
  "should edit an existing blog post"     ← seed post via API in beforeEach
  "should delete a blog post"             ← seed post via API in beforeEach
  → Each test is 10-20 seconds, failure pinpoints the exact problem

Key Takeaways

  1. E2E tests simulate real users interacting with your full application stack -- frontend, backend, database, and external services together
  2. Use E2E tests sparingly for critical user flows only (login, checkout, registration); they are the slowest and most expensive tests to maintain
  3. Cypress excels in developer experience with time-travel debugging and runs inside the browser; best for Chromium-focused JavaScript teams
  4. Playwright excels in cross-browser testing (Chrome, Firefox, Safari), auto-waiting, browser context isolation, and free built-in parallel execution
  5. Selenium is the oldest and most language-diverse framework; choose it for legacy projects, real mobile device testing, or non-JavaScript teams
  6. Network interception (cy.intercept() in Cypress, page.route() in Playwright) lets you stub API responses, test error states, and assert on request bodies
  7. Use data-testid attributes for element selectors -- they are stable, explicit, and decoupled from CSS styling and DOM structure
  8. Test isolation is non-negotiable: each E2E test must create its own state and never depend on another test having run first
  9. Visual regression testing catches unintended CSS and layout changes by comparing screenshots against approved baselines
  10. CI/CD integration with sharding, retries, and artifact capture (screenshots, videos, traces) makes E2E tests practical for real teams at scale

Explain-It Challenge

Scenario: You are the lead developer on a team building an e-commerce application with a React frontend and a Node.js/Express backend. The checkout flow involves: (1) browsing products, (2) adding items to the cart, (3) viewing the cart, (4) entering shipping information, (5) entering payment details via Stripe, (6) reviewing the order summary, and (7) confirming the purchase. The application must work on Chrome, Firefox, and Safari on both desktop and mobile viewports. Your CI/CD pipeline runs on GitHub Actions.

Design the complete E2E testing strategy. Which framework do you choose and why? How do you handle the Stripe payment integration in tests (you cannot charge real cards)? How do you structure your page objects for each step of the checkout? How many E2E tests do you write and what does each one cover? How do you run these tests in CI/CD -- do you run all three browsers on every pull request, or use a different strategy? How do you handle flaky tests? How do you keep the test suite fast as it grows to 50+ E2E tests? Write the full test plan with describe/it structure and explain your reasoning for every decision.


← 3.18.c · 3.18 Overview