Authentication Testing Guide

How to Test Google OAuth Login with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing Google OAuth with Playwright. Redirect flow, popup flow, account chooser, consent screen denial, token refresh, saved auth state for CI, and the cross-origin pitfalls that silently break real suites.

4.5B+

Google accounts are used by over 4.5 billion people worldwide, making Google OAuth the most common third-party login flow your users will encounter.

0 hopsRedirect hops per flow
0OAuth flow variants
0Test scenarios covered
0%Fewer lines with Assrt

1. Why Testing Google OAuth Is Deceptively Hard

Google OAuth looks simple from a user perspective: click "Sign in with Google," pick an account, approve the consent screen, and you are back in the app. Under the hood, this flow involves at least two cross-origin navigations, a server-side token exchange, and a consent screen that Google redesigns on its own schedule. Each of these layers introduces a failure surface that a naive Playwright test will miss.

There are two fundamentally different OAuth flows your app might use, and you need to know which one you are testing. The redirect flow navigates the entire page to accounts.google.com, collects consent, and redirects back to your callback URL with an authorization code. The popup flow opens a new browser window via window.open(), runs the same consent flow inside that window, and then communicates the result back to the opener via postMessage or a redirect. In Playwright, these two flows require completely different instrumentation. The redirect flow is a same-page navigation you can track with waitForURL. The popup flow requires page.waitForEvent('popup') to capture the new window before you can interact with it.

Beyond the two flows, Google OAuth has four structural challenges that make automated testing painful. First, the consent screen is served from accounts.google.com, a domain you do not control and cannot mock without breaking the OAuth state parameter validation. Second, Google aggressively detects automated browsers and will show CAPTCHAs or block sign-in attempts in headless mode. Third, the account chooser UI renders differently depending on how many Google accounts are signed in, and the selectors change across locale and A/B test variants. Fourth, the token exchange happens server-side, so the browser never sees the access token or refresh token directly, which means your test must verify the downstream effect (a session cookie, a database row, or an API call) rather than the token itself.

A good Google OAuth test suite covers both flows, the happy path, the denial path, the multi-account chooser, token refresh, and the CI shortcut of reusing saved auth state. The sections below walk through each scenario with runnable Playwright TypeScript code.

OAuth Redirect Sequence

BrowserYour Appaccounts.google.comCallbackDashboardClick Sign In302 Redirect with client_idUser grants consentRedirect with auth codeExchange code for tokensSet session cookieRedirect to dashboard

2. Setting Up a Test Environment

Before you write any tests, set up a dedicated Google Cloud project for testing. Do not run OAuth tests against your production GCP project. A separate project keeps test users, consent screen configurations, and API quotas isolated from production.

GCP Test Project Setup

Create a new GCP project, enable the Google Identity API, and configure an OAuth 2.0 consent screen in "Testing" mode. Testing mode lets you add specific test user emails without needing Google to verify your app. Only users you explicitly add will be able to complete the OAuth flow, which is exactly what you want for automated tests.

GCP Test Project Setup Prerequisites

  • Create a dedicated GCP project (separate from production)
  • Enable the Google Identity API in your test project
  • Configure an OAuth 2.0 consent screen in "Testing" mode
  • Add test user emails to the allowed users list
  • Create OAuth 2.0 client credentials (Web application type)
  • Add http://localhost:3000/api/auth/callback/google as an authorized redirect URI
  • Create 2 to 3 dedicated Google accounts for E2E testing
  • Store test credentials in your CI secret manager
.env.test

Test User Accounts

Create two or three Google accounts specifically for end-to-end testing. These should be real Google accounts (not mocked) because Google validates the credentials server-side and there is no "test mode" equivalent to Stripe test cards. Store the passwords in your CI secret manager. Enable "less secure app access" or, better, use app passwords if the accounts have 2FA enabled. For accounts with 2FA, you can also generate backup codes and store them as secrets for emergency use in CI.

OAuth Playground for Debugging

Google provides the OAuth 2.0 Playground where you can manually step through the authorization code exchange. Use this to verify your redirect URIs and scopes are correct before writing Playwright tests. It saves hours of debugging "redirect_uri_mismatch" errors.

Playwright Configuration

Google OAuth tests must run in headed mode with a real Chromium browser. Headless mode triggers Google's bot detection far more aggressively. Configure your Playwright project accordingly.

playwright.config.ts

Google OAuth Redirect Flow

🌐

User clicks Sign In

Your app

↪️

Redirect to Google

accounts.google.com

🔒

Account chooser

Select account

Consent screen

Approve scopes

↪️

Redirect to callback

/api/auth/callback

⚙️

Token exchange

Server-side

Session created

User logged in

3. Scenario: Redirect Flow Happy Path

The redirect flow is the most common Google OAuth implementation. Your app redirects the browser to accounts.google.com with the client ID, redirect URI, and requested scopes as query parameters. Google authenticates the user, collects consent, and redirects back to your callback URL with an authorization code. Your server exchanges that code for tokens and creates a session.

1

Redirect Flow: First-Time Login

Moderate

This scenario covers a user who has never logged into your app before. They will see the account chooser (or login form), then the consent screen, then land back on your app with a new session.

Playwright Implementation

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

test('Google OAuth redirect: first-time login succeeds', async ({ page }) => {
  // 1. Start on your login page
  await page.goto('/login');
  await page.getByRole('button', { name: /sign in with google/i }).click();

  // 2. Wait for navigation to Google's auth page
  await page.waitForURL(/accounts\.google\.com/, { timeout: 15_000 });

  // 3. Enter the test account email
  await page.getByLabel('Email or phone').fill(process.env.GOOGLE_TEST_EMAIL!);
  await page.getByRole('button', { name: /next/i }).click();

  // 4. Enter the password
  await page.waitForSelector('input[type="password"]', { state: 'visible' });
  await page.getByLabel('Enter your password').fill(
    process.env.GOOGLE_TEST_PASSWORD!
  );
  await page.getByRole('button', { name: /next/i }).click();

  // 5. Handle the consent screen (first-time login shows this)
  //    Wait for either the consent page or direct redirect
  const consentOrRedirect = await Promise.race([
    page.waitForURL(/consent/, { timeout: 10_000 })
      .then(() => 'consent' as const),
    page.waitForURL(new RegExp(process.env.APP_BASE_URL!), { timeout: 10_000 })
      .then(() => 'redirected' as const),
  ]);

  if (consentOrRedirect === 'consent') {
    // Click "Continue" or "Allow" on the consent screen
    await page.getByRole('button', { name: /continue|allow/i }).click();
  }

  // 6. Wait for redirect back to your app
  await page.waitForURL(/\/dashboard/, { timeout: 30_000 });

  // 7. Assert the user is logged in
  await expect(page.getByRole('heading', { name: /dashboard/i }))
    .toBeVisible();
  await expect(page.getByText(process.env.GOOGLE_TEST_EMAIL!))
    .toBeVisible();
});

What to Assert Beyond the UI

Verify that the session cookie is set, the user record exists in your database, and the access token was stored. Poll your own user API endpoint to confirm the account was provisioned with the correct email and scopes.

// After the UI assertions, verify the session
const cookies = await page.context().cookies();
const sessionCookie = cookies.find(c => c.name === 'session-token');
expect(sessionCookie).toBeDefined();
expect(sessionCookie!.httpOnly).toBe(true);
expect(sessionCookie!.secure).toBe(true);

// Verify user was created in your database
const response = await page.request.get('/api/me');
const user = await response.json();
expect(user.email).toBe(process.env.GOOGLE_TEST_EMAIL);
expect(user.provider).toBe('google');

Google OAuth Popup Flow

🌐

User clicks Sign In

Your app (opener)

🌐

window.open()

New popup window

🔒

Google login

accounts.google.com

Consent screen

In popup window

↪️

Popup redirects

To callback URL

⚙️

postMessage

Popup to opener

Popup closes

Session active

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

5. Scenario: Account Chooser with Multiple Accounts

When a user has multiple Google accounts signed in (which is extremely common), Google shows an account chooser instead of going straight to the consent screen. Your test needs to select the correct account from the list. The challenge is that the account chooser renders differently depending on how many accounts are present, whether any have profile pictures, and which locale is active.

3

Account Chooser: Selecting the Correct Account

Moderate

To test the account chooser, you need a browser session where multiple Google accounts are already signed in. The most reliable approach is to sign into both accounts before the OAuth test begins, then verify the chooser appears and select the intended one.

test('account chooser: selects the correct account', async ({ page }) => {
  // Pre-condition: sign into two Google accounts before the test.
  // This is typically done in a globalSetup or fixture that creates
  // a storageState file with both sessions active.

  await page.goto('/login');
  await page.getByRole('button', { name: /sign in with google/i }).click();
  await page.waitForURL(/accounts\.google\.com/, { timeout: 15_000 });

  // The account chooser should display both accounts
  // Select the test account by matching on the email text
  const accountButton = page.locator(
    `[data-identifier="${process.env.GOOGLE_TEST_EMAIL}"]`
  );

  // Fallback: if data-identifier is not present, find by visible text
  const fallback = page.getByText(process.env.GOOGLE_TEST_EMAIL!, { exact: true });

  const target = await accountButton.isVisible()
    ? accountButton
    : fallback;
  await target.click();

  // May need to re-enter password for security
  try {
    await page.waitForSelector('input[type="password"]', {
      state: 'visible',
      timeout: 5_000,
    });
    await page.getByLabel('Enter your password').fill(
      process.env.GOOGLE_TEST_PASSWORD!
    );
    await page.getByRole('button', { name: /next/i }).click();
  } catch {
    // Password not required; already trusted session
  }

  // Handle consent
  try {
    await page.waitForURL(/consent/, { timeout: 5_000 });
    await page.getByRole('button', { name: /continue|allow/i }).click();
  } catch {
    // No consent needed
  }

  await page.waitForURL(/\/dashboard/, { timeout: 30_000 });
  await expect(page.getByText(process.env.GOOGLE_TEST_EMAIL!))
    .toBeVisible();
});

The data-identifier attribute on the account buttons is relatively stable, but Google does not guarantee it. The fallback that matches on visible email text provides resilience when Google changes the chooser markup.

7. Scenario: Token Refresh and Session Expiry

Google access tokens expire after one hour. If your app stores the access token and uses it for API calls (Google Calendar, Gmail, Drive), you need to test that the refresh token rotation works correctly. If the refresh fails, the user should be prompted to re-authenticate, not shown a cryptic 401 error.

Testing token refresh end-to-end requires either waiting an hour (impractical) or manipulating the token expiry. The most reliable approach is to use your app's API to artificially expire the token, then verify the refresh flow triggers correctly.

test('token refresh: expired token triggers silent refresh', async ({ page, request }) => {
  // Pre-condition: user is already logged in (use storageState)
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: /dashboard/i }))
    .toBeVisible();

  // Artificially expire the access token via your test API
  await request.post('/api/test/expire-token', {
    data: { email: process.env.GOOGLE_TEST_EMAIL },
  });

  // Trigger an action that requires a valid Google access token
  await page.getByRole('button', { name: /sync calendar/i }).click();

  // The app should silently refresh the token and complete the action
  // without redirecting to Google login again
  await expect(page.getByText(/synced successfully/i))
    .toBeVisible({ timeout: 15_000 });

  // Verify we are still on the dashboard (no redirect to /login)
  await expect(page).toHaveURL(/\/dashboard/);
});

test('session expiry: revoked refresh token forces re-login', async ({ page, request }) => {
  await page.goto('/dashboard');

  // Revoke the refresh token entirely
  await request.post('/api/test/revoke-refresh-token', {
    data: { email: process.env.GOOGLE_TEST_EMAIL },
  });

  // Trigger an action that needs a Google API call
  await page.getByRole('button', { name: /sync calendar/i }).click();

  // The app should detect the revoked token and redirect to login
  await page.waitForURL(/\/login/, { timeout: 15_000 });
  await expect(
    page.getByText(/session expired|sign in again/i)
  ).toBeVisible();
});

The /api/test/expire-token and /api/test/revoke-refresh-token endpoints should only exist in your test environment. Gate them behind an environment check like process.env.NODE_ENV === 'test'. This pattern gives you full control over token lifecycle without waiting for real expiry timers.

8. Scenario: Bypassing Google Login in CI with Saved Auth State

Running the full Google login flow on every CI run is slow, fragile (Google may show CAPTCHAs), and hammers Google's servers. The production-grade approach is to capture the authenticated browser state once, save it as a Playwright storageState file, and reuse it across test runs.

Playwright's storageState captures all cookies and localStorage entries from a browser context. By running the Google login once in a setup step and saving the state, subsequent tests can start already authenticated without touching accounts.google.com at all.

// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '.auth/google-user.json');

async function globalSetup(config: FullConfig) {
  const browser = await chromium.launch({ headless: false });
  const context = await browser.newContext();
  const page = await context.newPage();

  // Perform the full Google OAuth login once
  await page.goto(process.env.APP_BASE_URL + '/login');
  await page.getByRole('button', { name: /sign in with google/i }).click();
  await page.waitForURL(/accounts\.google\.com/);

  await page.getByLabel('Email or phone').fill(process.env.GOOGLE_TEST_EMAIL!);
  await page.getByRole('button', { name: /next/i }).click();
  await page.waitForSelector('input[type="password"]', { state: 'visible' });
  await page.getByLabel('Enter your password').fill(
    process.env.GOOGLE_TEST_PASSWORD!
  );
  await page.getByRole('button', { name: /next/i }).click();

  // Handle consent if needed
  try {
    await page.waitForURL(/consent/, { timeout: 5_000 });
    await page.getByRole('button', { name: /continue|allow/i }).click();
  } catch {
    // Already consented
  }

  // Wait for your app to finish the OAuth callback
  await page.waitForURL(/\/dashboard/, { timeout: 30_000 });

  // Save the authenticated state
  await context.storageState({ path: authFile });
  await browser.close();
}

export default globalSetup;
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  globalSetup: require.resolve('./global-setup'),
  projects: [
    {
      name: 'authenticated',
      use: {
        storageState: '.auth/google-user.json',
      },
    },
  ],
});

With this setup, every test in the "authenticated" project starts with the Google session cookies already loaded. No Google login flow runs during the test itself. This cuts test execution time dramatically and eliminates the risk of Google's bot detection blocking your CI pipeline.

Refreshing the Auth State

Google session cookies expire. The storageState file will go stale after some period (typically days, not hours). Set up a scheduled CI job that regenerates the auth file weekly, or add a check at the start of your test run that verifies the session is still valid and re-runs the login if not. Store the auth file as an encrypted CI artifact, not in your git repository.

9. Common Pitfalls That Break Real Test Suites

Bot Detection in Headless Mode

Google actively detects headless browsers. If your tests work locally but fail in CI with a "Couldn't sign you in" or CAPTCHA page, headless mode is almost certainly the cause. Run OAuth tests with headless: false and use a virtual display (Xvfb) in CI. Alternatively, use the storageState approach from Section 8 to bypass the login entirely in CI.

Consent Screen Selector Churn

Google redesigns the consent screen periodically. Buttons that were labeled "Allow" become "Continue," or the DOM structure changes from flat buttons to a multi-step flow. Use flexible regex selectors like /continue|allow/i instead of exact text matching. Better yet, use the storageState approach so your tests rarely need to interact with the consent screen at all.

Locale and A/B Test Variants

Google shows different UI variants based on locale, account age, and ongoing A/B tests. A test that works in en-US may fail in de-DE because the button text is "Weiter" instead of "Next." Pin your test browser to a specific locale by setting locale: 'en-US' in your Playwright context options. This eliminates one entire class of flakiness.

Race Condition Between Popup Close and Main Page Update

In the popup flow, there is a timing window between when the popup closes and when the main page receives the authentication result via postMessage. If your test asserts on the main page immediately after the popup closes, it may see stale state. Always use an auto-retrying assertion like toBeVisible() with a timeout rather than an immediate check after the popup close event.

Using the Wrong Redirect URI

Google's OAuth server validates the redirect URI against the exact list configured in the GCP console. A trailing slash mismatch, an HTTP vs HTTPS difference, or a port number discrepancy will cause a "redirect_uri_mismatch" error that is impossible to debug from the Playwright side because it renders as a Google error page, not your app's error page. Triple-check that your .env.test redirect URI matches what is configured in GCP, character for character.

10. Writing These Scenarios in Plain English with Assrt

Every scenario above is 30 to 70 lines of Playwright TypeScript, full of try/catch blocks for consent screens, race-condition guards for popups, and fragile selectors for Google's ever-changing UI. Multiply that by the seven scenarios you actually need and you have a maintenance burden that grows every time Google updates their login page. Assrt lets you describe the scenario in plain English, generates the equivalent Playwright code, and regenerates selectors automatically when the underlying page changes.

The redirect flow scenario from Section 3 looks like this in Assrt:

Redirect Flow: Playwright vs Assrt

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

test('Google OAuth redirect: first-time login', async ({ page }) => {
  await page.goto('/login');
  await page.getByRole('button', { name: /sign in with google/i }).click();
  await page.waitForURL(/accounts\.google\.com/, { timeout: 15_000 });
  await page.getByLabel('Email or phone').fill(process.env.GOOGLE_TEST_EMAIL!);
  await page.getByRole('button', { name: /next/i }).click();
  await page.waitForSelector('input[type="password"]', { state: 'visible' });
  await page.getByLabel('Enter your password').fill(
    process.env.GOOGLE_TEST_PASSWORD!
  );
  await page.getByRole('button', { name: /next/i }).click();

  const consentOrRedirect = await Promise.race([
    page.waitForURL(/consent/, { timeout: 10_000 })
      .then(() => 'consent' as const),
    page.waitForURL(new RegExp(process.env.APP_BASE_URL!), { timeout: 10_000 })
      .then(() => 'redirected' as const),
  ]);

  if (consentOrRedirect === 'consent') {
    await page.getByRole('button', { name: /continue|allow/i }).click();
  }

  await page.waitForURL(/\/dashboard/, { timeout: 30_000 });
  await expect(page.getByRole('heading', { name: /dashboard/i }))
    .toBeVisible();
  await expect(page.getByText(process.env.GOOGLE_TEST_EMAIL!))
    .toBeVisible();

  const cookies = await page.context().cookies();
  const sessionCookie = cookies.find(c => c.name === 'session-token');
  expect(sessionCookie).toBeDefined();
  expect(sessionCookie!.httpOnly).toBe(true);

  const response = await page.request.get('/api/me');
  const user = await response.json();
  expect(user.email).toBe(process.env.GOOGLE_TEST_EMAIL);
  expect(user.provider).toBe('google');
});
57% fewer lines

The popup flow scenario:

scenarios/google-oauth-popup.assrt

Assrt compiles each of those files into the same Playwright TypeScript you saw in Sections 3 and 4, committed to your repo as a real test you can read, run, and modify. When Google renames a button from "Allow" to "Continue" or restructures the account chooser, Assrt detects the failure, analyzes the new DOM, and opens a pull request with the updated locator. Your scenario file stays untouched.

Start with the redirect flow happy path. Once it is green in your CI, add the popup flow, then the account chooser, then consent denial, then token refresh, then the storageState shortcut. In a single afternoon you can have comprehensive Google OAuth test coverage that survives Google's next consent screen redesign without any manual maintenance.

OAuth Test Suite Run

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk