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.
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
| Scenario | Why E2E |
|---|---|
| User registration and login | Full auth flow: form validation, API call, token storage, redirect, session persistence |
| Checkout and payment | Critical revenue path that spans multiple pages and services |
| Multi-step forms or wizards | State must persist across pages; only E2E captures this |
| OAuth and SSO flows | Involves redirects to external identity providers and back |
| Smoke tests after deployment | Quick sanity check that the application boots and key features work |
When E2E Tests Are NOT the Right Choice
| Scenario | Better Alternative |
|---|---|
| Testing a single utility function | Unit test with Jest |
| Testing API response shape and status codes | API test with Supertest |
| Checking every form validation rule | Unit test the validator function |
| Testing 50 edge cases of a pricing calculation | Unit test |
| Verifying database query correctness | Integration 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 Strategy | Resilience | Readability | Recommended? |
|---|---|---|---|
data-testid or data-cy | High | High | Yes -- best practice |
Semantic role/aria (role, aria-label) | High | Medium | Yes -- also tests accessibility |
placeholder, name attributes | Medium | Medium | Sometimes -- for form inputs |
| CSS class names | Low | Medium | No -- coupled to styling |
| Element IDs | Medium | Medium | Rarely -- IDs may change |
| Tag + nth-child / XPath | Very Low | Low | Never |
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:
- Attached to the DOM
- Visible (not hidden by CSS)
- Stable (not mid-animation)
- Able to receive events (not blocked by an overlay)
- 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
| Feature | Cypress | Playwright | Selenium |
|---|---|---|---|
| Language | JavaScript / TypeScript only | JS, TS, Python, Java, C# | Java, Python, JS, C#, Ruby, and more |
| Browser Support | Chromium, Firefox, Edge, WebKit (experimental) | Chromium, Firefox, WebKit (full native support) | All major browsers via WebDriver |
| Architecture | Runs inside the browser | Controls browser via DevTools Protocol | Controls browser via WebDriver protocol |
| Auto-Waiting | Built-in, implicit in assertions | Built-in, explicit in actions and assertions | Manual waits required (WebDriverWait) |
| Parallel Execution | Paid via Cypress Cloud, or manual splitting | Built-in and free, sharding across machines | Via Selenium Grid |
| Multi-Tab / Multi-Domain | Limited (single origin historically) | Full support (multiple pages, contexts) | Full support |
| Mobile Testing | Viewport emulation only | Viewport emulation + device profiles | Real device support via Appium |
| Speed | Fast | Very fast | Slower (WebDriver protocol overhead) |
| Community | Very large, mature | Growing rapidly, Microsoft-backed | Largest (oldest framework, 2004) |
| Debugging | Time-travel debugger (excellent) | Trace viewer, VS Code extension | Browser DevTools |
| Setup | Single npm install | Single install + browser downloads | Requires WebDriver binaries, more config |
| Test Isolation | Manual (clear cookies/storage) | Automatic (fresh context per test) | Manual |
| Network Mocking | cy.intercept() | page.route() | Requires external proxy |
| Code Generation | Cypress Studio (limited) | npx playwright codegen (powerful) | Selenium IDE (browser extension) |
| Cost | Free (parallel requires paid Cloud) | Completely free | Free (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
| Practice | Why |
|---|---|
| Run E2E tests AFTER unit and API tests pass | Fail fast -- do not waste 20 minutes on slow E2E tests if fast tests already fail |
| Use separate CI jobs for each test tier | Different 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 minutes | Keep feedback time under 10 minutes |
| Set retries to 1-2 in CI | Handle transient failures without masking real bugs |
| Run full browser matrix on nightly schedule, Chromium only on PRs | Balance thoroughness with PR speed |
Use webServer in Playwright config | Automatically 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
| Tool | Description | Cost |
|---|---|---|
| Percy (BrowserStack) | Cloud-based visual testing, renders across browsers, smart diffing | Paid (free tier available) |
| Chromatic (Storybook) | Visual testing for component libraries, compares stories | Paid (free tier available) |
| Applitools Eyes | AI-powered visual testing, ignores irrelevant differences | Paid |
| BackstopJS | Open-source visual regression with configurable viewports | Free |
| Playwright built-in | Local screenshot comparison, no cloud needed | Free |
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 Cause | Solution |
|---|---|
| Element not yet visible | Use auto-waiting (Playwright) or increase timeout (Cypress) |
| CSS animation in progress | Disable animations in test mode: * { transition: none !important; animation: none !important; } |
| Race condition with API response | Wait for the network response before asserting: cy.wait('@apiCall') or page.waitForResponse() |
| Shared database state between tests | Reset database before each test (API call or beforeEach hook) |
Hardcoded sleep(2000) calls | Replace with explicit waits for specific conditions |
| Random test data collisions | Use unique identifiers per test (timestamps, UUIDs) |
| Third-party service unreliable | Stub external API calls in tests |
| Browser window not focused | Use 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
- E2E tests simulate real users interacting with your full application stack -- frontend, backend, database, and external services together
- Use E2E tests sparingly for critical user flows only (login, checkout, registration); they are the slowest and most expensive tests to maintain
- Cypress excels in developer experience with time-travel debugging and runs inside the browser; best for Chromium-focused JavaScript teams
- Playwright excels in cross-browser testing (Chrome, Firefox, Safari), auto-waiting, browser context isolation, and free built-in parallel execution
- Selenium is the oldest and most language-diverse framework; choose it for legacy projects, real mobile device testing, or non-JavaScript teams
- Network interception (
cy.intercept()in Cypress,page.route()in Playwright) lets you stub API responses, test error states, and assert on request bodies - Use
data-testidattributes for element selectors -- they are stable, explicit, and decoupled from CSS styling and DOM structure - Test isolation is non-negotiable: each E2E test must create its own state and never depend on another test having run first
- Visual regression testing catches unintended CSS and layout changes by comparing screenshots against approved baselines
- 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.