Netspective Logo
Automated Testing

E2E Testing

End-to-end testing strategies to verify complete user workflows

End-to-end (E2E) tests verify that your application works correctly from a user's perspective, testing complete workflows through the actual UI and real backend services. They sit at the top of the testing pyramid and should be used sparingly for critical user journeys.

What is E2E Testing?

E2E tests simulate real user interactions:

  • Navigate through the actual application
  • Fill out forms, click buttons, submit data
  • Verify the UI reflects expected changes
  • Confirm data is persisted correctly

E2E Test Characteristics

AspectDescription
ScopeComplete application stack
SpeedSlow (seconds to minutes per test)
ReliabilityCan be flaky due to timing, network
MaintenanceHigher maintenance than unit tests
ValueHigh confidence in real user workflows

When to Write E2E Tests

Good Candidates for E2E

  • Critical user journeys: Login, checkout, signup
  • Complex workflows: Multi-step forms, wizards
  • Integration points: Payment processing, third-party auth
  • Regulatory requirements: Workflows requiring audit evidence

Skip E2E For

  • Edge cases (use unit tests)
  • API validation (use integration tests)
  • Visual styling (use visual regression tools)
  • Every possible permutation

E2E Testing with Playwright

Basic Test Structure

import { test, expect } from '@playwright/test';

test.describe('User Authentication', () => {
  test('should login with valid credentials', async ({ page }) => {
    // Navigate to login page
    await page.goto('/login');

    // Fill in credentials
    await page.fill('[data-testid="email-input"]', 'user@example.com');
    await page.fill('[data-testid="password-input"]', 'SecurePass123!');

    // Submit form
    await page.click('[data-testid="login-button"]');

    // Verify successful login
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('[data-testid="welcome-message"]'))
      .toContainText('Welcome, User');
  });

  test('should show error for invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[data-testid="email-input"]', 'user@example.com');
    await page.fill('[data-testid="password-input"]', 'wrongpassword');
    await page.click('[data-testid="login-button"]');

    await expect(page.locator('[data-testid="error-message"]'))
      .toContainText('Invalid credentials');
    await expect(page).toHaveURL('/login');
  });
});

Page Object Model

Encapsulate page interactions for maintainability:

// pages/LoginPage.ts
export class LoginPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.page.fill('[data-testid="email-input"]', email);
    await this.page.fill('[data-testid="password-input"]', password);
    await this.page.click('[data-testid="login-button"]');
  }

  async getErrorMessage() {
    return this.page.locator('[data-testid="error-message"]').textContent();
  }
}

// pages/DashboardPage.ts
export class DashboardPage {
  constructor(private page: Page) {}

  async getWelcomeMessage() {
    return this.page.locator('[data-testid="welcome-message"]').textContent();
  }

  async navigateToProfile() {
    await this.page.click('[data-testid="profile-link"]');
  }
}

// tests/auth.spec.ts
test('should login successfully', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const dashboardPage = new DashboardPage(page);

  await loginPage.goto();
  await loginPage.login('user@example.com', 'SecurePass123!');

  await expect(page).toHaveURL('/dashboard');
  const welcomeMessage = await dashboardPage.getWelcomeMessage();
  expect(welcomeMessage).toContain('Welcome');
});

Testing Complex Workflows

Multi-Step Form

test.describe('Checkout Flow', () => {
  test.beforeEach(async ({ page }) => {
    // Setup: Add item to cart and navigate to checkout
    await page.goto('/products/1');
    await page.click('[data-testid="add-to-cart"]');
    await page.goto('/checkout');
  });

  test('should complete full checkout', async ({ page }) => {
    // Step 1: Shipping Information
    await page.fill('[data-testid="shipping-name"]', 'John Doe');
    await page.fill('[data-testid="shipping-address"]', '123 Main St');
    await page.fill('[data-testid="shipping-city"]', 'New York');
    await page.selectOption('[data-testid="shipping-state"]', 'NY');
    await page.fill('[data-testid="shipping-zip"]', '10001');
    await page.click('[data-testid="continue-to-payment"]');

    // Step 2: Payment Information
    await expect(page.locator('[data-testid="payment-section"]')).toBeVisible();
    await page.fill('[data-testid="card-number"]', '4111111111111111');
    await page.fill('[data-testid="card-expiry"]', '12/25');
    await page.fill('[data-testid="card-cvc"]', '123');
    await page.click('[data-testid="continue-to-review"]');

    // Step 3: Review and Confirm
    await expect(page.locator('[data-testid="order-summary"]')).toBeVisible();
    await expect(page.locator('[data-testid="shipping-summary"]'))
      .toContainText('123 Main St');
    await page.click('[data-testid="place-order"]');

    // Confirmation
    await expect(page).toHaveURL(/\/orders\/\d+/);
    await expect(page.locator('[data-testid="order-confirmation"]'))
      .toContainText('Order Placed Successfully');
  });
});

Testing with Authentication State

// fixtures/auth.ts
import { test as base } from '@playwright/test';

type AuthFixtures = {
  authenticatedPage: Page;
  adminPage: Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    // Login before test
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'user@example.com');
    await page.fill('[data-testid="password"]', 'password123');
    await page.click('[data-testid="login"]');
    await page.waitForURL('/dashboard');

    await use(page);

    // Logout after test
    await page.goto('/logout');
  },

  adminPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'admin@example.com');
    await page.fill('[data-testid="password"]', 'adminpass123');
    await page.click('[data-testid="login"]');
    await page.waitForURL('/admin');

    await use(page);
  },
});

// tests/dashboard.spec.ts
import { test } from '../fixtures/auth';

test('authenticated user can view dashboard', async ({ authenticatedPage }) => {
  await authenticatedPage.goto('/dashboard');
  await expect(authenticatedPage.locator('h1')).toContainText('Dashboard');
});

test('admin can access admin panel', async ({ adminPage }) => {
  await adminPage.goto('/admin/users');
  await expect(adminPage.locator('table')).toBeVisible();
});

Handling Flaky Tests

Wait Strategies

// Bad: Fixed timeout
await page.waitForTimeout(2000); // Don't do this

// Good: Wait for specific conditions
await page.waitForSelector('[data-testid="results"]');
await page.waitForLoadState('networkidle');
await expect(page.locator('[data-testid="loading"]')).toBeHidden();

// Wait for API response
await Promise.all([
  page.waitForResponse(resp =>
    resp.url().includes('/api/data') && resp.status() === 200
  ),
  page.click('[data-testid="load-data"]'),
]);

Retry Configuration

// playwright.config.ts
export default {
  retries: process.env.CI ? 2 : 0,
  timeout: 30000,
  expect: {
    timeout: 5000,
  },
  use: {
    actionTimeout: 10000,
    navigationTimeout: 15000,
  },
};

Debugging Flaky Tests

// Enable tracing for failed tests
export default {
  use: {
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
};

// Run with debug mode
// npx playwright test --debug

E2E Testing for Accessibility

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('homepage should be accessible', async ({ page }) => {
  await page.goto('/');

  const accessibilityResults = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa'])
    .analyze();

  expect(accessibilityResults.violations).toEqual([]);
});

test('form should have proper labels', async ({ page }) => {
  await page.goto('/contact');

  // Check that all inputs have associated labels
  const inputs = await page.locator('input:not([type="hidden"])').all();

  for (const input of inputs) {
    const id = await input.getAttribute('id');
    const label = page.locator(`label[for="${id}"]`);
    await expect(label).toBeVisible();
  }
});

Visual Regression Testing

test('homepage visual regression', async ({ page }) => {
  await page.goto('/');

  // Full page screenshot
  await expect(page).toHaveScreenshot('homepage.png', {
    fullPage: true,
    animations: 'disabled',
  });
});

test('component visual regression', async ({ page }) => {
  await page.goto('/components/button');

  const button = page.locator('[data-testid="primary-button"]');
  await expect(button).toHaveScreenshot('primary-button.png');

  // Hover state
  await button.hover();
  await expect(button).toHaveScreenshot('primary-button-hover.png');
});

E2E Test Organization

Directory Structure

tests/
├── e2e/
│   ├── fixtures/
│   │   ├── auth.ts
│   │   └── testData.ts
│   ├── pages/
│   │   ├── LoginPage.ts
│   │   ├── DashboardPage.ts
│   │   └── CheckoutPage.ts
│   ├── specs/
│   │   ├── auth.spec.ts
│   │   ├── checkout.spec.ts
│   │   └── dashboard.spec.ts
│   └── support/
│       ├── commands.ts
│       └── helpers.ts
├── playwright.config.ts
└── package.json

Test Tagging

// Tag tests for selective execution
test('critical checkout flow @critical @checkout', async ({ page }) => {
  // ...
});

test('edge case handling @edge', async ({ page }) => {
  // ...
});

// Run only critical tests
// npx playwright test --grep @critical

CI/CD Integration

GitHub Actions Configuration

name: E2E Tests
on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Start application
        run: npm run start:test &
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

      - name: Wait for application
        run: npx wait-on http://localhost:3000

      - name: Run E2E tests
        run: npx playwright test

      - name: Upload test results
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

E2E Testing for Compliance

Audit Trail Testing

test('should log all user actions for audit', async ({ page }) => {
  await loginAsAdmin(page);

  // Perform actions that should be logged
  await page.goto('/patients/123');
  await page.click('[data-testid="view-records"]');
  await page.click('[data-testid="download-record"]');

  // Verify audit log
  await page.goto('/admin/audit-log');
  const logs = await page.locator('[data-testid="audit-entry"]').all();

  expect(logs.length).toBeGreaterThanOrEqual(2);
  await expect(logs[0]).toContainText('Viewed patient record');
  await expect(logs[1]).toContainText('Downloaded patient record');
});

Session Timeout Testing

test('should enforce session timeout', async ({ page }) => {
  await loginAsUser(page);

  // Simulate inactivity (in real test, might use time manipulation)
  await page.evaluate(() => {
    // Force session expiry for testing
    window.sessionStorage.setItem('sessionExpiry', Date.now() - 1000);
  });

  // Attempt action
  await page.goto('/dashboard');

  // Should redirect to login
  await expect(page).toHaveURL('/login');
  await expect(page.locator('[data-testid="session-expired-message"]'))
    .toBeVisible();
});

Best Practices Summary

Do

  • Focus on critical user journeys
  • Use Page Object Model
  • Wait for specific conditions, not timeouts
  • Run tests in isolated environments
  • Use data-testid attributes for selectors
  • Generate reports and screenshots

Don't

  • Test every possible scenario with E2E
  • Use fragile selectors (text content, CSS classes)
  • Share state between tests
  • Ignore flaky tests (fix or remove them)
  • Run E2E tests on every commit (use CI stages)


Compliance

This section fulfills ISO 13485 requirements for design validation (7.3.7) and control of records (4.2.4), and ISO 27001 requirements for secure development lifecycle (A.8.25) and security testing (A.8.29).

View full compliance matrix

How is this guide?

Last updated on

On this page