Files
2025-11-30 08:24:31 +08:00

10 KiB

Turnstile Testing Guide

Complete testing strategies for E2E, unit, and integration tests

Official Docs: https://developers.cloudflare.com/turnstile/troubleshooting/testing/


Quick Reference: Dummy Credentials

Sitekeys (Client-Side)

const TEST_SITEKEYS = {
  ALWAYS_PASS: '1x00000000000000000000AA',           // Visible, always passes
  ALWAYS_BLOCK: '2x00000000000000000000AB',          // Visible, always blocks
  ALWAYS_PASS_INVISIBLE: '1x00000000000000000000BB', // Invisible, always passes
  ALWAYS_BLOCK_INVISIBLE: '2x00000000000000000000BB',// Invisible, always blocks
  FORCE_INTERACTIVE: '3x00000000000000000000FF',     // Visible, forces checkbox
}

Secret Keys (Server-Side)

const TEST_SECRET_KEYS = {
  ALWAYS_PASS: '1x0000000000000000000000000000000AA',  // success: true
  ALWAYS_FAIL: '2x0000000000000000000000000000000AA',  // success: false
  TOKEN_SPENT: '3x0000000000000000000000000000000AA',  // "already spent" error
}

Dummy Token

const DUMMY_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'

CRITICAL:

  • Dummy sitekeys generate XXXX.DUMMY.TOKEN.XXXX
  • Dummy secret keys ONLY accept dummy token
  • Production secret keys REJECT dummy token
  • Real tokens FAIL with dummy secret keys

Environment Detection Patterns

Pattern 1: Request Headers

function isTestEnvironment(request: Request): boolean {
  return request.headers.get('x-test-environment') === 'true'
}

// Usage in Cloudflare Worker
if (isTestEnvironment(request)) {
  secretKey = TEST_SECRET_KEYS.ALWAYS_PASS
}

Pattern 2: IP Address

function isTestEnvironment(request: Request): boolean {
  const ip = request.headers.get('CF-Connecting-IP') || ''
  const testIPs = ['127.0.0.1', '::1', 'localhost']
  return testIPs.includes(ip)
}

Pattern 3: Query Parameter

function isTestEnvironment(request: Request): boolean {
  const url = new URL(request.url)
  return url.searchParams.get('test') === 'true'
}

Pattern 4: Environment Variable

const sitekey = process.env.NODE_ENV === 'test'
  ? TEST_SITEKEYS.ALWAYS_PASS
  : process.env.TURNSTILE_SITE_KEY

Playwright Testing

Setup

// playwright.config.ts
export default defineConfig({
  use: {
    baseURL: 'http://localhost:5173',
    extraHTTPHeaders: {
      'x-test-environment': 'true', // Auto-use test credentials
    },
  },
})

Basic Test

// tests/contact-form.spec.ts
import { test, expect } from '@playwright/test'

test('submits contact form with Turnstile', async ({ page }) => {
  await page.goto('/contact')

  // Fill form
  await page.fill('input[name="email"]', 'test@example.com')
  await page.fill('textarea[name="message"]', 'Test message')

  // Turnstile auto-solves with dummy token in test mode
  await page.click('button[type="submit"]')

  // Verify success
  await expect(page.locator('.success-message')).toBeVisible()
})

Advanced: Multiple Scenarios

test('handles Turnstile failure gracefully', async ({ page, context }) => {
  // Override to use "always fail" sitekey
  await context.route('**/api.js', route => {
    const FAIL_SITEKEY = '2x00000000000000000000AB'
    // Inject failing sitekey
    route.continue()
  })

  await page.goto('/contact')
  await page.fill('input[name="email"]', 'test@example.com')
  await page.click('button[type="submit"]')

  await expect(page.locator('.error-message')).toContainText('verification failed')
})

Cypress Testing

Setup

// cypress.config.ts
export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:5173',
    setupNodeEvents(on, config) {
      // Set test header on all requests
      on('before:browser:launch', (browser, launchOptions) => {
        launchOptions.args.push('--disable-web-security')
        return launchOptions
      })
    },
  },
})

Test Example

// cypress/e2e/turnstile.cy.ts
describe('Turnstile Form', () => {
  beforeEach(() => {
    cy.intercept('**/*', (req) => {
      req.headers['x-test-environment'] = 'true'
    })
  })

  it('submits form successfully', () => {
    cy.visit('/contact')

    cy.get('input[name="email"]').type('test@example.com')
    cy.get('textarea[name="message"]').type('Test message')

    // Turnstile auto-solves in test mode
    cy.get('button[type="submit"]').click()

    cy.contains('Success').should('be.visible')
  })
})

Jest / Vitest (React)

Mock @marsidev/react-turnstile

// jest.setup.ts or vitest.setup.ts
import React from 'react'

jest.mock('@marsidev/react-turnstile', () => ({
  Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
    // Auto-solve with dummy token
    React.useEffect(() => {
      onSuccess('XXXX.DUMMY.TOKEN.XXXX')
    }, [onSuccess])

    return <div data-testid="turnstile-mock" />
  },
}))

Component Test

// ContactForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ContactForm } from './ContactForm'

test('submits form with Turnstile', async () => {
  render(<ContactForm />)

  fireEvent.change(screen.getByLabelText('Email'), {
    target: { value: 'test@example.com' },
  })

  fireEvent.change(screen.getByLabelText('Message'), {
    target: { value: 'Test message' },
  })

  // Turnstile auto-solves (mocked)
  const submitButton = screen.getByRole('button', { name: 'Submit' })
  expect(submitButton).not.toBeDisabled()

  fireEvent.click(submitButton)

  await waitFor(() => {
    expect(screen.getByText('Success')).toBeInTheDocument()
  })
})

Server-Side Testing (Vitest)

Validate Dummy Token

// server.test.ts
import { describe, it, expect } from 'vitest'
import { validateTurnstile } from './turnstile-server-validation'
import { TEST_SECRET_KEYS, DUMMY_TOKEN } from './turnstile-test-config'

describe('Turnstile Validation', () => {
  it('validates dummy token with test secret', async () => {
    const result = await validateTurnstile(
      DUMMY_TOKEN,
      TEST_SECRET_KEYS.ALWAYS_PASS
    )

    expect(result.success).toBe(true)
  })

  it('rejects real token with test secret', async () => {
    const realToken = 'real-production-token'

    const result = await validateTurnstile(
      realToken,
      TEST_SECRET_KEYS.ALWAYS_PASS
    )

    expect(result.success).toBe(false)
  })

  it('handles always-fail secret key', async () => {
    const result = await validateTurnstile(
      DUMMY_TOKEN,
      TEST_SECRET_KEYS.ALWAYS_FAIL
    )

    expect(result.success).toBe(false)
  })
})

Cloudflare Workers Testing (Miniflare)

Setup

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'miniflare',
    environmentOptions: {
      bindings: {
        TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA',
        TURNSTILE_SITE_KEY: '1x00000000000000000000AA',
      },
    },
  },
})

Worker Test

// worker.test.ts
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
import { describe, it, expect } from 'vitest'
import worker from '../src/index'
import { DUMMY_TOKEN } from './turnstile-test-config'

describe('Worker with Turnstile', () => {
  it('validates test token successfully', async () => {
    const formData = new FormData()
    formData.append('email', 'test@example.com')
    formData.append('cf-turnstile-response', DUMMY_TOKEN)

    const request = new Request('http://localhost/api/contact', {
      method: 'POST',
      body: formData,
      headers: {
        'x-test-environment': 'true',
      },
    })

    const ctx = createExecutionContext()
    const response = await worker.fetch(request, env, ctx)
    await waitOnExecutionContext(ctx)

    expect(response.status).toBe(200)
  })
})

CI/CD Configuration

GitHub Actions

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        env:
          TURNSTILE_SITE_KEY: 1x00000000000000000000AA
          TURNSTILE_SECRET_KEY: 1x0000000000000000000000000000000AA
          NODE_ENV: test
        run: npm test

GitLab CI

# .gitlab-ci.yml
test:
  image: node:20
  script:
    - npm ci
    - npm test
  variables:
    TURNSTILE_SITE_KEY: "1x00000000000000000000AA"
    TURNSTILE_SECRET_KEY: "1x0000000000000000000000000000000AA"
    NODE_ENV: "test"

Testing Best Practices

Always use dummy keys - Never use production credentials in tests Test both success and failure - Use both pass and fail test keys Mock in unit tests - Mock Turnstile component for fast unit tests E2E with real widget - Use test sitekeys in E2E tests Separate environments - Different config for test/dev/staging/prod Test expiration - Verify token expiration handling Test error states - Validate error callback behavior

Never commit production keys - Use environment variables Don't skip server validation tests - Critical security component Don't test with production sitekeys - Can trigger rate limits Don't hardcode test keys - Use constants/config files


Debugging Tests

Enable Verbose Logging

turnstile.render('#container', {
  sitekey: TEST_SITEKEYS.ALWAYS_PASS,
  callback: (token) => console.log('[TEST] Token:', token),
  'error-callback': (error) => console.error('[TEST] Error:', error),
  'expired-callback': () => console.warn('[TEST] Token expired'),
})

Check Network Requests

// Playwright
await page.route('**/siteverify', route => {
  console.log('Siteverify called with:', route.request().postData())
  route.continue()
})

Verify Environment Detection

test('uses test credentials in test env', async ({ page }) => {
  const response = await page.evaluate(() => {
    return document.querySelector('.cf-turnstile')?.getAttribute('data-sitekey')
  })

  expect(response).toBe('1x00000000000000000000AA')
})

Last Updated: 2025-10-22 Test Framework Support: Playwright, Cypress, Jest, Vitest, Miniflare