| name | e2e-testing |
| description | Use when writing, improving, or debugging end-to-end tests that verify full user flows through the real system. Covers Playwright, Cypress, Selenium, Appium, and Maestro with Page Object Model pattern, critical path testing, codegen, and cross-browser/mobile strategies.
USE FOR: Playwright, Cypress, Selenium, Appium, Maestro, browser testing, mobile testing, critical path tests, page object pattern, codegen
DO NOT USE FOR: API-only testing (use api-testing or integration-testing), isolated function tests (use unit-testing), service contract verification (use contract-testing)
|
| license | MIT |
| metadata | {"displayName":"End-to-End Testing","author":"Tyler-R-Kendrick"} |
| compatibility | claude, copilot, cursor |
| references | [{"title":"Playwright Documentation","url":"https://playwright.dev/docs/intro"},{"title":"Cypress Documentation","url":"https://docs.cypress.io"},{"title":"Selenium Documentation","url":"https://www.selenium.dev/documentation/"}] |
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
npm init playwright@latest
npx playwright install
npx playwright install chromium firefox webkit
Configuration
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[] } },
{ : , : { ...devices[] } },
{ : , : { ...devices[] } },
],
: {
: ,
: ,
: !process..,
},
});
Navigation, Assertions, and Interactions
import { test, expect } from '@playwright/test';
test.describe('User Authentication', () => {
test('should login with valid credentials', async ({ page }) => {
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');
await expect(page.getByRole('heading', { name: 'Welcome, Alice' })).toBeVisible();
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
page.().();
page.().();
page.(, { : }).();
(page.()).();
(page).();
});
(, ({ page }) => {
page.();
page.().();
page.().();
page.(, { : }).();
(page).();
page.(, { : }).();
page.(, { : }).();
(page).();
});
});
Page Object Model with Playwright
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() {
..();
}
() {
..(email);
..(password);
..();
}
() {
(.).(message);
}
}
{ , , expect } ;
{
: ;
: ;
: ;
: ;
() {
. = page;
. = page.(, { : });
. = page.(, { : });
. = page.(, { : });
}
() {
(.).();
}
() {
..();
..();
}
}
{ test, expect } ;
{ } ;
{ } ;
(, ({ page }) => {
loginPage = (page);
dashboardPage = (page);
loginPage.();
loginPage.(, );
(page).();
dashboardPage.();
});
Playwright Codegen
npx playwright codegen http://localhost:3000
npx playwright codegen --viewport-size=375,812 http://localhost:3000
npx playwright codegen --target=playwright-test http://localhost:3000
Playwright Trace Viewer
npx playwright show-trace test-results/trace.zip
npx playwright test --trace on
Running Playwright Tests
npx playwright test
npx playwright test --project=chromium
npx playwright test --headed
npx playwright test --ui
npx playwright test --grep "login"
npx playwright test --debug
npx playwright show-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
npx cypress run
Configuration
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) {
},
},
});
Cypress Test Example
describe('Checkout Flow', () => {
beforeEach(() => {
cy.request('POST', '/api/test/seed', { fixture: 'checkout' });
cy.visit('/products');
});
it('should complete purchase from product page to confirmation', () => {
cy.contains('Widget Pro').click();
cy.url().should('include', '/products/');
cy.get('[data-testid="add-to-cart"]').click();
cy.get('[data-testid="cart-count"]').should('have.text', '1');
cy.get('[data-testid="cart-icon"]').click();
cy.url().should('include', '/cart');
cy.contains('Widget Pro').should('be.visible');
cy.get().();
cy.().();
cy.().();
cy.().();
cy.().();
cy.().();
cy.().();
cy.().();
cy.().();
cy.().(, );
cy.().();
cy.().();
});
(, {
cy.().().();
cy.().();
cy.().();
cy.().();
cy.().(, );
});
});
Custom Commands
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');
});
});
cy.login('alice@example.com', 'securePassword123');
npx cypress run
npx cypress run --browser chrome
npx cypress run --spec "cypress/e2e/checkout.cy.js"
npx cypress open
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();
driver.findElement(By.id()).sendKeys();
driver.findElement(By.id()).sendKeys();
driver.findElement(By.cssSelector()).click();
wait.until(ExpectedConditions.urlContains());
driver.findElement(By.tagName()).getText();
assertTrue(welcomeText.contains());
}
}
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)
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
appium driver install xcuitest
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 () => {
const emailField = await driver.$('~email-input');
await emailField.setValue('alice@example.com');
const passwordField = await driver.$('~password-input');
await passwordField.setValue('securePassword123');
const loginButton = driver.$();
loginButton.();
welcomeText = driver.$();
welcomeText.({ : });
( welcomeText.()).();
});
(, () => {
driver.([
{ : , : , : },
{ : , : },
{ : , : , : },
{ : },
]);
nextScreen = driver.$();
nextScreen.({ : });
});
});
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())
.until(d -> d.findElement(AppiumBy.accessibilityId()));
assertTrue(welcome.getText().contains());
}
}
Maestro (Mobile E2E — YAML-Based)
Maestro provides the simplest way to write mobile E2E tests using declarative YAML flows.
Example Flow
appId: com.example.myapp
---
- launchApp
- tapOn: "Email"
- inputText: "alice@example.com"
- tapOn: "Password"
- inputText: "securePassword123"
- tapOn: "Sign In"
- assertVisible: "Welcome, Alice"
Checkout Flow
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/
maestro studio
maestro record flows/login.yaml
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
import { test, expect } from '@playwright/test';
test.describe('Critical Path: Authentication', () => {
test('signup → verify email → login → logout', async ({ page }) => {
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');
await page.goto('/login');
await page.getByLabel().();
page.().();
page.(, { : }).();
(page).();
page.(, { : }).();
page.(, { : }).();
(page).();
});
});
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: ${{
Docker Compose for E2E
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() or waitForTimeout() calls.
- Run E2E tests against a fully deployed environment, not mocked services.
- Use
codegen to 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.