DEV Community

Anand Rathnas
Anand Rathnas

Posted on • Originally published at jo4.io

Setting Up E2E Tests with Playwright + Auth0: The 5 Things That Took Longer Than the Tests Themselves

This article was originally published on Jo4 Blog.

We deleted approximately 10,000 lines of old EC2 Terraform infrastructure and replaced it with 1,637 lines of Playwright E2E tests that prove our entire "publisher gets paid" flow works -- from signup to settlement -- in seconds instead of months.

The tests themselves? Straightforward. The setup? Five separate rabbit holes, each deeper than the last.

TL;DR

Auth0 SPA SDK writes tokens to localStorage asynchronously after handleRedirectCallback(). Playwright's waitForLoadState('networkidle') fires before those writes complete. You need page.waitForFunction() to poll for the exact localStorage key. Also, you need a test-only controller gated by @ConditionalOnProperty to bypass time-based business logic. Here are all five things that cost me more time than writing the test assertions.

1. Auth0 Tokens and the localStorage Race

This was the biggest time sink. After Auth0 redirects back to your app, the SPA SDK calls handleRedirectCallback() which exchanges the authorization code for tokens. Those tokens get written to localStorage.

Playwright's waitForLoadState('networkidle') seems like the right thing to wait for. The token exchange is a network request, after all. But the localStorage writes that happen after the token exchange are synchronous DOM operations, not network requests. networkidle fires before they complete.

If you snapshot storageState() at this point, you capture a half-written auth state. The user key exists but the access token doesn't. Subsequent tests authenticate (they have the user identity) but get 401 on every API call (no access token).

The fix: poll for the exact key.

await page.waitForFunction(
  () => {
    const keys = Object.keys(localStorage);
    return keys.some(key =>
      key.startsWith('@@auth0spajs@@') &&
      key.includes('your-client-id') &&
      key.includes('your-audience')
    );
  },
  null,  // Must pass null as second arg
  { timeout: 30000 }
);
Enter fullscreen mode Exit fullscreen mode

That three-argument form matters. waitForFunction(fn, opts) treats opts as the argument passed to the function. You need waitForFunction(fn, null, opts) to pass options to Playwright, not to your function.

I wrote a separate deep-dive on this localStorage race condition because it deserves its own post.

2. Bypassing the 14-Day Commission Hold

Jo4 has a 14-day hold on commissions before they're eligible for approval. This is a fraud prevention measure -- it gives brands time to detect invalid conversions. After approval, settlements run on a monthly cron.

For E2E tests, you can't wait 14 days. And you definitely can't wait for the first of next month.

The solution: a test-only Spring controller that's excluded from production builds at compile time.

@RestController
@RequestMapping("/api/v1/internal/test")
@ConditionalOnProperty(name = "jo4.e2e-test-mode", havingValue = "true")
public class TestSchedulerController {

    private final CommissionService commissionService;
    private final SettlementService settlementService;

    @PostMapping("/auto-approve-commissions")
    public ResponseEntity<Void> autoApproveCommissions() {
        commissionService.bulkAutoApproveExpiredPending(Long.MAX_VALUE);
        return ResponseEntity.ok().build();
    }

    @PostMapping("/trigger-settlement")
    public ResponseEntity<Void> triggerSettlement() {
        settlementService.processMonthlySettlements();
        return ResponseEntity.ok().build();
    }
}
Enter fullscreen mode Exit fullscreen mode

@ConditionalOnProperty("jo4.e2e-test-mode") means this controller doesn't even get instantiated unless the property is explicitly set to true. It's not just gated at runtime -- Spring doesn't register the bean at all. No risk of it existing in production.

The bulkAutoApproveExpiredPending(Long.MAX_VALUE) call is the clever bit. The method normally checks if commissions are older than 14 days. By passing Long.MAX_VALUE as the threshold, every commission qualifies as "expired" and gets auto-approved immediately.

3. The Full Flow Test

With the Auth0 race condition solved and the time-bypass controller in place, the E2E test proves the entire monetization pipeline:

test('publisher gets paid - full flow', async ({ page }) => {
  // 1. Publisher signs up and creates a tracking link
  await loginAsPublisher(page);
  const trackingLink = await createTrackingLink(page, campaignId);

  // 2. Simulate a click and conversion
  await page.goto(trackingLink);
  await simulateConversion(page, trackingLink);

  // 3. Bypass the 14-day hold
  await fetch(`${API_URL}/api/v1/internal/test/auto-approve-commissions`, {
    method: 'POST',
    headers: authHeaders
  });

  // 4. Trigger settlement
  await fetch(`${API_URL}/api/v1/internal/test/trigger-settlement`, {
    method: 'POST',
    headers: authHeaders
  });

  // 5. Verify publisher sees the payout
  await page.goto('/dashboard/earnings');
  await expect(page.getByTestId('settlement-amount')).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

This runs in seconds. The real flow takes 14 days + waiting for the monthly cron. The E2E test proves every step works without the calendar being involved.

4. Deleting 10,000 Lines of Terraform

Before the Playwright setup, we had old EC2-based Terraform infrastructure for running Selenium tests. It provisioned EC2 instances, installed Chrome, managed security groups, and had its own deployment pipeline.

All of that infrastructure existed to run browser tests that were flaky, slow, and required dedicated compute.

Playwright runs locally or in CI with zero infrastructure. No EC2 instances. No security groups. No AMIs to maintain. We deleted every line of that Terraform and replaced it with a playwright.config.ts and a package.json script.

The 10,000 lines deleted versus 1,637 lines added ratio tells the story. Modern testing tools have made dedicated test infrastructure obsolete for most teams.

5. Keeping Test State Clean

The last surprise: Auth0 rate limits on the /oauth/token endpoint. If every test logs in fresh through the Auth0 UI, you'll hit rate limits fast in CI.

The fix is Playwright's storageState pattern:

// global-setup.ts — runs once before all tests
async function globalSetup() {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  await loginThroughAuth0UI(page);

  // Wait for the localStorage key (see problem #1)
  await waitForAuth0Token(page);

  // Save the authenticated state
  await page.context().storageState({ path: AUTH_STATE_PATH });
  await browser.close();
}
Enter fullscreen mode Exit fullscreen mode
// playwright.config.ts
export default defineConfig({
  use: {
    storageState: AUTH_STATE_PATH,  // Every test starts authenticated
  },
});
Enter fullscreen mode Exit fullscreen mode

One real login. Every test reuses the saved cookies and localStorage tokens. No rate limits. Tests run faster because they skip the Auth0 redirect dance.

What I'd Do Differently

If I were starting over, I'd set up the TestSchedulerController and the waitForFunction localStorage polling on day one, before writing a single test. Those two pieces are the foundation everything else depends on. I spent hours debugging test failures that were actually setup failures.

Also: don't bother with networkidle. For Auth0 flows, it's a trap. Use explicit waits for the exact state you need.

The Numbers

  • Deleted: ~10,000 lines of Terraform EC2 test infra
  • Added: 1,637 lines of Playwright tests
  • Full flow test time: ~12 seconds
  • Real flow time: 14 days + monthly cron
  • Auth0 logins per CI run: 1 (thanks to storageState)

Have you set up E2E tests with Auth0 or another identity provider? What was your biggest pain point? I'd bet it was the token storage timing.

Building jo4.io -- an affiliate marketplace tested end-to-end, from click to payout.

Top comments (0)