Intégrations

Tester des CAPTCHA Android avec Espresso et CaptchaAI

Les tests UI Android bases sur Espresso finissent souvent par tomber sur un CAPTCHA dans une WebView : page de connexion, tunnel d'inscription, checkout tiers ou parcours de paiement embarque. Tant que ce point n'est pas traite, le test E2E n'est plus vraiment automatisable. CaptchaAI permet d'ajouter une résolution programmatique au milieu de ce parcours sans faire intervenir un humain.

Le principe est simple : détecter le CAPTCHA dans la WebView, envoyer les bons paramètres à un petit backend de test, attendre le token puis le réinjecter dans la page pour poursuivre le scénario.

Scénario du monde réel

Votre application Android charge une page de paiement tierce dans une WebView. Cette page impose un reCAPTCHA v2 avant la validation finale. Pendant les tests instrumentés Espresso, ce challenge bloque le scénario de bout en bout et empêche de valider le flux de paiement.

Environnement : Android Studio, Kotlin, Espresso, AndroidX Test, CaptchaAI API, backend Python local.

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

Ajoutez un helper réservé au mode debug, capable d'exécuter du JavaScript dans la WebView de l'application :

// 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
        }
    }
}

Étape 2 : exposer un backend de résolution

Exécutez ce solveur Python sur votre machine de développement pendant l'exécution du test :

# 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)

Étape 3 : brancher Espresso sur la résolution CAPTCHA

// 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())
        )
    }
}

Dépannage

Probleme Cause probable Correctif
10.0.2.2 est inaccessible Vous n'êtes pas sur l'émulateur Android Utilisez l'IP réelle de la machine hôte sur appareil physique
evaluateJavascript ne donne rien La WebView n'est pas encore chargée Attendez WebViewClient.onPageFinished() avant d'exécuter le script
addJavascriptInterface ne répond pas JavaScript est désactivé Activez webView.settings.javaScriptEnabled = true
La requête réseau est bloquée Android bloque le trafic HTTP clair Autorisez android:usesCleartextTraffic="true" en debug seulement

FAQ

Espresso peut-il piloter directement le contenu d'une WebView ?

Espresso fournit onWebView() pour certains cas simples, mais pas pour injecter du JavaScript arbitraire. Pour un CAPTCHA, il faut passer par evaluateJavascript() et l'API native de la WebView.

Est-ce que cela fonctionne sur de vrais appareils en CI ?

Oui. Il faut remplacer 10.0.2.2 par l'adresse IP de la machine qui exécute le backend et vérifier que l'appareil peut joindre ce service sur le réseau de test.

Comment éviter d'embarquer ce helper en production ?

Placez ce type de helper dans src/debug/java/. Les variantes Android excluent automatiquement ce source set des builds release.

Et pour reCAPTCHA Enterprise ?

L'approche reste la même, mais il faut fournir le sitekey Enterprise et, selon l'implémentation cible, ajouter les paramètres supplémentaires attendus par CaptchaAI.

Articles connexes

  • Résoudre le callback reCAPTCHA v2 avec l'API
  • Construire un pipeline de tests automatisé avec CaptchaAI
  • Gérer reCAPTCHA v2 et Turnstile sur le même site

Prochaines étapes

Si vos tests mobiles butent sur une WebView protégée, récupérez votre clé API CaptchaAI et ajoutez un backend de résolution à votre environnement QA.

Guides associés :

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