End-to-End Testing — Testing Full User Flows
Overview
End-to-end (E2E) tests verify that complete user workflows function correctly through the real system — browser, API, database, and all. They sit at the top of the Test Trophy because they provide the highest confidence but are also the most expensive to write, run, and maintain.
Rule of thumb: Write E2E tests only for critical user journeys — login, signup, checkout, core business flows. Do not E2E-test every feature.
Cross-Platform E2E Tools
| Tool | Platform | Browsers | Key Strengths |
|---|---|---|---|
| Playwright | Web | Chromium, Firefox, WebKit | Auto-wait, codegen, trace viewer, multi-language |
| Cypress | Web | Chromium-based | Time-travel debugging, component testing, DX |
| Selenium | Web | All browsers | Selenium Grid, mature ecosystem, language bindings |
| Appium | Mobile | iOS, Android | Cross-platform gestures, native + hybrid apps |
| Maestro | Mobile | iOS, Android | YAML-based, simple setup, fast iteration |
Playwright
Playwright is the modern standard for web E2E testing — multi-browser, auto-waiting, and built-in tooling.
Setup
# Install Playwright
npm init playwright@latest
# Install browsers
npx playwright install
# Install specific browsers
npx playwright install chromium firefox webkit
Configuration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['junit', { outputFile: 'test-results/junit.xml' }],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Navigation, Assertions, and Interactions
import { test, expect } from '@playwright/test';
test.describe('User Authentication', () => {
test('should login with valid credentials', async ({ page }) => {
// Navigate
await page.goto('/login');
// Fill form (auto-waits for elements)
await page.getByLabel('Email').fill('alice@example.com');
await page.getByLabel('Password').fill('securePassword123');
await page.getByRole('button', { name: 'Sign In' }).click();
// Assert — auto-waits for navigation
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Welcome, Alice' })).toBeVisible();
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('alice@example.com');
await page.getByLabel('Password').fill('wrongPassword');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page.getByRole('alert')).toContainText('Invalid email or password');
await expect(page).toHaveURL('/login');
});
test('should logout and redirect to login', async ({ page }) => {
// Login first (helper or fixture)
await page.goto('/login');
await page.getByLabel('Email').fill('alice@example.com');
await page.getByLabel('Password').fill('securePassword123');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page).toHaveURL('/dashboard');
// Logout
await page.getByRole('button', { name: 'User menu' }).click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await expect(page).toHaveURL('/login');
});
});
Page Object Model with Playwright
// page-objects/login-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
readonly errorAlert: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.signInButton = page.getByRole('button', { name: 'Sign In' });
this.errorAlert = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
async expectError(message: string) {
await expect(this.errorAlert).toContainText(message);
}
}
// page-objects/dashboard-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly welcomeHeading: Locator;
readonly userMenuButton: Locator;
readonly signOutItem: Locator;
constructor(page: Page) {
this.page = page;
this.welcomeHeading = page.getByRole('heading', { name: /Welcome/ });
this.userMenuButton = page.getByRole('button', { name: 'User menu' });
this.signOutItem = page.getByRole('menuitem', { name: 'Sign Out' });
}
async expectWelcome(name: string) {
await expect(this.welcomeHeading).toContainText(`Welcome, ${name}`);
}
async signOut() {
await this.userMenuButton.click();
await this.signOutItem.click();
}
}
// Using page objects in tests
import { test, expect } from '@playwright/test';
import { LoginPage } from './page-objects/login-page';
import { DashboardPage } from './page-objects/dashboard-page';
test('should login and see dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('alice@example.com', 'securePassword123');
await expect(page).toHaveURL('/dashboard');
await dashboardPage.expectWelcome('Alice');
});
Playwright Codegen
# Generate tests by recording browser interactions
npx playwright codegen http://localhost:3000
# Generate tests for a specific viewport
npx playwright codegen --viewport-size=375,812 http://localhost:3000
# Generate tests targeting specific locators
npx playwright codegen --target=playwright-test http://localhost:3000
Playwright Trace Viewer
# View trace files from failed tests
npx playwright show-trace test-results/trace.zip
# Run tests with tracing enabled
npx playwright test --trace on
Running Playwright Tests
npx playwright test # Run all tests
npx playwright test --project=chromium # Single browser
npx playwright test --headed # Visible browser
npx playwright test --ui # Interactive UI mode
npx playwright test --grep "login" # Filter by test name
npx playwright test --debug # Step-through debugger
npx playwright show-report # Open HTML report
Cypress
Cypress is a developer-friendly E2E testing tool with time-travel debugging and a built-in test runner.
Setup
npm install -D cypress
npx cypress open # Opens interactive runner
npx cypress run # Headless mode
Configuration
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
defaultCommandTimeout: 10000,
retries: {
runMode: 2,
openMode: 0,
},
setupNodeEvents(on, config) {
// Plugin setup
},
},
});
Cypress Test Example
// cypress/e2e/checkout.cy.js
describe('Checkout Flow', () => {
beforeEach(() => {
// Seed test data via API
cy.request('POST', '/api/test/seed', { fixture: 'checkout' });
cy.visit('/products');
});
it('should complete purchase from product page to confirmation', () => {
// Browse products
cy.contains('Widget Pro').click();
cy.url().should('include', '/products/');
// Add to cart
cy.get('[data-testid="add-to-cart"]').click();
cy.get('[data-testid="cart-count"]').should('have.text', '1');
// Go to cart
cy.get('[data-testid="cart-icon"]').click();
cy.url().should('include', '/cart');
cy.contains('Widget Pro').should('be.visible');
// Proceed to checkout
cy.get('[data-testid="checkout-button"]').click();
// Fill shipping info
cy.get('#shipping-name').type('Alice Smith');
cy.get('#shipping-address').type('123 Main St');
cy.get('#shipping-city').type('Springfield');
cy.get('#shipping-zip').type('62701');
// Fill payment (test card)
cy.get('#card-number').type('4242424242424242');
cy.get('#card-expiry').type('12/28');
cy.get('#card-cvc').type('123');
// Place order
cy.get('[data-testid="place-order"]').click();
// Verify confirmation
cy.url().should('include', '/order-confirmation');
cy.contains('Order Confirmed').should('be.visible');
cy.get('[data-testid="order-number"]').should('exist');
});
it('should show validation errors for empty shipping form', () => {
cy.get('[data-testid="add-to-cart"]').first().click();
cy.get('[data-testid="cart-icon"]').click();
cy.get('[data-testid="checkout-button"]').click();
// Submit without filling form
cy.get('[data-testid="place-order"]').click();
cy.get('.field-error').should('have.length.at.least', 3);
});
});
Custom Commands
// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.session([email, password], () => {
cy.visit('/login');
cy.get('#email').type(email);
cy.get('#password').type(password);
cy.get('button[type="submit"]').click();
cy.url().should('include', '/dashboard');
});
});
// Usage in tests
cy.login('alice@example.com', 'securePassword123');
npx cypress run # Headless
npx cypress run --browser chrome # Specific browser
npx cypress run --spec "cypress/e2e/checkout.cy.js"
npx cypress open # Interactive
Selenium / WebDriver
Selenium is the most established browser automation framework with support for all major browsers and languages.
Java Example
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.*;
import org.junit.jupiter.api.*;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
class LoginSeleniumTest {
private WebDriver driver;
private WebDriverWait wait;
@BeforeEach
void setUp() {
var options = new ChromeOptions();
options.addArguments("--headless=new");
options.addArguments("--no-sandbox");
options.addArguments("--disable-dev-shm-usage");
driver = new ChromeDriver(options);
wait = new WebDriverWait(driver, Duration.ofSeconds(10));
driver.manage().window().setSize(new Dimension(1280, 720));
}
@AfterEach
void tearDown() {
if (driver != null) {
driver.quit();
}
}
@Test
@DisplayName("Should login with valid credentials")
void loginWithValidCredentials() {
driver.get("http://localhost:3000/login");
driver.findElement(By.id("email")).sendKeys("alice@example.com");
driver.findElement(By.id("password")).sendKeys("securePassword123");
driver.findElement(By.cssSelector("button[type='submit']")).click();
wait.until(ExpectedConditions.urlContains("/dashboard"));
var welcomeText = driver.findElement(By.tagName("h1")).getText();
assertTrue(welcomeText.contains("Welcome, Alice"));
}
}
C# Example
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.UI;
using Xunit;
public class LoginSeleniumTests : IDisposable
{
private readonly IWebDriver _driver;
private readonly WebDriverWait _wait;
public LoginSeleniumTests()
{
var options = new ChromeOptions();
options.AddArgument("--headless=new");
options.AddArgument("--no-sandbox");
_driver = new ChromeDriver(options);
_wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10));
}
public void Dispose()
{
_driver.Quit();
_driver.Dispose();
}
[Fact]
public void Login_ValidCredentials_RedirectsToDashboard()
{
_driver.Navigate().GoToUrl("http://localhost:3000/login");
_driver.FindElement(By.Id("email")).SendKeys("alice@example.com");
_driver.FindElement(By.Id("password")).SendKeys("securePassword123");
_driver.FindElement(By.CssSelector("button[type='submit']")).Click();
_wait.Until(d => d.Url.Contains("/dashboard"));
var heading = _driver.FindElement(By.TagName("h1")).Text;
Assert.Contains("Welcome, Alice", heading);
}
}
Selenium Grid (Parallel Cross-Browser)
# docker-compose.yml for Selenium Grid
services:
selenium-hub:
image: selenium/hub:4
ports:
- "4442:4442"
- "4443:4443"
- "4444:4444"
chrome:
image: selenium/node-chrome:4
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
firefox:
image: selenium/node-firefox:4
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
edge:
image: selenium/node-edge:4
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
Appium (Mobile E2E)
Appium enables cross-platform mobile testing for iOS and Android, supporting native, hybrid, and mobile web apps.
Setup
npm install -g appium
appium driver install uiautomator2 # Android
appium driver install xcuitest # iOS
Android Example (JavaScript)
import { remote } from 'webdriverio';
describe('Mobile Login', () => {
let driver;
beforeAll(async () => {
driver = await remote({
path: '/wd/hub',
port: 4723,
capabilities: {
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:deviceName': 'Pixel_5',
'appium:app': './app/build/outputs/apk/debug/app-debug.apk',
'appium:noReset': false,
},
});
});
afterAll(async () => {
await driver.deleteSession();
});
it('should login with valid credentials', async () => {
// Find and interact with elements
const emailField = await driver.$('~email-input');
await emailField.setValue('alice@example.com');
const passwordField = await driver.$('~password-input');
await passwordField.setValue('securePassword123');
const loginButton = await driver.$('~login-button');
await loginButton.click();
// Wait for and assert dashboard
const welcomeText = await driver.$('~welcome-message');
await welcomeText.waitForExist({ timeout: 10000 });
expect(await welcomeText.getText()).toContain('Welcome, Alice');
});
it('should handle swipe gesture', async () => {
// Swipe left to navigate
await driver.touchAction([
{ action: 'press', x: 300, y: 500 },
{ action: 'wait', ms: 500 },
{ action: 'moveTo', x: 50, y: 500 },
{ action: 'release' },
]);
const nextScreen = await driver.$('~next-screen-title');
await nextScreen.waitForExist({ timeout: 5000 });
});
});
iOS Example (Java)
import io.appium.java_client.ios.IOSDriver;
import io.appium.java_client.ios.options.XCUITestOptions;
import org.junit.jupiter.api.*;
import java.net.URL;
class IOSLoginTest {
private IOSDriver driver;
@BeforeEach
void setUp() throws Exception {
var options = new XCUITestOptions()
.setDeviceName("iPhone 15")
.setPlatformVersion("17.0")
.setApp("/path/to/MyApp.app");
driver = new IOSDriver(new URL("http://localhost:4723/wd/hub"), options);
}
@AfterEach
void tearDown() {
if (driver != null) driver.quit();
}
@Test
void loginWithValidCredentials() {
driver.findElement(AppiumBy.accessibilityId("email-input"))
.sendKeys("alice@example.com");
driver.findElement(AppiumBy.accessibilityId("password-input"))
.sendKeys("securePassword123");
driver.findElement(AppiumBy.accessibilityId("login-button"))
.click();
var welcome = new WebDriverWait(driver, Duration.ofSeconds(10))
.until(d -> d.findElement(AppiumBy.accessibilityId("welcome-message")));
assertTrue(welcome.getText().contains("Welcome, Alice"));
}
}
Maestro (Mobile E2E — YAML-Based)
Maestro provides the simplest way to write mobile E2E tests using declarative YAML flows.
Example Flow
# flows/login.yaml
appId: com.example.myapp
---
- launchApp
- tapOn: "Email"
- inputText: "alice@example.com"
- tapOn: "Password"
- inputText: "securePassword123"
- tapOn: "Sign In"
- assertVisible: "Welcome, Alice"
Checkout Flow
# flows/checkout.yaml
appId: com.example.myapp
---
- launchApp
- tapOn: "Products"
- tapOn: "Widget Pro"
- tapOn: "Add to Cart"
- tapOn:
id: "cart-icon"
- assertVisible: "Widget Pro"
- tapOn: "Checkout"
- tapOn: "Name"
- inputText: "Alice Smith"
- tapOn: "Place Order"
- assertVisible: "Order Confirmed"
- takeScreenshot: order-confirmation
maestro test flows/login.yaml
maestro test flows/ # Run all flows
maestro studio # Interactive recording
maestro record flows/login.yaml # Record video
Page Object Model Pattern
The Page Object Model (POM) encapsulates page-specific locators and actions into reusable classes, reducing duplication and making tests easier to maintain.
Structure
e2e/
page-objects/
login-page.ts
dashboard-page.ts
checkout-page.ts
components/
navigation.ts
cart-sidebar.ts
tests/
auth.spec.ts
checkout.spec.ts
fixtures/
auth.fixture.ts
Benefits
| Without POM | With POM |
|---|---|
| Locators scattered across tests | Locators defined once per page |
| Selector change = update many tests | Selector change = update one page object |
| Repeated interaction code | Reusable action methods |
| Hard to read test intent | Tests read like user stories |
Critical Path Testing
Focus E2E tests on the workflows that must never break — the flows that directly impact revenue, user retention, or security.
Typical Critical Paths
| Path | Why Critical |
|---|---|
| Signup | User acquisition |
| Login / Logout | Access control |
| Checkout / Payment | Revenue |
| Password Reset | Account recovery |
| Search + Results | Core functionality |
| Notification Preferences | Compliance (GDPR) |
Example: Critical Path Test Suite
// e2e/critical-paths/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Critical Path: Authentication', () => {
test('signup → verify email → login → logout', async ({ page }) => {
// Signup
await page.goto('/signup');
await page.getByLabel('Name').fill('New User');
await page.getByLabel('Email').fill(`user-${Date.now()}@example.com`);
await page.getByLabel('Password').fill('SecureP@ss123');
await page.getByRole('button', { name: 'Create Account' }).click();
await expect(page).toHaveURL('/verify-email');
// Simulate email verification (via API in test)
// ...
// Login
await page.goto('/login');
await page.getByLabel('Email').fill(`user-${Date.now()}@example.com`);
await page.getByLabel('Password').fill('SecureP@ss123');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page).toHaveURL('/dashboard');
// Logout
await page.getByRole('button', { name: 'User menu' }).click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await expect(page).toHaveURL('/login');
});
});
CI Integration
GitHub Actions — Playwright
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run E2E Tests
run: npx playwright test
env:
CI: true
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 14
- uses: actions/upload-artifact@v4
if: failure()
with:
name: test-traces
path: test-results/
retention-days: 7
Docker Compose for E2E
# docker-compose.e2e.yml
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://test:test@db:5432/testdb
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test"]
interval: 5s
timeout: 5s
retries: 5
e2e:
image: mcr.microsoft.com/playwright:v1.48.0-jammy
depends_on:
- app
working_dir: /app
volumes:
- .:/app
command: npx playwright test
environment:
- BASE_URL=http://app:3000
Best Practices
- Write E2E tests only for critical user journeys — they are expensive to maintain.
- Use the Page Object Model to encapsulate selectors and actions — never put selectors directly in tests.
- Prefer Playwright's role-based locators (
getByRole,getByLabel) over CSS selectors — they are more resilient. - Use auto-waiting (Playwright/Cypress) instead of explicit
sleep()orwaitForTimeout()calls. - Run E2E tests against a fully deployed environment, not mocked services.
- Use
codegento bootstrap tests quickly, then refine the generated code. - Keep tests independent — each test should set up its own data and not depend on previous tests.
- Use test fixtures or API seeding to set up test data, not UI interactions.
- Capture screenshots, videos, and traces on failure for debugging.
- Run E2E in CI on merge to main (or on PR with retries) — not on every commit.
- For mobile testing, start with Maestro for simple flows and graduate to Appium for complex gestures.
- Use Selenium Grid or Playwright's built-in parallelism for cross-browser testing at scale.