DEV Community

Cover image for Building a Comprehensive E2E Test Suite with Playwright: Lessons from 100+ Test Cases
Eric Elikplim Sunu
Eric Elikplim Sunu

Posted on

Building a Comprehensive E2E Test Suite with Playwright: Lessons from 100+ Test Cases

The Journey

While developing the platform for LiveSpaces, I identified a critical gap in our delivery workflow. As the application grew, our reliance on manual verification and our existing CI/CD checks proved insufficient. We needed a way to streamline release cycles without sacrificing quality.

I took the initiative to build a comprehensive End-to-End (E2E) test suite using Playwright, but I underestimated the complexity. It wasn't just about clicking buttons; it was about handling authentication flows, managing device states, and dealing with third-party integrations securely and reliably.

From authentication flows to device management, every feature presented unique challenges. Here is a retrospective on the architecture I built to solve these bottlenecks, the decisions I made, and the lessons I learned.


Prerequisites

This post focuses on advanced patterns and architectural decisions rather than basic installation. I assume you are familiar with the basics of Playwright or similar E2E tools (Cypress, Selenium).

If you are brand new to Playwright, I highly recommend checking out the official documentation first.

Quick Context:
The examples below use:

  • Framework: Playwright (Node.js)
  • Language: JavaScript/TypeScript
  • Pattern: Page Object Model (POM)

What I Built

100+ Test Cases Across 6 Major Areas

  1. Email Signup Flow — The complete journey from registration to business setup.
  2. OAuth Integration — Signup and signin flows with comprehensive mocking.
  3. Device Pairing — Device pairing logic using 6-character OTP codes.
  4. Device Management — Renaming, deleting, and managing device settings.
  5. Account Type Selection — Complex modal handling for onboarding flows.
  6. Business Information Forms — Industry, demographics, and capacity selection logic.

Key Challenges & Solutions

Challenge 1: Email Verification in Tests

The Problem: I could not access real email inboxes (Gmail/Outlook) reliably in automated tests. Using real inboxes made the tests slow, flaky, and prone to security blocks.

The Solution: I implemented a smart mocking strategy.

  • Development: I mocked the email verification endpoint to return a fixed success response.
  • Production/Integration: I documented hooks for services like Mailosaur for when real delivery testing is strictly necessary.

Code Example:

// Mock the verification code endpoint to bypass email delivery
await page.route('**/api/verification/send', route => {
  route.fulfill({
    status: 200,
    body: JSON.stringify({
      success: true,
      code: '123456' // Fixed mock code for testing
    })
  });
});
Enter fullscreen mode Exit fullscreen mode

Challenge 2: OAuth Testing

The Problem: Automating third-party providers (Google, Facebook) is often a violation of their Terms of Service. Furthermore, dealing with 2FA, captchas, or external popups leads to incredibly flaky tests.

The Solution: Comprehensive OAuth flow mocking. Instead of visiting the provider, I intercepted the callback that the provider would have sent to our application.

Code Example:

// Mock the OAuth callback
await page.route('**/api/auth/oauth/callback**', route => {
  const url = new URL(route.request().url());

  // If the app is trying to exchange a token, give it a mock user
  if (url.searchParams.has('access_token')) {
    route.fulfill({
      status: 200,
      body: JSON.stringify({
        jwt: 'mock_jwt_token',
        user: { id: 123, email: 'test@example.com' }
      })
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

Challenge 3: Integration vs. Mocked Tests

The Problem: I needed fast feedback loops during development (mocks) but actual verification before deployment (integration). I didn't want to maintain two separate test suites.

The Solution: Dual-mode test files. I designed the tests to auto-detect the presence of an authentication token in the environment variables to decide whether to mock network requests or hit the real backend.

The Logic Flow:

Dual-mode flowchart

Code Example:

const AUTH_TOKEN = process.env.AUTH_TOKEN;
const USE_REAL_BACKEND = !!AUTH_TOKEN;

test.beforeEach(async ({ page }) => {
  if (USE_REAL_BACKEND) {
    console.log('Using REAL backend');
    // No mocks; allow requests to pass through
  } else {
    console.log('Using MOCKED mode');
    // Apply mocks defined in separate helper files
    await applyNetworkMocks(page); 
  }
});
Enter fullscreen mode Exit fullscreen mode

Challenge 4: Test Data Cleanup

The Problem: Integration tests running against a real backend leave behind "zombie" data (e.g., created devices), causing subsequent tests to fail due to duplicate name errors or database bloat.

The Solution:

  • Strict try/finally blocks for guaranteed cleanup within the test.
  • (Pro Tip: For larger suites, moving this logic into Playwright Fixtures is the preferred pattern).

Code Example:

test('should create and manage device', async ({ page, request }) => {
  let testDeviceId = null;

  try {
    // 1. Create test device via API
    const response = await request.post(`${API_URL}/devices`, {
      headers: { Authorization: `Bearer ${AUTH_TOKEN}` },
      data: { name: 'Test Device', code: 'TEST123' }
    });
    testDeviceId = (await response.json()).data.id;

    // 2. Test the UI flow interacting with this device...
    await page.reload();
    await expect(page.getByText('Test Device')).toBeVisible();

  } finally {
    // 3. Always cleanup, even if the assertion above fails
    if (testDeviceId) {
      await request.delete(`${API_URL}/devices/${testDeviceId}`, {
        headers: { Authorization: `Bearer ${AUTH_TOKEN}` }
      });
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Challenge 5: Async Component Loading

The Problem: Third-party components (maps, heavy charts) often load asynchronously. Tests were failing because they tried to interact with elements before they were truly interactive.

The Solution: Utilizing Playwright's Web-First Assertions. Unlike manual timeouts (waitForTimeout), these assertions automatically retry until the condition is met or the timeout is reached.

Code Example:

const mapInput = page.getByTestId('map-input');

// Bad Practice: 
// await page.waitForTimeout(1000);

// Best Practice: Wait for state, not time
// This automatically waits for the element to be in the DOM, visible, AND enabled
await expect(mapInput).toBeEnabled({ timeout: 10000 });

// Now safe to interact
await mapInput.click();
Enter fullscreen mode Exit fullscreen mode

Challenge 6: Portal-Based UI Components

The Problem: Modern UI libraries (like Radix UI or Headless UI) often render dropdowns and modals in "portals" at the bottom of the <body> tag, physically outside the component that triggered them.

The Solution:

  • I used specific ARIA roles.
  • I scoped locators correctly to escape the current container and search the document root.

Code Example:

// The dropdown trigger is in the main container
await page.getByRole('button', { name: 'Options' }).click();

// The menu itself is in a portal at the document root
// We wait for the menu specifically to be visible
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();

// Click the item inside the menu
await menu.getByRole('menuitem', { name: 'Rename' }).click();
Enter fullscreen mode Exit fullscreen mode

What Worked Really Well

1. Page Object Model (POM) Pattern

Centralizing selectors in reusable classes was the single highest-ROI decision I made. When the UI changed (and it did often), I updated one file instead of 20.

The Architecture:

Page Object Model architecture

Code Example:

// pages/AuthPage.js
export class AuthPage {
  constructor(page) {
    this.page = page;
    this.emailInput = page.getByPlaceholder('name@example.com');
    this.loginButton = page.getByRole('button', { name: 'Login' });
  }

  async login(email, password) {
    await this.emailInput.fill(email);
    await this.loginButton.click();
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Comprehensive Documentation

Tests without documentation become legacy code the moment they are written. I created 15+ markdown files covering "How to run," "How to mock," and "How to debug." This dramatically reduced onboarding time for future developers and prevented "fear of the test suite."

3. Error Case Testing

Testing the "Happy Path" is easy. Testing the "Sad Path" is where value lies. I systematically mocked 404s, 500s, and network timeouts to ensure the UI handled errors gracefully (e.g., showing Toast notifications) rather than crashing.

4. Dual-Mode Testing

By using environment variables to switch between Mocks and Real Backends, a single test file serves as both a Unit-like Test (Mocked: fast, stable, deterministic) and an Integration Test (Real: slower, verifies backend contracts).


Key Learnings & Tips

  1. Mock External Services: Never test Google's login page. Mock the OIDC response. You are testing your app, not Google's uptime.
  2. Prioritize User-Visible Locators: Use getByRole and getByText over CSS classes (.btn-primary). This mimics how users find elements and makes tests resilient to styling refactors.
  3. Always Cleanup: Test data pollution causes flaky tests. If you create it, delete it (preferably in a finally block or a fixture).
  4. Wait Properly: Never use waitForTimeout(5000). If you find yourself doing this, you are likely missing a proper state assertion or an await expect(...).

The Numbers

  • Test Files: 10+
  • Test Cases: 100+
  • Lines of Code: ~5,000+
  • Coverage Areas: 6 major features
  • Confidence Level: High

Further Reading

If you want to dive deeper into the concepts mentioned here, I recommend these resources:


This post is based on real-world experience building a production E2E suite. All code examples are simplified for clarity.

Top comments (0)