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.
“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.”
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
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
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.
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.
Redirect Flow: First-Time Login
ModerateThis 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
4. Scenario: Popup Flow with window.open Tracking
Many apps use the popup flow to avoid navigating the user away from the current page. Libraries like Firebase Auth, NextAuth, and Supabase Auth all support this pattern. The popup opens a new browser window to accounts.google.com, the user authenticates there, and the result is sent back to the main page via postMessage or by the popup redirecting to a callback that writes a cookie and closes itself.
In Playwright, the key is to call page.waitForEvent('popup') before clicking the button that triggers the popup. This returns a promise that resolves to the popup Page object once the new window opens. You then drive the Google login inside that popup page, and switch back to the original page to verify the session.
Popup Flow: Firebase Auth Google Sign-In
ComplexThis scenario covers the popup-based flow used by Firebase Auth, Supabase, and similar libraries. The popup opens, the user logs in, the popup closes, and the main page reflects the authenticated state.
test('Google OAuth popup: sign-in via popup window', async ({ page }) => {
await page.goto('/login');
// Start waiting for the popup BEFORE clicking the button
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: /sign in with google/i }).click();
// Capture the popup window
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
// The popup navigates to accounts.google.com
await popup.waitForURL(/accounts\.google\.com/, { timeout: 15_000 });
// Enter credentials in the popup
await popup.getByLabel('Email or phone').fill(
process.env.GOOGLE_TEST_EMAIL!
);
await popup.getByRole('button', { name: /next/i }).click();
await popup.waitForSelector('input[type="password"]', { state: 'visible' });
await popup.getByLabel('Enter your password').fill(
process.env.GOOGLE_TEST_PASSWORD!
);
await popup.getByRole('button', { name: /next/i }).click();
// Handle consent if shown
try {
await popup.waitForURL(/consent/, { timeout: 5_000 });
await popup.getByRole('button', { name: /continue|allow/i }).click();
} catch {
// Consent was already granted; popup will close automatically
}
// Wait for the popup to close (the library closes it after auth)
await popup.waitForEvent('close', { timeout: 15_000 });
// Back on the main page: verify the user is logged in
await expect(page.getByText(process.env.GOOGLE_TEST_EMAIL!))
.toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('button', { name: /sign out/i }))
.toBeVisible();
});The critical detail is the ordering: you must call waitForEvent('popup') before the click. If you click first, the popup may open and close before Playwright starts listening, and your test will hang indefinitely on a promise that never resolves.
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.
Account Chooser: Selecting the Correct Account
ModerateTo 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.
6. Scenario: Consent Screen Scope Denial
Users can click "Cancel" or deny specific scopes on the Google consent screen. Your app must handle this gracefully: show a helpful error, explain which permissions are required and why, and offer a way to try again. Many apps silently crash or show a generic error page when consent is denied. This scenario catches that.
Consent Denial: User Clicks Cancel
StraightforwardWhen the user denies consent, Google redirects back to your callback URL with an error=access_denied query parameter instead of an authorization code. Your app should detect this and redirect to a meaningful error state.
test('consent denial: user cancels and sees helpful error', 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 });
// Complete the login steps
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();
// On the consent screen, click Cancel / Deny
await page.waitForURL(/consent/, { timeout: 10_000 });
await page.getByRole('button', { name: /cancel/i }).click();
// Google redirects back with error=access_denied
// Your app should handle this and show a user-facing message
await page.waitForURL(/\/login/, { timeout: 15_000 });
await expect(
page.getByText(/permission|denied|cancelled|try again/i)
).toBeVisible();
// Verify no session was created
const cookies = await page.context().cookies();
const sessionCookie = cookies.find(c => c.name === 'session-token');
expect(sessionCookie).toBeUndefined();
});This is a straightforward test, but it catches a surprisingly common bug. Many OAuth implementations only handle the happy path and throw an unhandled exception when the callback receives an error parameter instead of a code.
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');
});The popup flow scenario:
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.
Related Guides
How to Test Auth0 Universal Login
A practical, scenario-by-scenario guide to testing Auth0 Universal Login with Playwright....
How to Test Azure AD Login
A practical, scenario-by-scenario guide to testing Azure AD (Entra ID) login with...
How to Test Clerk Sign-In
A practical, scenario-by-scenario guide to testing Clerk authentication with Playwright....
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.