reCAPTCHA Testing Guide

How to Test reCAPTCHA v3 with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing reCAPTCHA v3 with Playwright. Token generation, Google test keys, score thresholds, server-side verification, graceful degradation, and the async pitfalls that silently break real test suites.

7M+

Over 7 million websites use reCAPTCHA. Version 3 runs invisibly in the background, returning a score between 0.0 and 1.0 with no user interaction required.

Google reCAPTCHA documentation

0 UIInvisible: no checkbox, no puzzle
0.0 to 0Score range (bot to human)
0Test scenarios covered
0%Server-side verification

1. Why Testing reCAPTCHA v3 Is Counterintuitive

If you have tested reCAPTCHA v2, you know the checkbox and the image grid challenge. Version 3 throws all of that out. There is no checkbox. There is no puzzle. There is nothing for the user to see or click. reCAPTCHA v3 runs entirely in the background: it loads a JavaScript library, observes browser behavior, and when your code calls grecaptcha.execute(), it returns a token. That token is sent to your server, which forwards it to Google's verification API, and Google responds with a score between 0.0 (very likely a bot) and 1.0 (very likely a human).

This architecture creates three testing challenges that do not exist with visible CAPTCHAs. First, there is no UI element to interact with, so traditional Playwright locators have nothing to target. Second, the interesting logic lives on your server, not in the browser. The browser only produces a token; the server decides what the score means. Third, the reCAPTCHA script loads asynchronously from google.com/recaptcha/api.js and may not be ready when your form submission fires, creating a race condition that surfaces only under slow network conditions.

The result is that reCAPTCHA v3 tests look nothing like typical UI tests. Instead of clicking buttons and checking text, you intercept network requests, mock external APIs, and assert on payloads sent between your client and server. This guide walks through every scenario you need with runnable Playwright TypeScript code and plain English Assrt equivalents.

reCAPTCHA v3 Token Flow

🌐

Page loads reCAPTCHA script

async from google.com

🔒

grecaptcha.execute() called

with site key + action

Google returns token

opaque string

⚙️

Token sent to your server

in form POST body

🔔

Server verifies with Google

POST to siteverify API

Google returns score

0.0 to 1.0 + action

reCAPTCHA v3 End-to-End Verification Sequence

BrowserGoogle reCAPTCHAYour ServerGoogle Verify APIgrecaptcha.execute(siteKey, {action})Token (opaque string)Form POST with g-recaptcha-responsePOST /recaptcha/api/siteverify{ success, score, action }200 OK or 403 Rejected

2. Setting Up the Test Environment

Google provides dedicated test keys for reCAPTCHA v3 that bypass the actual bot detection. These keys always return a token that verifies successfully. This is the foundation of every reCAPTCHA test: swap in the test keys, and Google will never block your automated browser.

Google Test Keys

KeyValueBehavior
Site Key6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhIAlways passes client-side
Secret Key6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWeAlways returns success on verify

Environment Variables

.env.test

Test Key Setup Checklist

  • Copy Google test site key: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
  • Copy Google test secret key: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
  • Add both keys to your .env.test file
  • Set APP_BASE_URL to your local dev server
  • Verify your app reads keys from environment variables (not hard-coded)
  • Confirm CI pipeline uses test keys only (never in staging or production)

Configure your application to read these from environment variables so that test, staging, and production each use different keys. Never hard-code the site key in your frontend source. Load it from your server config or a build-time variable.

Playwright Config for reCAPTCHA Tests

reCAPTCHA v3 loads a script from www.google.com and communicates with www.gstatic.com. Your Playwright config should not block these domains. If you are using route interception for other purposes, allow-list Google's reCAPTCHA endpoints explicitly.

playwright.config.ts

3. Scenario: Form Submission with reCAPTCHA Token

The most fundamental reCAPTCHA v3 test verifies that your form actually sends the token to your server. Many integrations break silently here: the form submits successfully, the reCAPTCHA token field is empty, and the server either skips verification or rejects the request. You need to confirm the token is present in the outgoing request body.

1

Verify reCAPTCHA Token Is Sent with Form Submission

Straightforward

Goal

Submit a contact form and assert that the POST request includes a non-empty g-recaptcha-response field.

Playwright Implementation

recaptcha-v3.spec.ts

Form Submission with Token: Playwright vs Assrt

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

test('contact form sends reCAPTCHA token in POST body', async ({ page }) => {
  const formRequestPromise = page.waitForRequest(
    (req) => req.url().includes('/api/contact') && req.method() === 'POST'
  );

  await page.goto('/contact');
  await page.waitForFunction(() => {
    return typeof (window as any).grecaptcha !== 'undefined'
      && typeof (window as any).grecaptcha.execute === 'function';
  }, { timeout: 10_000 });

  await page.getByLabel('Name').fill('Test User');
  await page.getByLabel('Email').fill('test@assrt.ai');
  await page.getByLabel('Message').fill('This is an automated test.');
  await page.getByRole('button', { name: /send|submit/i }).click();

  const formRequest = await formRequestPromise;
  const body = formRequest.postDataJSON();

  expect(body['g-recaptcha-response']).toBeTruthy();
  expect(typeof body['g-recaptcha-response']).toBe('string');
  expect(body['g-recaptcha-response'].length).toBeGreaterThan(10);
});
53% fewer lines

4. Scenario: Using Google Test Keys That Always Pass

Google's test site key generates tokens that always verify with a score of 1.0 when validated against the test secret key. This is the recommended approach for CI environments where you need the full reCAPTCHA flow to execute without Google flagging your automated browser as a bot. The test keys guarantee deterministic results regardless of the browser's behavior patterns.

2

Full Flow with Google Test Keys (Score 1.0)

Straightforward

Goal

Using Google test keys, submit a form, verify the token with your server, and confirm the server accepts the submission because Google returns a score of 1.0.

Playwright Implementation

test('test keys: form submission succeeds with score 1.0', async ({ page, request }) => {
  await page.goto('/contact');

  // Confirm the page loaded with the test site key
  const siteKey = await page.evaluate(() => {
    const script = document.querySelector('script[src*="recaptcha"]');
    return script?.getAttribute('src') || '';
  });
  expect(siteKey).toContain('6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI');

  // Wait for grecaptcha to be ready
  await page.waitForFunction(() => {
    return typeof (window as any).grecaptcha !== 'undefined';
  });

  // Fill and submit the form
  await page.getByLabel('Name').fill('Test User');
  await page.getByLabel('Email').fill('testkeys@assrt.ai');
  await page.getByLabel('Message').fill('Testing with Google test keys.');
  await page.getByRole('button', { name: /send|submit/i }).click();

  // Assert successful submission (your server verified with test secret)
  await expect(page.getByText(/thank you|message sent|success/i))
    .toBeVisible({ timeout: 10_000 });

  // Optionally verify via your API that the score was recorded
  const res = await request.get('/api/contact/last?email=testkeys@assrt.ai');
  const data = await res.json();
  expect(data.recaptchaScore).toBe(1.0);
});

Assrt Equivalent

# scenarios/recaptcha-test-keys-pass.assrt
describe: Form submission succeeds with Google test keys

given:
  - I am on the contact page
  - the app is configured with Google reCAPTCHA test keys

steps:
  - fill in Name with "Test User"
  - fill in Email with "testkeys@assrt.ai"
  - fill in Message with "Testing with Google test keys."
  - click "Send"

expect:
  - the page shows a success message
  - the server recorded a reCAPTCHA score of 1.0

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started
reCAPTCHA v3 Test Suite

5. Scenario: Simulating Low Scores and Bot Detection

Google's test keys always return success. They do not simulate a low score. To test what happens when reCAPTCHA flags a visitor as a bot, you need to mock the server-side verification response. This is the only reliable way to exercise your score threshold logic in automated tests.

The pattern is straightforward: intercept the outgoing request from your server to Google's siteverify endpoint and return a crafted response with the score you want. Since Playwright runs in the browser and cannot intercept server-to-server requests directly, you configure your application to use a mock verification endpoint during tests, or you use Playwright's route interception at the browser level combined with a test-mode flag in your server.

Server-Side Verification Flow (Mocked for Low Score)

🌐

Browser sends token

in form POST

⚙️

Your server receives token

extracts from body

🔔

Server calls siteverify

mocked in test mode

Mock returns score 0.1

simulates bot

🔒

Server rejects submission

returns 403

3

Server Rejects Low-Score Submission

Moderate

Goal

Simulate a reCAPTCHA score of 0.1 (bot) and verify your server rejects the form submission with an appropriate error message.

Playwright Implementation

test('low score: server rejects submission when score is 0.1', async ({ page }) => {
  // Your server should support a test header or env var that overrides
  // the reCAPTCHA verification response for testing purposes.
  // Option A: Use a custom header to tell your server to mock the score.
  await page.setExtraHTTPHeaders({
    'X-Recaptcha-Test-Score': '0.1',
  });

  await page.goto('/contact');

  await page.waitForFunction(() => {
    return typeof (window as any).grecaptcha !== 'undefined';
  });

  await page.getByLabel('Name').fill('Bot User');
  await page.getByLabel('Email').fill('bot@assrt.ai');
  await page.getByLabel('Message').fill('I am definitely not a bot.');
  await page.getByRole('button', { name: /send|submit/i }).click();

  // Assert the server rejected the submission
  await expect(page.getByText(/suspicious activity|verification failed|try again/i))
    .toBeVisible({ timeout: 10_000 });

  // Verify the form is still visible (user can retry)
  await expect(page.getByLabel('Message')).toBeVisible();
});

// Option B: Mock at the API level using Playwright's request interception
test('low score via API mock: server returns 403', async ({ page }) => {
  // Intercept your own server's contact endpoint to simulate the 403
  await page.route('**/api/contact', async (route) => {
    const request = route.request();
    if (request.method() === 'POST') {
      // Forward the request but override the response
      const response = await route.fetch();
      const status = response.status();

      // If your server returns 403 for low scores, verify that behavior
      // by mocking the verification endpoint your server calls
      await route.fulfill({
        status: 403,
        contentType: 'application/json',
        body: JSON.stringify({
          error: 'reCAPTCHA verification failed',
          score: 0.1,
        }),
      });
    }
  });

  await page.goto('/contact');
  await page.waitForFunction(() => typeof (window as any).grecaptcha !== 'undefined');

  await page.getByLabel('Name').fill('Bot User');
  await page.getByLabel('Email').fill('bot2@assrt.ai');
  await page.getByLabel('Message').fill('Testing low score rejection.');
  await page.getByRole('button', { name: /send|submit/i }).click();

  await expect(page.getByText(/verification failed|try again/i))
    .toBeVisible({ timeout: 10_000 });
});

Assrt Equivalent

# scenarios/recaptcha-low-score.assrt
describe: Server rejects form submission when reCAPTCHA score is low

given:
  - I am on the contact page
  - the reCAPTCHA verification is mocked to return score 0.1

steps:
  - fill in Name with "Bot User"
  - fill in Email with "bot@assrt.ai"
  - fill in Message with "I am definitely not a bot."
  - click "Send"

expect:
  - the page shows a verification failed error
  - the form is still visible so the user can retry

6. Scenario: reCAPTCHA Load Failure Graceful Degradation

The reCAPTCHA JavaScript loads from Google's servers. If those servers are unreachable (corporate firewalls, ad blockers, network outages, or users in regions where Google is blocked), the grecaptcha object never appears on the page. Your application needs a fallback strategy, and your tests need to verify it works.

Common fallback strategies include: allowing the form to submit without a token (with server-side rate limiting as a safety net), showing a traditional CAPTCHA challenge, or displaying a clear error message explaining the issue. Whatever your strategy is, you need a test that proves it activates correctly.

4

Form Degrades Gracefully When reCAPTCHA Fails to Load

Complex

Goal

Block the reCAPTCHA script from loading and verify the form still submits (or shows a meaningful fallback message).

Playwright Implementation

recaptcha-v3.spec.ts

Assrt Equivalent

# scenarios/recaptcha-blocked.assrt
describe: Form degrades gracefully when reCAPTCHA script is blocked

given:
  - all requests to google.com/recaptcha are blocked
  - I am on the contact page

steps:
  - fill in Name with "Blocked User"
  - fill in Email with "blocked@assrt.ai"
  - fill in Message with "reCAPTCHA could not load."
  - click "Send"

expect:
  - the page shows either a success message or a clear explanation
  - the page does not show an unhandled JavaScript error

7. Scenario: Network Request Assertion

Beyond verifying that the token exists, you should assert the exact shape of the request your client sends to your server. reCAPTCHA tokens are long, opaque strings that expire after two minutes. Your tests should confirm the token arrives in the expected field, with the expected content type, within the expected time window.

5

Assert Token Format and Request Shape

Moderate

Goal

Capture the outgoing form request, validate the token format, and verify that the request includes all expected fields alongside the reCAPTCHA response.

Playwright Implementation

test('form POST includes valid reCAPTCHA token and all fields', async ({ page }) => {
  const requests: Array<{ url: string; body: any; headers: Record<string, string> }> = [];

  // Listen to all outgoing requests to your API
  page.on('request', (req) => {
    if (req.url().includes('/api/contact') && req.method() === 'POST') {
      requests.push({
        url: req.url(),
        body: req.postDataJSON(),
        headers: req.headers(),
      });
    }
  });

  await page.goto('/contact');
  await page.waitForFunction(() => typeof (window as any).grecaptcha !== 'undefined');

  await page.getByLabel('Name').fill('Network Test');
  await page.getByLabel('Email').fill('network@assrt.ai');
  await page.getByLabel('Message').fill('Verifying request shape.');
  await page.getByRole('button', { name: /send|submit/i }).click();

  // Wait for the request to be captured
  await expect.poll(() => requests.length, { timeout: 10_000 }).toBeGreaterThan(0);

  const req = requests[0];

  // Verify content type
  expect(req.headers['content-type']).toContain('application/json');

  // Verify all form fields are present
  expect(req.body.name).toBe('Network Test');
  expect(req.body.email).toBe('network@assrt.ai');
  expect(req.body.message).toBe('Verifying request shape.');

  // Verify reCAPTCHA token format
  const token = req.body['g-recaptcha-response'];
  expect(token).toBeTruthy();
  expect(typeof token).toBe('string');
  // reCAPTCHA tokens are typically 400+ characters
  expect(token.length).toBeGreaterThan(100);
});

Assrt Equivalent

# scenarios/recaptcha-request-shape.assrt
describe: Form POST includes valid reCAPTCHA token and all fields

given:
  - I am on the contact page
  - reCAPTCHA has loaded

steps:
  - fill in Name with "Network Test"
  - fill in Email with "network@assrt.ai"
  - fill in Message with "Verifying request shape."
  - click "Send"

expect:
  - the POST to /api/contact has content-type application/json
  - the body includes name, email, and message fields
  - the body includes g-recaptcha-response as a string longer than 100 characters

8. Scenario: Multiple Actions on the Same Page

reCAPTCHA v3 supports action names: when you call grecaptcha.execute(siteKey, {action: 'login'}), the action string is included in the verification response from Google. This lets your server distinguish between a login attempt, a signup, and a contact form submission, all protected by the same reCAPTCHA site key. Google recommends using different actions for each user flow so you can analyze score distributions per action in the reCAPTCHA admin console.

The testing challenge is verifying that each form on a page (or each route in a single-page app) sends the correct action name. A mismatch between the client action and what your server expects will cause silent verification failures in production.

6

Verify Correct Action Names for Login, Signup, and Contact

Moderate

Goal

On a page with multiple forms (or across multiple routes), verify each form sends the correct reCAPTCHA action name in its request.

Playwright Implementation

const actionScenarios = [
  {
    route: '/login',
    action: 'login',
    fill: async (page: any) => {
      await page.getByLabel('Email').fill('action@assrt.ai');
      await page.getByLabel('Password').fill('TestPass123!');
    },
    submitLabel: /log in|sign in/i,
    apiEndpoint: '/api/auth/login',
  },
  {
    route: '/signup',
    action: 'signup',
    fill: async (page: any) => {
      await page.getByLabel('Email').fill('action@assrt.ai');
      await page.getByLabel('Password').fill('TestPass123!');
      await page.getByLabel('Confirm password').fill('TestPass123!');
    },
    submitLabel: /sign up|create account/i,
    apiEndpoint: '/api/auth/signup',
  },
  {
    route: '/contact',
    action: 'contact',
    fill: async (page: any) => {
      await page.getByLabel('Name').fill('Action Test');
      await page.getByLabel('Email').fill('action@assrt.ai');
      await page.getByLabel('Message').fill('Testing action names.');
    },
    submitLabel: /send|submit/i,
    apiEndpoint: '/api/contact',
  },
];

for (const scenario of actionScenarios) {
  test(`reCAPTCHA action is "${scenario.action}" on ${scenario.route}`, async ({ page }) => {
    const requestPromise = page.waitForRequest(
      (req: any) => req.url().includes(scenario.apiEndpoint) && req.method() === 'POST'
    );

    await page.goto(scenario.route);
    await page.waitForFunction(() => typeof (window as any).grecaptcha !== 'undefined');

    await scenario.fill(page);
    await page.getByRole('button', { name: scenario.submitLabel }).click();

    const req = await requestPromise;
    const body = req.postDataJSON();

    // Verify the action name matches what the server expects
    expect(body['recaptcha_action'] || body['action']).toBe(scenario.action);

    // Also verify the token is present
    expect(body['g-recaptcha-response']).toBeTruthy();
  });
}

Assrt Equivalent

# scenarios/recaptcha-multi-action.assrt
describe: Each form sends the correct reCAPTCHA action name

scenarios:
  - route: /login
    action: login
    steps:
      - fill in Email and Password
      - click "Log in"
    expect:
      - the POST to /api/auth/login includes recaptcha_action "login"

  - route: /signup
    action: signup
    steps:
      - fill in Email, Password, and Confirm password
      - click "Sign up"
    expect:
      - the POST to /api/auth/signup includes recaptcha_action "signup"

  - route: /contact
    action: contact
    steps:
      - fill in Name, Email, and Message
      - click "Send"
    expect:
      - the POST to /api/contact includes recaptcha_action "contact"

9. Common Pitfalls

Async Load Race Condition

The most common reCAPTCHA v3 bug is calling grecaptcha.execute() before the script has finished loading. In a fast local environment, the script loads in milliseconds and the race never surfaces. In CI, on a cold start, or with network throttling, the form submits before the token is generated. Always use grecaptcha.ready() or the onload callback parameter in the script tag. Your Playwright tests should include a waitForFunction that checks for the grecaptcha object before interacting with any form.

Expired Tokens

reCAPTCHA tokens expire two minutes after generation. If your user fills a long form slowly, or your test has a deliberate pause between generating the token and submitting, the server-side verification will fail with a timeout-or-duplicate error code. The fix is to generate the token at submission time, not at page load. Test this explicitly by adding a two-minute delay between page load and form submission, then asserting your application handles the expired token by regenerating it.

Action Name Mismatch

The action string in the client-side grecaptcha.execute() call must match what your server expects in the verification response. If your client sends action: "contact_form" but your server checks for action: "contact", the verification succeeds (Google does not enforce action matching), but your server logic rejects the request. This is a silent bug that only surfaces in production. The multi-action test in Section 8 catches it.

Test Key vs Real Key Confusion

Google's test keys always return success. If your CI uses test keys (correct) but your staging environment also uses test keys (wrong), you lose your last line of defense before production. Your deployment pipeline should assert that production and staging use real keys, and only the test environment uses the 6LeIxAc... keys. A simple check in your CI config can prevent this: read the site key from the environment and fail the build if a non-test deploy uses a test key.

Score Threshold Configuration Drift

reCAPTCHA v3 does not enforce score thresholds. Google returns a score, and your server decides what to do with it. If your threshold is 0.5 in code but a developer changes it to 0.7 in a config file, legitimate users start getting blocked. Test your threshold logic explicitly by mocking verification responses with scores just above and just below the threshold. Use 0.49 and 0.51 for a threshold of 0.5, for example, and assert that the first is rejected and the second is accepted.

10. Writing These Scenarios in Plain English with Assrt

The scenarios above span 300+ lines of Playwright TypeScript. Each one requires understanding network interception, async timing, and the reCAPTCHA verification protocol. Multiply that by the number of forms on your site and the various failure modes, and you have a maintenance burden that grows with every new protected endpoint.

Assrt lets you describe each scenario in plain English. It generates the Playwright TypeScript code, commits it to your repo, and regenerates it when your page structure changes. Here is the graceful degradation scenario from Section 6, written as an Assrt file:

# scenarios/recaptcha-degradation.assrt
describe: Form works when reCAPTCHA fails to load

given:
  - all requests to google.com/recaptcha are blocked
  - I am on the contact page

steps:
  - verify that grecaptcha is NOT available on the page
  - fill in the contact form with test data
  - click "Send"

expect:
  - the page shows either a success message or a clear fallback
  - no unhandled JavaScript errors appear in the console

Assrt compiles that file into the same Playwright TypeScript with route blocking, waitForFunction checks, and conditional assertions you saw in Section 6. When your fallback strategy changes or your form fields are renamed, Assrt detects the failure, analyzes the updated DOM, and opens a pull request with the fixed locators. Your scenario file stays untouched.

The same applies to network assertion scenarios. Instead of manually wiring up request listeners and parsing JSON bodies, you describe the expected request shape in natural language. Assrt generates the request interception, the body parsing, and the assertions. This is particularly valuable for reCAPTCHA tests because the interesting assertions are all about request payloads and server responses, not visible UI elements.

Start with the token generation test from Section 3. Once it passes in your CI, add the low score mock, then graceful degradation, then multi-action verification. In an afternoon you can have comprehensive reCAPTCHA v3 coverage that most teams never build because the boilerplate for network interception and server mocking is too tedious to write by hand.

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