Use Cases

Gestion des CAPTCHA dans les tests d'intégration continue

Un pipeline CI/CD ne clique pas. Dès qu'un test de bout en bout tombe sur un CAPTCHA, il se bloque — sauf si votre suite sait le résoudre elle-même. La réponse tient en une ligne : appelez l'API CaptchaAI depuis vos tests, avec la clé stockée comme secret CI, et le pipeline franchit les pages protégées sans la moindre manipulation manuelle.

Le fil conducteur : une équipe QA d'un SaaS francophone qui exécute ses tests E2E chaque nuit sur un environnement de staging, avec un runner hébergé en région eu-west-3 (Paris). Aucun compte réel, aucune donnée personnelle en jeu — on reste sur du staging, ce qui reste la bonne pratique côté RGPD. Voici ce que couvre ce guide :

  • un client de résolution Python conçu pour tourner sans humain dans un runner ;
  • son intégration dans une suite Selenium + pytest ;
  • le câblage complet sous GitHub Actions, GitLab CI et Jenkins ;
  • deux garde-fous pour maîtriser la facture de résolution en CI.

Pourquoi les CAPTCHA cassent vos tests E2E en CI/CD

Un pipeline s'exécute sans humain devant l'écran, et c'est précisément le but. Or un CAPTCHA attend une action humaine. Sans service de résolution, chaque test de bout en bout échoue systématiquement dès qu'il atteint une page protégée — non pas parce que votre application est cassée, mais parce que le test reste coincé sur le défi. Les endroits typiques :

  • la page de connexion, souvent protégée par reCAPTCHA v2 ;
  • les formulaires de contact ou d'inscription, fréquemment derrière Cloudflare Turnstile ;
  • le checkout ou toute action sensible que le site veut protéger des robots.

La solution : intégrez l'API CaptchaAI directement dans votre suite de tests. La clé API vit dans le gestionnaire de secrets de la CI, et les tests résolvent les CAPTCHA à la volée pendant l'exécution du pipeline. Le test ne distingue plus une page protégée d'une page ordinaire.

Architecture du pipeline de test

┌──────────────┐     ┌──────────────┐     ┌────────────┐     ┌──────────────┐
│ Git Push     │────▶│ CI Runner    │────▶│ E2E Tests  │────▶│ Test Report  │
│              │     │ (headless    │     │ + CAPTCHA  │     │              │
│              │     │  Chrome)     │     │ solving    │     │              │
└──────────────┘     └──────────────┘     └────────────┘     └──────────────┘
                                                │
                                                ▼
                                         ┌────────────┐
                                         │ CaptchaAI  │
                                         │ API        │
                                         └────────────┘

Un client de résolution CAPTCHA pour la CI (Python)

Cette classe encapsule l'envoi de la tâche et l'interrogation du résultat. Elle lit la clé depuis la variable d'environnement CAPTCHAAI_API_KEY et échoue clairement si le secret est absent — un comportement utile en CI, où l'on préfère un message explicite à un test qui traîne jusqu'au timeout.

import os
import time
import requests


class CICaptchaSolver:
    """CAPTCHA solver designed for CI environments."""
    BASE = "https://ocr.captchaai.com"

    def __init__(self):
        self.api_key = os.environ.get("CAPTCHAAI_API_KEY")
        if not self.api_key:
            raise EnvironmentError("CAPTCHAAI_API_KEY not set")

    def solve(self, params, initial_wait=10, timeout=120):
        params["key"] = self.api_key
        params["json"] = 1
        resp = requests.post(f"{self.BASE}/in.php", data=params).json()
        if resp["status"] != 1:
            raise Exception(f"CAPTCHA submit failed: {resp['request']}")

        task_id = resp["request"]
        time.sleep(initial_wait)
        deadline = time.time() + timeout

        while time.time() < deadline:
            result = requests.get(
                f"{self.BASE}/res.php",
                params={"key": self.api_key, "action": "get", "id": task_id, "json": 1},
            ).json()
            if result["request"] == "CAPCHA_NOT_READY":
                time.sleep(5)
                continue
            if result["status"] == 1:
                return result["request"]
            raise Exception(f"CAPTCHA solve failed: {result['request']}")

        raise TimeoutError("CAPTCHA solve timed out in CI")

    def solve_recaptcha(self, sitekey, pageurl):
        return self.solve({
            "method": "userrecaptcha",
            "googlekey": sitekey,
            "pageurl": pageurl,
        })

    def solve_turnstile(self, sitekey, pageurl):
        return self.solve({
            "method": "turnstile",
            "sitekey": sitekey,
            "pageurl": pageurl,
        })

Brancher la résolution dans pytest

conftest.py

Deux fixtures suffisent : une pour le client de résolution (partagé sur toute la session), une pour le navigateur headless recréé à chaque test. Les flags --no-sandbox et --disable-dev-shm-usage sont indispensables dans un conteneur CI, sous peine de voir Chrome planter au démarrage.

import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


@pytest.fixture(scope="session")
def captcha_solver():
    return CICaptchaSolver()


@pytest.fixture(scope="function")
def browser():
    options = Options()
    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--disable-gpu")
    driver = webdriver.Chrome(options=options)
    driver.set_window_size(1920, 1080)
    yield driver
    driver.quit()

Le fichier de test

Le schéma est toujours le même : le test remplit le formulaire, demande un token à CaptchaAI, l'injecte dans le champ attendu (g-recaptcha-response pour reCAPTCHA v2, cf-turnstile-response pour Turnstile), puis valide. Ici, une connexion protégée par reCAPTCHA v2 et un formulaire de contact protégé par Turnstile.

import time
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC


class TestLoginFlow:
    SITEKEY = "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-"
    LOGIN_URL = "https://staging.example.com/login"

    def test_login_with_captcha(self, browser, captcha_solver):
        browser.get(self.LOGIN_URL)

        # Fill credentials
        browser.find_element(By.ID, "username").send_keys("testuser")
        browser.find_element(By.ID, "password").send_keys("testpass123")

        # Solve CAPTCHA
        token = captcha_solver.solve_recaptcha(self.SITEKEY, self.LOGIN_URL)
        browser.execute_script(
            f'document.querySelector("[name=g-recaptcha-response]").value = "{token}";'
        )

        # Submit
        browser.find_element(By.ID, "login-btn").click()
        time.sleep(3)

        # Verify login success
        assert "dashboard" in browser.current_url.lower()

    def test_login_wrong_password(self, browser, captcha_solver):
        browser.get(self.LOGIN_URL)
        browser.find_element(By.ID, "username").send_keys("testuser")
        browser.find_element(By.ID, "password").send_keys("wrongpass")

        token = captcha_solver.solve_recaptcha(self.SITEKEY, self.LOGIN_URL)
        browser.execute_script(
            f'document.querySelector("[name=g-recaptcha-response]").value = "{token}";'
        )

        browser.find_element(By.ID, "login-btn").click()
        time.sleep(3)

        error = browser.find_element(By.CSS_SELECTOR, ".error-message")
        assert error.is_displayed()


class TestContactForm:
    SITEKEY = "0x4AAAA..."
    FORM_URL = "https://staging.example.com/contact"

    def test_contact_form_submission(self, browser, captcha_solver):
        browser.get(self.FORM_URL)

        browser.find_element(By.ID, "name").send_keys("CI Test")
        browser.find_element(By.ID, "email").send_keys("ci@test.com")
        browser.find_element(By.ID, "message").send_keys("Automated CI test")

        token = captcha_solver.solve_turnstile(self.SITEKEY, self.FORM_URL)
        browser.execute_script(
            f'document.querySelector("[name=cf-turnstile-response]").value = "{token}";'
        )

        browser.find_element(By.CSS_SELECTOR, "button[type='submit']").click()

        WebDriverWait(browser, 10).until(
            EC.presence_of_element_located((By.CSS_SELECTOR, ".success-message"))
        )

Déclencher la suite depuis GitHub Actions

Le secret CAPTCHAAI_API_KEY est exposé au job via env, jamais écrit en dur. Le rapport HTML est archivé même en cas d'échec (if: always()), ce qui facilite le diagnostic quand un test tombe sur le runner mais pas sur votre poste.

name: E2E Tests with CAPTCHA

on:
  push:
    branches: [main, staging]
  pull_request:
    branches: [main]

jobs:
  e2e-tests:
    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install Chrome
        uses: browser-actions/setup-chrome@v1
        with:
          chrome-version: stable

      - name: Install ChromeDriver
        uses: nanasess/setup-chromedriver@v2

      - name: Install dependencies
        run: |
          pip install selenium requests pytest pytest-html

      - name: Run E2E tests
        env:
          CAPTCHAAI_API_KEY: ${{ secrets.CAPTCHAAI_API_KEY }}
        run: |
          pytest tests/e2e/ -v --html=report.html --self-contained-html

      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: e2e-report
          path: report.html

La même suite sous GitLab CI

Sur GitLab, le conteneur selenium/standalone-chrome fournit le navigateur, et la clé passe par une variable de projet (Settings → CI/CD → Variables, masquée). Les rapports JUnit remontent directement dans l'interface de merge request.

e2e_tests:
  stage: test
  image: python:3.11
  services:

    - selenium/standalone-chrome:latest
  variables:
    SELENIUM_REMOTE_URL: "http://selenium__standalone-chrome:4444/wd/hub"
  script:

    - pip install selenium requests pytest
    - pytest tests/e2e/ -v
  artifacts:
    when: always
    reports:
      junit: report.xml

Et sous Jenkins

Sur Jenkins, la clé est un identifiant (credentials('captchaai-api-key')) injecté dans l'environnement du pipeline. Le bloc post { always } publie les résultats JUnit à chaque exécution, réussie ou non.

pipeline {
    agent any
    environment {
        CAPTCHAAI_API_KEY = credentials('captchaai-api-key')
    }
    stages {
        stage('Setup') {
            steps {
                sh 'pip install selenium requests pytest'
            }
        }
        stage('E2E Tests') {
            steps {
                sh 'pytest tests/e2e/ -v --junitxml=results.xml'
            }
        }
    }
    post {
        always {
            junit 'results.xml'
        }
    }
}

Maîtriser les coûts de résolution en CI

Chaque résolution occupe un thread le temps qu'elle dure. Deux réflexes évitent de gaspiller :

  • ne résoudre que lorsque c'est pertinent (pas sur chaque build de PR) ;
  • couper proprement la suite si le solde tombe sous un seuil.

Ne résolvez que lorsque c'est nécessaire

Inutile de payer une résolution sur chaque build de pull request. Le garde-fou ci-dessous saute les tests CAPTCHA si le drapeau SKIP_CAPTCHA_TESTS est posé ou si la clé est absente.

import os

def should_run_captcha_tests():
    """Skip CAPTCHA tests in certain environments."""
    if os.environ.get("SKIP_CAPTCHA_TESTS"):
        return False
    if not os.environ.get("CAPTCHAAI_API_KEY"):
        return False
    return True


# In test
import pytest

@pytest.mark.skipif(
    not should_run_captcha_tests(),
    reason="CAPTCHA tests disabled or API key not set"
)
class TestWithCaptcha:
    def test_login(self, browser, captcha_solver):
        pass

Vérifiez le solde avant de lancer la suite

Rien de plus frustrant qu'une suite entière qui échoue à mi-parcours faute de solde. Cette fixture, exécutée une fois par session, interrompt proprement les tests si le solde passe sous un seuil (0,50 $ dans l'exemple).

@pytest.fixture(scope="session", autouse=True)
def check_captcha_balance(captcha_solver):
    import requests
    resp = requests.get(
        f"{captcha_solver.BASE}/res.php",
        params={"key": captcha_solver.api_key, "action": "getbalance"},
    )
    balance = float(resp.text)
    if balance < 0.50:
        pytest.skip(f"CaptchaAI balance too low: ${balance:.2f}")

Dépannage

Problème Cause Correctif
CAPTCHAAI_API_KEY not set Secret absent de la configuration CI Ajoutez la clé aux secrets de votre CI
Chrome plante dans le runner Flag --no-sandbox manquant Ajoutez les flags Chrome headless
Les tests passent en local, échouent en CI Version de navigateur différente Épinglez la version de Chrome dans la CI
Le CAPTCHA expire (timeout) Réseau du runner CI trop lent Augmentez le paramètre timeout
La facture grimpe Trop de résolutions par exécution Activez SKIP_CAPTCHA_TESTS sur les builds de PR

FAQ

Faut-il résoudre un CAPTCHA à chaque exécution du pipeline ?

Non. Réservez les tests avec résolution aux fusions vers main ou à une exécution planifiée (par exemple chaque nuit). Sur les builds de pull request, activez SKIP_CAPTCHA_TESTS pour éviter des résolutions inutiles et contenir la facture.

Combien coûte l'ajout de la résolution CAPTCHA à la CI ?

CaptchaAI facture au thread simultané, pas au CAPTCHA résolu : chaque plan inclut un nombre de résolutions illimité par thread. Le plan BASIC ($15/mois, 5 threads) couvre largement une suite E2E qui tourne quelques fois par jour ; montez en gamme uniquement si vos tests parallèles saturent les threads.

CaptchaAI prend-il en charge hCaptcha ou GeeTest v4 pour mes tests ?

Non — hCaptcha et FunCaptcha (Arkose Labs) ne sont pas pris en charge, et GeeTest v4 est annoncé « à venir » sans être disponible. Pour vos tests E2E, appuyez-vous sur les types couverts : reCAPTCHA v2 et v3, Cloudflare Turnstile et Challenge, GeeTest v3, ainsi que les CAPTCHA image/OCR et en grille.

Un test réussit en local mais échoue en CI : que vérifier ?

Regardez d'abord la version de Chrome (épinglez-la dans la CI) et les flags --no-sandbox / --disable-dev-shm-usage, indispensables dans un conteneur. Vérifiez ensuite que le secret CAPTCHAAI_API_KEY est bien exposé au job et que le timeout tient compte de la latence réseau du runner.

Pour aller plus loin

Intégrez la résolution CAPTCHA à votre pipeline : créez votre compte CaptchaAI.

Les commentaires sont désactivés pour cet article.