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