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

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:
- Log in via API and inject cookies instead of clicking through the UI every test.
- Stub heavy API routes so UI assertions don’t depend on live data.
- 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:
| Kind | Goal | Auth | Network |
|---|---|---|---|
| UI workflow E2E | Nav, layouts, RBAC chrome | API login + cookies | Stub heavy APIs |
| True integration | Payments, import, webhooks | Real or seeded | Live 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).