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: 2in 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
webServerin 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.