← All posts

2026-06-17

Making Playwright E2E Stop Flaking: API Login and Route Stubbing for Next.js Apps

PlaywrightE2ETestingNext.jsBizee

Bizee Playwright E2E API login and route stubbing cover

Part of Building Bizee — Technical Notes. See also the Bizee case study.

Introduction

The hook

Your E2E suite is “green on my machine” and red in CI. Tests wait on URLs that never settle. Login forms race hydration. One slow /api/notifications call cascades into thirty timeouts.

We hit that wall building Bizee. The fix wasn’t “add more waitForTimeout.” It was deterministic auth + stub the noise.

The objective

You will learn how to:

  1. Log in via API and inject cookies instead of clicking through the UI every test.
  2. Stub heavy API routes so UI assertions don’t depend on live data.
  3. Wait on accessible UI state (roles, headings) instead of brittle navigation races.

Prerequisites

  • Playwright basics (test, expect, fixtures)
  • A Next.js app with a JSON login API (cookie or token session)
  • Willingness to separate unit of UI under test from full-stack integration

Body

Step 1: Decide what each test is proving

For Bizee we split:

KindGoalAuthNetwork
UI workflow E2ENav, layouts, RBAC chromeAPI login + cookiesStub heavy APIs
True integrationPayments, import, webhooksReal or seededLive or contract

Most “flakes” came from treating every test like a full production rehearsal.

Step 2: API login → storage state → page cookies

Clicking “Sign in” in every test is slow and racey. We authenticate once through the API, then apply cookies to the browser context.

// tests/e2e/helpers/auth.ts (pattern)
export async function loginViaApi(
  request: APIRequestContext,
  email: string,
  password: string
) {
  const response = await request.post('/api/auth/login', {
    data: { email, password },
  });
  if (!response.ok()) {
    throw new Error(`API login failed: ${response.status()}`);
  }
  return request.storageState();
}

export async function applyLoginState(page: Page, storage: Awaited<ReturnType<typeof loginViaApi>>) {
  await page.context().addCookies(
    storage.cookies.map((cookie) => ({ ...cookie, url: process.env.NEXT_PUBLIC_APP_URL! }))
  );
}

Result: Tests start already “in the app.” Auth UI gets its own focused tests.

Step 3: Stub the chatty endpoints

Dashboards fan out: appointments, messages, notifications, cart, Stripe status. For layout/RBAC tests, return tiny JSON fixtures.

// tests/e2e/helpers/testHelpers.ts (pattern)
export async function stubHeavyRoutes(page: Page) {
  await page.route('**/api/appointments*', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ appointments: [] }),
    })
  );
  await page.route('**/api/notifications*', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ notifications: [] }),
    })
  );
  // …messages, dashboard stats, cart, stripe/subscription…
}

Stubbing isn’t cheating when the ticket says “prove the reports page renders and exports.” It’s scoping.

Step 4: Assert on settled UI, not wishful URLs

Prefer:

await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 60_000 });

Over:

await page.waitForURL('**/dashboard'); // flakes when redirects bounce
await page.waitForTimeout(5000);       // hides root causes

We also set view mode (business vs client) in localStorage before navigation so multi-role users don’t land on the wrong chrome.

Step 5: Measure the suite like a product

Bizee’s chromium wire-up landed at 76/76 after this pattern. The win wasn’t magic selectors — it was fewer moving parts per test.


Conclusion

Summary

  • API login + cookie injection beats UI login for most E2E.
  • Stub heavy routes; keep live network for true integration tests.
  • Assert accessibility roles and visible headings, not sleeps.
  • Name what each test owns so flakes have somewhere to die.

Call to Action

Explore the live app that these tests guard: bizee.life.
If you maintain a flaky Playwright suite, drop a comment with your worst offender (auth, waits, or third-party iframes).

References