Integrations

Tester des CAPTCHA Android avec Espresso et CaptchaAI

Un reCAPTCHA v2 qui apparaît dans une WebView suffit à figer toute une suite Espresso : le test n'atteint plus l'écran de confirmation, et votre parcours de bout en bout cesse d'être automatisable. La réponse tient en trois gestes, sans intervention humaine : détecter le CAPTCHA dans la WebView, le faire résoudre par CaptchaAI via un petit backend de test, puis réinjecter le token pour laisser le scénario se poursuivre. Ce guide s'adresse aux ingénieurs QA et développeurs d'automatisation qui veulent garder une couverture E2E stable, y compris sur les écrans protégés par un reCAPTCHA.

Le cas concret : un paiement tiers en WebView

Le scénario est classique, d'un éditeur parisien à une fintech bruxelloise. Votre application Android ouvre une page de paiement tierce dans une WebView, qui impose un reCAPTCHA v2 avant la validation. En test instrumenté Espresso, ce défi bloque le scénario et empêche de vérifier le tunnel de paiement.

Périmètre sûr et RGPD

Le périmètre reste strictement celui de vos tests QA, ce qui garde aussi la démarche saine côté RGPD :

  • un environnement de recette isolé, jamais la production ;
  • un jeu de données maîtrisé, sans donnée personnelle réelle ;
  • uniquement des parcours que vous contrôlez et que vous avez le droit d'automatiser.

Vous n'injectez donc jamais un token dans un flux tiers que vous ne maîtrisez pas : le CAPTCHA que vous résolvez est celui de votre propre scénario de recette.

Environnement utilisé : Android Studio, Kotlin, Espresso, AndroidX Test, l'API CaptchaAI et un backend Python local.

Étape 1 : injecter un helper de test dans l'application

Ajoutez un helper réservé au source set debug, capable d'exécuter du JavaScript dans la WebView. Il expose une interface JavaScript pour remonter le sitekey détecté et gère l'appel au backend :

// CaptchaTestHelper.kt — debug source set only
package com.example.app.testing

import android.webkit.JavascriptInterface
import android.webkit.WebView
import kotlinx.coroutines.*
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject

class CaptchaTestHelper(private val webView: WebView) {

    private var detectedSitekey: String? = null
    private var detectedPageUrl: String? = null
    private var solvedToken: String? = null

    @JavascriptInterface
    fun onCaptchaDetected(sitekey: String, pageurl: String) {
        detectedSitekey = sitekey
        detectedPageUrl = pageurl
    }

    fun detectCaptcha() {
        webView.post {
            webView.evaluateJavascript("""
                (function() {
                    var el = document.querySelector('.g-recaptcha');
                    if (el) {
                        CaptchaHelper.onCaptchaDetected(
                            el.getAttribute('data-sitekey'),
                            window.location.href
                        );
                        return 'found';
                    }
                    return 'not_found';
                })();
            """, null)
        }
    }

    suspend fun solveAndInject(): Boolean = withContext(Dispatchers.IO) {
        val sitekey = detectedSitekey ?: return@withContext false
        val pageurl = detectedPageUrl ?: return@withContext false

        // Call backend solver
        val client = OkHttpClient.Builder()
            .callTimeout(java.time.Duration.ofMinutes(3))
            .build()

        val body = JSONObject().apply {
            put("captchaType", "recaptcha_v2")
            put("sitekey", sitekey)
            put("pageurl", pageurl)
        }.toString().toRequestBody("application/json".toMediaType())

        val request = Request.Builder()
            .url("http://10.0.2.2:3000/api/solve-captcha")  // Host loopback for emulator
            .post(body)
            .build()

        val response = client.newCall(request).execute()
        val json = JSONObject(response.body?.string() ?: "")
        val token = json.optString("token", "")

        if (token.isEmpty()) return@withContext false

        solvedToken = token

        // Inject token on main thread
        withContext(Dispatchers.Main) {
            webView.evaluateJavascript("""
                document.getElementById('g-recaptcha-response').value = '$token';
                try {
                    var clients = ___grecaptcha_cfg.clients;
                    Object.keys(clients).forEach(function(k) {
                        Object.keys(clients[k]).forEach(function(j) {
                            if (clients[k][j] && clients[k][j].callback) {
                                clients[k][j].callback('$token');
                            }
                        });
                    });
                } catch(e) {}
            """, null)
        }

        return@withContext true
    }

    companion object {
        fun attach(webView: WebView): CaptchaTestHelper {
            val helper = CaptchaTestHelper(webView)
            webView.addJavascriptInterface(helper, "CaptchaHelper")
            return helper
        }
    }
}

Détection et portée du helper

La détection cible la classe .g-recaptcha et lit l'attribut data-sitekey dans le DOM. Le helper vit uniquement dans src/debug/, jamais dans un build release.

Étape 2 : exposer un backend de résolution CaptchaAI

L'app ne parle pas directement à CaptchaAI : elle appelle un service local qui soumet la tâche et interroge le résultat. Lancez ce backend Flask sur votre poste pendant les tests :

# android_test_solver.py
import os
import time
import requests
from flask import Flask, request, jsonify

app = Flask(__name__)
API_KEY = os.environ.get("CAPTCHAAI_API_KEY", "YOUR_API_KEY")

@app.route("/api/solve-captcha", methods=["POST"])
def solve():
    data = request.json

    # Submit to CaptchaAI
    resp = requests.get("https://ocr.captchaai.com/in.php", params={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": data["sitekey"],
        "pageurl": data["pageurl"],
        "json": "1",
    })
    result = resp.json()
    if result.get("status") != 1:
        return jsonify({"error": result.get("request")}), 400

    task_id = result["request"]

    # Poll for result
    for _ in range(30):
        time.sleep(5)
        poll = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY, "action": "get", "id": task_id, "json": "1",
        })
        poll_result = poll.json()
        if poll_result.get("status") == 1:
            return jsonify({"token": poll_result["request"]})
        if poll_result.get("request") != "CAPCHA_NOT_READY":
            return jsonify({"error": poll_result["request"]}), 400

    return jsonify({"error": "Timeout"}), 408

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=3000)

Du sitekey au token

Le service envoie le sitekey et l'URL à in.php avec method=userrecaptcha, puis interroge res.php toutes les 5 secondes jusqu'au token. Depuis l'émulateur, 10.0.2.2 pointe vers la machine hôte : c'est ainsi que la WebView atteint votre backend.

Étape 3 : déclencher la résolution CAPTCHA depuis Espresso

Il reste à câbler le test : naviguer jusqu'au checkout, récupérer la WebView, attacher le helper, résoudre et injecter, puis vérifier la confirmation.

// CheckoutCaptchaTest.kt
package com.example.app

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class CheckoutCaptchaTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun testCheckoutWithCaptcha() {
        // Navigate to checkout
        onView(withId(R.id.checkout_button)).perform(click())

        // Wait for WebView to load
        Thread.sleep(5000)

        // Access the WebView and attach helper
        activityRule.scenario.onActivity { activity ->
            val webView = activity.findViewById<android.webkit.WebView>(R.id.webview)

            val helper = CaptchaTestHelper.attach(webView)
            helper.detectCaptcha()

            // Wait for detection
            Thread.sleep(2000)

            // Solve and inject
            runBlocking {
                val solved = helper.solveAndInject()
                assert(solved) { "CAPTCHA should be solved successfully" }
            }
        }

        // Continue with form submission after token injection
        Thread.sleep(1000)

        // Verify checkout completed
        onView(withText("Order Confirmed")).check(
            androidx.test.espresso.assertion.ViewAssertions.matches(isDisplayed())
        )
    }
}

Enchaîner détection, résolution et vérification

Les Thread.sleep() restent simples pour l'exemple ; en pratique, remplacez-les par une attente sur WebViewClient.onPageFinished() pour fiabiliser le test en CI (OVHcloud, Scaleway).

Ce que fait réellement l'injection du token

Un reCAPTCHA v2 résolu se matérialise par deux choses côté page : le token écrit dans le champ caché g-recaptcha-response, et le callback que Google déclenche pour signaler la validation. Le helper reproduit les deux, si bien que la WebView se comporte comme si un utilisateur avait coché la case.

La résolution suit toujours le même enchaînement :

  • Détection : le script lit data-sitekey sur l'élément .g-recaptcha et remonte l'URL courante de la page.
  • Résolution : le backend soumet la tâche à CaptchaAI, puis interroge le résultat toutes les 5 secondes jusqu'au token.
  • Injection : le token est écrit dans le champ caché, puis le callback enregistré est appelé pour laisser le formulaire se valider.

Sans cet appel du callback, beaucoup d'intégrations reCAPTCHA restent bloquées même avec un token valide : c'est l'étape que les tests oublient le plus souvent, et la première à vérifier quand la case se coche sans que le parcours n'avance.

Dépannage : les blocages fréquents

Problème Cause probable Correctif
10.0.2.2 est inaccessible Vous n'êtes pas sur l'émulateur Android Sur appareil physique, utilisez l'IP réelle de la machine hôte
evaluateJavascript ne renvoie rien La WebView n'est pas encore chargée Attendez WebViewClient.onPageFinished() avant d'exécuter le script
addJavascriptInterface reste sans effet JavaScript est désactivé Activez webView.settings.javaScriptEnabled = true
La requête réseau est bloquée Android filtre le trafic HTTP en clair Autorisez android:usesCleartextTraffic="true", en debug uniquement

Fiabiliser le test en CI

En intégration continue, le maillon fragile n'est pas la résolution mais l'attente : une WebView lente ou un émulateur froid font échouer un test pourtant correct. Quelques réglages suffisent à écarter les faux négatifs :

  • remplacez chaque Thread.sleep() par une attente explicite sur WebViewClient.onPageFinished() ou un IdlingResource ;
  • alignez le timeout Espresso sur le callTimeout de 3 minutes d'OkHttp, pour laisser le polling aboutir ;
  • gardez le backend de résolution et l'émulateur sur le même réseau (runner OVHcloud ou Scaleway, région eu-west-3 pour limiter la latence) ;
  • isolez la clé API dans une variable d'environnement du runner, jamais en clair dans le dépôt.

Questions fréquentes

Faut-il un backend séparé ou peut-on appeler CaptchaAI directement depuis l'app ?

Un backend intermédiaire est préférable : il évite d'embarquer la clé API dans l'APK, centralise le polling et se réutilise pour iOS ou vos tests web.

Cette méthode gère-t-elle reCAPTCHA v3 et reCAPTCHA Enterprise ?

Oui. Pour Enterprise, fournissez le sitekey correspondant et les paramètres attendus par CaptchaAI ; pour v3, adaptez la détection, car il s'agit d'un score côté serveur et non d'une case à cocher.

CaptchaAI peut-il résoudre hCaptcha dans une WebView Android ?

Non — hCaptcha n'est pas pris en charge, pas plus que FunCaptcha (Arkose Labs). L'approche vaut pour reCAPTCHA v2/v3, Cloudflare Turnstile, GeeTest v3 et les CAPTCHA image/OCR. Vérifiez le type présent avant d'écrire le test.

Comment empêcher ce helper de partir en production ?

Placez-le dans src/debug/java/. Les variantes de build Android excluent ces sources des builds release : le code de test ne fuite pas dans l'APK distribué. Prévoyez aussi un timeout Espresso large, aligné sur le callTimeout de 3 minutes d'OkHttp, pour laisser le polling aboutir.

Articles connexes

Prochaines étapes

Vos tests mobiles butent sur une WebView protégée ? Récupérez votre clé API CaptchaAI et branchez le backend de résolution sur votre environnement de recette.

Guides associés :

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