Testing

E2E Testing with Playwright: Complete Tutorial

A practical guide to end-to-end testing with Playwright including browser automation, page object models, visual testing, API mocking, and CI/CD integration.

E2E Testing with Playwright: Complete Tutorial

End-to-end tests verify that your application works from the user's perspective. They open a real browser, click buttons, fill forms, navigate pages, and assert that the right things appear on screen. Playwright makes this fast and reliable by controlling Chromium, Firefox, and WebKit through a single API.

I use Playwright on every project with a web interface. The test suite catches CSS regressions, broken forms, JavaScript errors, and authentication flows that unit and integration tests cannot reach. This guide covers everything from first test to CI/CD integration.

Prerequisites

  • Node.js installed (v14+)
  • A web application to test
  • Terminal access
  • Basic understanding of HTML and CSS selectors

Installation

npm init playwright@latest

# Or manual install
npm install --save-dev @playwright/test
npx playwright install

The playwright install command downloads browser binaries for Chromium, Firefox, and WebKit.

Project Structure

tests/
  e2e/
    home.spec.js
    login.spec.js
    checkout.spec.js
playwright.config.js

Configuration

// playwright.config.js
var config = {
  testDir: "./tests/e2e",
  timeout: 30000,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,

  use: {
    baseURL: "http://localhost:3000",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
    trace: "on-first-retry"
  },

  projects: [
    {
      name: "chromium",
      use: { browserName: "chromium" }
    },
    {
      name: "firefox",
      use: { browserName: "firefox" }
    },
    {
      name: "webkit",
      use: { browserName: "webkit" }
    }
  ],

  webServer: {
    command: "npm start",
    port: 3000,
    reuseExistingServer: !process.env.CI
  }
};

module.exports = config;

Writing Your First Test

// tests/e2e/home.spec.js
var { test, expect } = require("@playwright/test");

test("homepage has correct title", function(_a) {
  var page = _a.page;
  return page.goto("/").then(function() {
    return expect(page).toHaveTitle(/My App/);
  });
});

test("navigation links work", function(_a) {
  var page = _a.page;
  return page.goto("/").then(function() {
    return page.click('a[href="/about"]');
  }).then(function() {
    return expect(page).toHaveURL(/about/);
  }).then(function() {
    return expect(page.locator("h1")).toContainText("About");
  });
});

Running Tests

# Run all tests
npx playwright test

# Run specific file
npx playwright test tests/e2e/home.spec.js

# Run in headed mode (see the browser)
npx playwright test --headed

# Run in a specific browser
npx playwright test --project=chromium

# Debug mode
npx playwright test --debug

# Show HTML report
npx playwright show-report

Locators and Selectors

Recommended Locators

test("uses best practice locators", function(_a) {
  var page = _a.page;

  return page.goto("/signup").then(function() {
    // By role (most resilient)
    return page.getByRole("button", { name: "Sign Up" }).click();
  }).then(function() {
    // By label
    return page.getByLabel("Email address").fill("[email protected]");
  }).then(function() {
    // By placeholder
    return page.getByPlaceholder("Enter your name").fill("Shane");
  }).then(function() {
    // By text
    return page.getByText("Terms and Conditions").click();
  }).then(function() {
    // By test ID (explicit test attribute)
    return page.getByTestId("submit-button").click();
  });
});

CSS and XPath Selectors

test("uses CSS selectors", function(_a) {
  var page = _a.page;

  return page.goto("/dashboard").then(function() {
    // CSS selector
    return page.locator(".user-card .name").textContent();
  }).then(function(name) {
    expect(name).toBe("Shane");

    // CSS with nth
    return page.locator(".item-list li").nth(2).textContent();
  }).then(function(text) {
    expect(text).toContain("Item 3");

    // CSS with has
    return page.locator("tr:has(td:text('Active'))").count();
  }).then(function(count) {
    expect(count).toBeGreaterThan(0);
  });
});

Common Test Patterns

Form Testing

test("registration form validates and submits", function(_a) {
  var page = _a.page;

  return page.goto("/register").then(function() {
    // Fill the form
    return page.getByLabel("Full Name").fill("Shane Larson");
  }).then(function() {
    return page.getByLabel("Email").fill("[email protected]");
  }).then(function() {
    return page.getByLabel("Password").fill("SecurePass123!");
  }).then(function() {
    return page.getByLabel("Confirm Password").fill("SecurePass123!");
  }).then(function() {
    // Submit
    return page.getByRole("button", { name: "Create Account" }).click();
  }).then(function() {
    // Assert success
    return expect(page.getByText("Account created successfully")).toBeVisible();
  }).then(function() {
    return expect(page).toHaveURL(/dashboard/);
  });
});

test("shows validation errors for invalid input", function(_a) {
  var page = _a.page;

  return page.goto("/register").then(function() {
    return page.getByLabel("Email").fill("not-an-email");
  }).then(function() {
    return page.getByRole("button", { name: "Create Account" }).click();
  }).then(function() {
    return expect(page.getByText("Please enter a valid email")).toBeVisible();
  });
});

Authentication Flow

test.describe("Authentication", function() {

  test("logs in with valid credentials", function(_a) {
    var page = _a.page;

    return page.goto("/login").then(function() {
      return page.getByLabel("Email").fill("[email protected]");
    }).then(function() {
      return page.getByLabel("Password").fill("password123");
    }).then(function() {
      return page.getByRole("button", { name: "Log In" }).click();
    }).then(function() {
      return expect(page).toHaveURL(/dashboard/);
    }).then(function() {
      return expect(page.getByText("Welcome, User")).toBeVisible();
    });
  });

  test("shows error for invalid credentials", function(_a) {
    var page = _a.page;

    return page.goto("/login").then(function() {
      return page.getByLabel("Email").fill("[email protected]");
    }).then(function() {
      return page.getByLabel("Password").fill("wrong-password");
    }).then(function() {
      return page.getByRole("button", { name: "Log In" }).click();
    }).then(function() {
      return expect(page.getByText("Invalid email or password")).toBeVisible();
    }).then(function() {
      return expect(page).toHaveURL(/login/);
    });
  });

  test("redirects unauthenticated users to login", function(_a) {
    var page = _a.page;

    return page.goto("/dashboard").then(function() {
      return expect(page).toHaveURL(/login/);
    });
  });
});

Navigation and Routing

test("navigates through the main menu", function(_a) {
  var page = _a.page;

  return page.goto("/").then(function() {
    return page.getByRole("link", { name: "Products" }).click();
  }).then(function() {
    return expect(page).toHaveURL(/products/);
  }).then(function() {
    return expect(page.getByRole("heading", { level: 1 })).toContainText("Products");
  }).then(function() {
    return page.getByRole("link", { name: "About" }).click();
  }).then(function() {
    return expect(page).toHaveURL(/about/);
  });
});

Waiting for Elements

test("waits for dynamic content", function(_a) {
  var page = _a.page;

  return page.goto("/search").then(function() {
    return page.getByLabel("Search").fill("JavaScript");
  }).then(function() {
    return page.getByRole("button", { name: "Search" }).click();
  }).then(function() {
    // Wait for loading to finish
    return page.waitForSelector(".search-results .item");
  }).then(function() {
    return page.locator(".search-results .item").count();
  }).then(function(count) {
    expect(count).toBeGreaterThan(0);
  });
});

test("handles loading states", function(_a) {
  var page = _a.page;

  return page.goto("/data").then(function() {
    // Verify loading indicator appears
    return expect(page.getByText("Loading...")).toBeVisible();
  }).then(function() {
    // Wait for data to load
    return expect(page.getByText("Loading...")).not.toBeVisible({ timeout: 10000 });
  }).then(function() {
    // Verify data appeared
    return expect(page.locator("table tbody tr")).not.toHaveCount(0);
  });
});

Page Object Model

// tests/pages/LoginPage.js
function LoginPage(page) {
  this.page = page;
  this.emailInput = page.getByLabel("Email");
  this.passwordInput = page.getByLabel("Password");
  this.submitButton = page.getByRole("button", { name: "Log In" });
  this.errorMessage = page.getByTestId("error-message");
}

LoginPage.prototype.goto = function() {
  return this.page.goto("/login");
};

LoginPage.prototype.login = function(email, password) {
  var self = this;
  return self.emailInput.fill(email).then(function() {
    return self.passwordInput.fill(password);
  }).then(function() {
    return self.submitButton.click();
  });
};

LoginPage.prototype.getError = function() {
  return this.errorMessage.textContent();
};

module.exports = LoginPage;
// tests/pages/DashboardPage.js
function DashboardPage(page) {
  this.page = page;
  this.welcomeMessage = page.getByTestId("welcome-message");
  this.userMenu = page.getByTestId("user-menu");
  this.logoutButton = page.getByRole("button", { name: "Log Out" });
}

DashboardPage.prototype.isVisible = function() {
  return this.welcomeMessage.isVisible();
};

DashboardPage.prototype.getWelcomeText = function() {
  return this.welcomeMessage.textContent();
};

DashboardPage.prototype.logout = function() {
  var self = this;
  return self.userMenu.click().then(function() {
    return self.logoutButton.click();
  });
};

module.exports = DashboardPage;
// tests/e2e/login.spec.js
var { test, expect } = require("@playwright/test");
var LoginPage = require("../pages/LoginPage");
var DashboardPage = require("../pages/DashboardPage");

test.describe("Login Flow", function() {

  test("successful login redirects to dashboard", function(_a) {
    var page = _a.page;
    var loginPage = new LoginPage(page);
    var dashboard = new DashboardPage(page);

    return loginPage.goto().then(function() {
      return loginPage.login("[email protected]", "password123");
    }).then(function() {
      return expect(page).toHaveURL(/dashboard/);
    }).then(function() {
      return dashboard.getWelcomeText();
    }).then(function(text) {
      expect(text).toContain("Welcome");
    });
  });

  test("invalid credentials show error", function(_a) {
    var page = _a.page;
    var loginPage = new LoginPage(page);

    return loginPage.goto().then(function() {
      return loginPage.login("[email protected]", "wrong");
    }).then(function() {
      return expect(loginPage.errorMessage).toBeVisible();
    });
  });
});

API Mocking

Intercepting Network Requests

test("displays data from API", function(_a) {
  var page = _a.page;

  // Mock the API response
  return page.route("**/api/users", function(route) {
    route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify([
        { id: 1, name: "Alice", role: "admin" },
        { id: 2, name: "Bob", role: "user" }
      ])
    });
  }).then(function() {
    return page.goto("/users");
  }).then(function() {
    return expect(page.locator(".user-card")).toHaveCount(2);
  }).then(function() {
    return expect(page.getByText("Alice")).toBeVisible();
  });
});

test("handles API errors gracefully", function(_a) {
  var page = _a.page;

  return page.route("**/api/users", function(route) {
    route.fulfill({
      status: 500,
      contentType: "application/json",
      body: JSON.stringify({ error: "Internal Server Error" })
    });
  }).then(function() {
    return page.goto("/users");
  }).then(function() {
    return expect(page.getByText("Failed to load users")).toBeVisible();
  }).then(function() {
    return expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
  });
});

Modifying Responses

test("modifies API response", function(_a) {
  var page = _a.page;

  return page.route("**/api/config", function(route) {
    return route.fetch().then(function(response) {
      return response.json();
    }).then(function(json) {
      json.featureFlags.newUI = true;  // Enable feature flag
      return route.fulfill({
        response: response,
        body: JSON.stringify(json)
      });
    });
  }).then(function() {
    return page.goto("/");
  }).then(function() {
    return expect(page.locator(".new-ui-component")).toBeVisible();
  });
});

Visual Testing

Screenshots

test("homepage matches visual baseline", function(_a) {
  var page = _a.page;

  return page.goto("/").then(function() {
    return expect(page).toHaveScreenshot("homepage.png", {
      maxDiffPixelRatio: 0.01
    });
  });
});

test("component screenshot comparison", function(_a) {
  var page = _a.page;

  return page.goto("/components").then(function() {
    return expect(page.locator(".hero-section")).toHaveScreenshot("hero.png");
  });
});

First run creates baseline screenshots in a __snapshots__ directory. Subsequent runs compare against the baseline.

Complete Working Example: E-Commerce Checkout Test

// tests/e2e/checkout.spec.js
var { test, expect } = require("@playwright/test");

test.describe("Checkout Flow", function() {

  test.beforeEach(function(_a) {
    var page = _a.page;

    // Log in before each test
    return page.goto("/login").then(function() {
      return page.getByLabel("Email").fill("[email protected]");
    }).then(function() {
      return page.getByLabel("Password").fill("password123");
    }).then(function() {
      return page.getByRole("button", { name: "Log In" }).click();
    }).then(function() {
      return page.waitForURL("**/dashboard");
    });
  });

  test("completes a full purchase", function(_a) {
    var page = _a.page;

    // Browse products
    return page.goto("/products").then(function() {
      return page.getByText("Widget Pro").click();
    }).then(function() {
      // Add to cart
      return page.getByRole("button", { name: "Add to Cart" }).click();
    }).then(function() {
      return expect(page.getByTestId("cart-count")).toContainText("1");
    }).then(function() {
      // Go to cart
      return page.getByTestId("cart-icon").click();
    }).then(function() {
      return expect(page).toHaveURL(/cart/);
    }).then(function() {
      return expect(page.getByText("Widget Pro")).toBeVisible();
    }).then(function() {
      // Proceed to checkout
      return page.getByRole("button", { name: "Checkout" }).click();
    }).then(function() {
      // Fill shipping info
      return page.getByLabel("Address").fill("123 Main St");
    }).then(function() {
      return page.getByLabel("City").fill("Oakland");
    }).then(function() {
      return page.getByLabel("ZIP").fill("94611");
    }).then(function() {
      // Place order
      return page.getByRole("button", { name: "Place Order" }).click();
    }).then(function() {
      // Verify confirmation
      return expect(page.getByText("Order confirmed")).toBeVisible();
    }).then(function() {
      return expect(page.getByTestId("order-number")).toBeVisible();
    });
  });
});

Common Issues and Troubleshooting

Test is flaky — passes sometimes, fails sometimes

Timing issues with dynamic content:

Fix: Use Playwright's built-in auto-waiting instead of manual waits. Use expect(locator).toBeVisible() which retries automatically. Increase the assertion timeout for slow pages. Avoid page.waitForTimeout() (hardcoded waits).

Element not found but it is on the page

The element is inside a shadow DOM, iframe, or loads after the selector runs:

Fix: For iframes, use page.frameLocator("#iframe-id"). For shadow DOM, Playwright pierces shadow DOM by default with CSS selectors. For late-loading elements, use expect(locator).toBeVisible({ timeout: 10000 }).

Screenshots differ between local and CI

Different rendering engines, fonts, or screen sizes produce different screenshots:

Fix: Run visual tests in Docker for consistency. Use maxDiffPixelRatio or maxDiffPixels to allow small differences. Set explicit viewport sizes. Install the same fonts in CI.

Tests are slow

Each test opens a new browser context:

Fix: Use test.describe.configure({ mode: "parallel" }) to run tests concurrently. Share authentication state across tests using storageState. Use API mocking to avoid slow backend calls. Run only the browsers you need in CI.

Best Practices

  • Use role-based locators. getByRole("button", { name: "Submit" }) is more resilient than CSS selectors. It does not break when class names change.
  • Use the Page Object Model. Encapsulate page interactions in page objects. When the UI changes, update one file instead of every test.
  • Mock external APIs. E2E tests should test your application, not third-party services. Mock APIs to make tests fast, reliable, and independent of external systems.
  • Run tests in CI with retries. Browser tests can be flaky. Set retries: 2 in CI to handle occasional timing issues. Investigate tests that need retries frequently.
  • Capture screenshots and traces on failure. Playwright's trace viewer shows exactly what happened during a failed test — every click, navigation, and network request.
  • Keep E2E tests focused on user flows. Do not test every field validation with E2E — that is what unit tests are for. E2E tests should cover critical paths: login, checkout, signup, core features.
  • Use webServer in config to auto-start your app. Playwright can start your development server before running tests and stop it after. This makes the test suite self-contained.

References

Powered by Contentful