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-sitekeysur l'élément.g-recaptchaet 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 surWebViewClient.onPageFinished()ou unIdlingResource; - alignez le timeout Espresso sur le
callTimeoutde 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
- Déclencher le callback reCAPTCHA v2 après injection du token
- Construire un pipeline de tests automatisé avec CaptchaAI
- Gérer reCAPTCHA v2 et Turnstile sur une même page
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 :