Build reliable E2E test suites that validate complete user flows without becoming a maintenance nightmare. Covers Playwright, Cypress, test architecture, and flaky test management.
End-to-end (E2E) testing is the practice of validating a complete software workflow from user interaction to data persistence and external service integration. It matters when a system’s correctness depends on the interaction of multiple components—frontend, backend, database, third-party APIs, message queues, and external services—across a real network. This is not unit testing or integration testing; it is testing the actual experience a user has, with real data, real timing, and real failure scenarios.
The most common E2E pattern begins with a user journey: login, navigate to a dashboard, create a new order, submit it, and verify the confirmation email arrives. To encode this, define a test file with steps that reflect the actual sequence of actions. Use a declarative test runner such as Playwright, Cypress, or WebDriver with a test framework like Jest or pytest.
// e2e/orders/create-order.spec.js
const { test, expect } = require('@playwright/test');
test('User creates an order and receives confirmation email', async ({ page, request }) => {
await page.goto('/login');
await page.fill('input#email', '[email protected]');
await page.fill('input#password', 'secret');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await page.click('button:has-text("New Order")');
await page.fill('input#product', 'Widget Pro');
await page.fill('input#quantity', '3');
await page.click('button:has-text("Add to Cart")');
await page.click('button:has-text("Checkout")');
await page.fill('input#shipping-address', '123 Main St');
await page.fill('input#city', 'Seattle');
await page.selectOption('select#state', 'WA');
await page.fill('input#zip', '98101');
await page.click('button:has-text("Place Order")');
await expect(page).toHaveText('h1', 'Order Confirmed');
await expect(page).toHaveText('p', 'Your order #1001 has been placed.');
});
This test runs in a real browser, simulates user behavior with clicks, keypresses, and form submission, and asserts on the final state. It captures the full user experience, including network delays and asynchronous rendering.
E2E tests often fail silently due to timing issues. A common failure is missing an email delivery: the order is created, but the confirmation email arrives after the test finishes. The test passes, but a real user sees a delay.
To handle this, use explicit waits and polling. Instead of await page.waitForNavigation(), use await page.waitForSelector('text=Order Confirmed') and combine it with waitForFunction for complex conditions.
await page.waitForFunction(() => {
return document.querySelector('p').textContent.includes('Order #');
}, { timeout: 10_000 });
// Poll for email delivery via API
const email = await request.get('/api/[email protected]');
expect(email.status).toBe(200);
const emails = await email.json();
expect(emails.length).toBeGreaterThan(0);
Polling is more robust than fixed waits. The test waits until the condition is true, not just for a minimum duration. It avoids both flaky timeouts and unnecessary delays.
A test suite grows slowly into a maintenance nightmare when each test creates and cleans up its own data. The solution is to use a test data factory with preconditions.
For example, create a setupUser function that ensures a user exists in the database and returns their ID:
async function setupUser(page, email = '[email protected]', password = 'pass') {
const response = await page.request.post('/api/users', {
data: { email, password },
});
expect(response.status()).toBe(201);
const userData = await response.json();
return userData.id;
}
Use this in multiple tests:
test('User can update their profile', async ({ page }) => {
const userId = await setupUser(page, '[email protected]');
await page.goto(`/profile/${userId}`);
await page.fill('input#name', 'Alice Johnson');
await page.click('button[type="submit"]');
const profile = await page.request.get(`/api/users/${userId}`);
const data = await profile.json();
expect(data.name).toBe('Alice Johnson');
});
This approach ensures consistency and reduces test execution time. The test data is built once and reused across multiple scenarios.
E2E tests frequently depend on external services. A test that creates an order may rely on a payment gateway API, a message queue, and a database. These services must be running and in a known state.
Use docker-compose.yml to define the test environment:
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
depends_on:
- db
- payment-gateway
environment:
- DATABASE_URL=postgresql://db:5432/app
- PAYMENT_API_URL=http://payment-gateway:8080
db:
image: postgres:15
environment:
- POSTGRES_DB=app
- POSTGRES_USER=app
- POSTGRES_PASSWORD=secret
volumes:
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql
ports:
- "5432:5432"
payment-gateway:
image: ghcr.io/example/payment-gateway:latest
ports:
- "8080:8080"
command: ["--debug", "--timeout=15s"]
Run the test environment with:
docker-compose up -d db payment-gateway web
Then run tests using jest --config e2e/jest.config.js.
This ensures that every test runs against a consistent, reproducible environment. The database schema is seeded, the payment gateway is warm, and the web app is ready.
When an E2E test fails in CI, the first challenge is diagnosing the cause. A failure might be due to a flaky network, a stale database, or a misconfigured service.
To make failures actionable, capture artifacts after each test:
In Playwright, enable this with:
const { test, expect } = require('@playwright/test');
test.use({
screenshot: 'only-on-failure',
video: 'on-first-retry',
trace: 'on-first-retry',
});
test('User creates an order', async ({ page }) => {
// ... steps ...
await expect(page).toHaveURL('/dashboard');
await page.click('button:has-text("New Order")');
// ... more steps ...
});
When a test fails, CI systems like GitHub Actions or GitLab CI can attach:
This allows engineers to replay the test locally and diagnose issues without reproducing the failure manually.
As E2E testing matures, teams develop reusable test components. These are sequences of actions that are common across multiple test suites.
For example, create a LoginSteps module:
class LoginSteps {
constructor(page) {
this.page = page;
}
async login(email, password) {
await this.page.goto('/login');
await this.page.fill('input#email', email);
await this.page.fill('input#password', password);
await this.page.click('button[type="submit"]');
await this.page.waitForURL('/dashboard');
}
async logout() {
await this.page.click('button:has-text("Logout")');
await this.page.waitForURL('/login');
}
}
// Usage
test('User can create and delete an order', async ({ page }) => {
const loginSteps = new LoginSteps(page);
await loginSteps.login('[email protected]', 'secret');
await page.click('button:has-text("New Order")');
await page.fill('input#product', 'Laptop');
await page.click('button:has-text("Place Order")');
await expect(page).toHaveText('p', 'Order created successfully');
});
This pattern enables consistent behavior across projects. A test in one service can use the same login logic as a test in another service, even if the UI differs.
E2E tests are inherently flaky. A test may pass locally but fail in CI due to latency, database load, or background jobs.
Use a retry strategy with exponential backoff. Configure test runners to retry failing tests up to three times, with increasing timeouts.
In Jest, use:
{
"testRetry: 3,
"testTimeout": 30000
}
In Playwright, add retry options:
test.use({
retries: 2,
testTimeout: 30_000,
expect: {
timeout: 15_000,
},
});
When a test fails, the runner retries it immediately. If it fails again, it increases the timeout and retries again. This catches transient failures—network timeouts, background service delays, and race conditions—without requiring manual intervention.
After a test suite runs, it is crucial to clean up the database and reset the environment for the next run.
Use a beforeAll and afterAll hook to manage test data:
const { test, expect } = require('@playwright/test');
test.beforeAll(async () => {
const response = await fetch('http://localhost:3000/api/reset');
expect(response.status).toBe(200);
});
test.afterAll(async () => {
const response = await fetch('http://localhost:3000/api/cleanup');
expect(response.status).toBe(200);
});
The reset endpoint truncates all tables and reseeds the database. The cleanup endpoint deletes temporary data like uploaded files, emails, and logs.
This ensures that each test run starts from a known state, minimizing the impact of previous runs.
E2E tests must adapt to different environments: development, staging, production.
Use environment variables to configure test behavior:
const { test, expect } = require('@playwright/test');
test.use({
baseURL: process.env.TEST_BASE_URL || 'http://localhost:3000',
storageState: process.env.TEST_STORAGE_STATE || 'state.json',
});
test('User logs in with credentials', async ({ page }) => {
await page.goto('/');
await page.fill('input#email', process.env.USER_EMAIL);
await page.fill('input#password', process.env.USER_PASSWORD);
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
});
Set these in CI:
- name: Run E2E Tests
run: |
export TEST_BASE_URL=https://staging.example.com
export [email protected]
export USER_PASSWORD=secret
npx playwright test
env:
TEST_BASE_URL: ${{ secrets.TEST_BASE_URL }}
USER_EMAIL: ${{ secrets.USER_EMAIL }}
USER_PASSWORD: ${{ secrets.USER_PASSWORD }}
This allows the same test suite to run against any environment with minimal changes.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy