Integrationen

Android CAPTCHA-Test mit Espresso und CaptchaAI

Espresso kann ein reCAPTCHA v2 nicht anklicken – und soll es auch nicht. Der Weg, der in der Praxis trägt, sieht anders aus: Der Test liest den Sitekey aus der WebView aus, ein kleiner Backend-Dienst lässt die Abfrage von CaptchaAI lösen, und der Test trägt das zurückgelieferte Token in das versteckte Formularfeld ein. Danach läuft der Instrumentierungstest ohne manuellen Eingriff weiter.

Dieser Leitfaden zeigt den Aufbau: Kotlin-Debug-Helfer, Python-Solver für die Testlaufzeit und den Espresso-Test, der beides verbindet.

Warum Espresso an der WebView-Grenze aufhört

Espresso ist auf die native View-Hierarchie ausgelegt. Für Web-Inhalte gibt es onWebView() mit Atoms für Klicks und Textabfragen – genug, um ein Formular zu bedienen, aber nicht, um beliebiges JavaScript in der Seite auszuführen. Genau das braucht die CAPTCHA-Behandlung: data-sitekey aus dem DOM holen und später das versteckte Feld g-recaptcha-response setzen.

Der Ausweg führt über die WebView-API selbst: evaluateJavascript() führt Code in der geladenen Seite aus, addJavascriptInterface() reicht das Ergebnis zurück nach Kotlin. Beides gehört ausschließlich in das Debug-Source-Set.

Der Ablauf in drei Etappen

  1. Erkennen: Der Test prüft, ob die geladene Seite ein .g-recaptcha-Element enthält, und liest Sitekey und Page-URL aus.
  2. Lösen: Ein Solver-Dienst auf dem Entwicklungs- oder CI-Rechner übermittelt beides an die CaptchaAI-API und fragt den Status ab, bis ein Token vorliegt.
  3. Eintragen: Der Test schreibt das Token in g-recaptcha-response und ruft den reCAPTCHA-Callback auf, damit die Seite den Absende-Button freigibt.

Der Solver läuft bewusst außerhalb der App: Ein API-Schlüssel hat in einem APK nichts verloren, auch nicht im Debug-Build.

Testumgebung und Geltungsbereich

Stack: Android Studio, Kotlin, Espresso, AndroidX Test, CaptchaAI-API, Python-Backend mit Flask.

Das Beispiel prüft den Checkout-Flow einer eigenen App, die eine Bezahlseite mit reCAPTCHA v2 in einer WebView lädt. Alle Aufrufe zeigen auf eine Staging-Umgebung mit Testdaten und Sandbox-Zahlungsmitteln – der Ansatz ist für QA in eigenen oder freigegebenen Umgebungen gedacht, nicht für fremde Produktivsysteme.

Schritt 1: Debug-Helfer in der App anlegen

Der Helfer kapselt beide Richtungen: JavaScript in die Seite hinein, Ergebnisse zurück nach Kotlin. Legen Sie ihn unter src/debug/java/ ab, damit der Release-Build ihn nicht sieht.

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

Drei Details entscheiden über Erfolg oder Frust: evaluateJavascript() läuft auf dem Main-Thread (daher webView.post), der Netzwerkaufruf gehört auf Dispatchers.IO, und das Callback-Objekt trägt denselben Namen wie im Skript – hier CaptchaHelper.

Schritt 2: Backend-Solver für die Testlaufzeit

Der Solver bleibt klein: eine Flask-Route, die den Auftrag an in.php übermittelt und res.php abfragt, bis ein Token vorliegt. Den API-Schlüssel übergeben Sie über CAPTCHAAI_API_KEY.

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

reCAPTCHA v2 ist der langsamste der gängigen Typen: CaptchaAI nennt dafür eine Obergrenze von unter 60 Sekunden, während Cloudflare Turnstile in unter 10 Sekunden gelöst wird. Kalkulieren Sie das Timeout entsprechend großzügig – 30 Durchläufe à 5 Sekunden decken den Fall ab. Wichtig ist, CAPCHA_NOT_READY sauber von echten Fehlercodes zu trennen; nur der erste Fall rechtfertigt einen weiteren Versuch.

Schritt 3: Espresso-Test mit CAPTCHA-Abfrage

Der Test klickt sich bis zur WebView durch, hängt den Helfer an, wartet auf die Erkennung und lässt dann lösen.

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

Die Thread.sleep()-Aufrufe halten das Beispiel lesbar. In einer echten Suite ersetzen Sie sie durch IdlingResources – eines für onPageFinished(), eines für den Solver-Aufruf. Sonst wird der Test flaky, sobald der CI-Runner unter Last steht.

Emulator, physisches Gerät und CI-Pipeline

10.0.2.2 ist die Loopback-Adresse des Host-Rechners, aber nur im Android-Emulator. Auf einem physischen Testgerät tragen Sie die tatsächliche IP des Solver-Rechners ein und stellen sicher, dass beide im selben Netz erreichbar sind.

Ab Android 9 blockiert die Plattform unverschlüsselte HTTP-Verbindungen. Für den Debug-Build genügt android:usesCleartextTraffic="true" in einer eigenen AndroidManifest.xml unter src/debug/ – im Release-Manifest nicht.

In der Pipeline startet der Solver am einfachsten als zusätzlicher Service-Container neben dem Emulator-Job. In GitLab CI, das in vielen deutschsprachigen Entwicklungsteams neben GitHub Actions fest etabliert ist, genügt dafür ein services:-Eintrag; der API-Schlüssel kommt aus den maskierten CI/CD-Variablen und nie aus dem Repository. Wer die Runner selbst betreibt – etwa auf einer Hetzner- oder netcup-Instanz –, sollte dem Emulator-Host reichlich RAM zugestehen, sonst läuft die Erkennung ins Leere, bevor die Seite fertig gerendert ist.

Fehlerbehebung

Symptom Ursache Lösung
Backend unter 10.0.2.2 nicht erreichbar Test läuft auf einem physischen Gerät Host-IP im Testnetz eintragen; 10.0.2.2 gilt nur im Emulator
evaluateJavascript() liefert nichts zurück Seite beim Aufruf noch nicht fertig geladen Auf WebViewClient.onPageFinished() warten
Das JavaScript-Interface reagiert nicht JavaScript in der WebView deaktiviert webView.settings.javaScriptEnabled = true setzen
Token entsteht, wird aber abgelehnt Sitekey, Page-URL oder Sitzung passen nicht zusammen Parameter neu auslesen, Token in derselben Sitzung verwenden
Polling endet im Timeout Intervall zu eng, Fehlercodes nicht getrennt Alle 5–10 Sekunden abfragen, CAPCHA_NOT_READY gesondert behandeln

Kosten und Durchsatz im Testbetrieb

CaptchaAI rechnet pro gleichzeitigem Thread ab, nicht pro Lösung; innerhalb eines Tarifs sind die Lösungen unbegrenzt. Entscheidend ist also nicht, wie oft Ihre Pipeline läuft, sondern wie viele CAPTCHA-Abfragen gleichzeitig offen sind.

  • BASIC (15 $/Monat, 5 Threads) reicht für eine Suite mit wenigen parallelen Emulatoren.
  • STANDARD (30 $/Monat, 15 Threads) deckt mehrere Teams oder eine breite Gerätematrix ab.
  • ADVANCE (90 $/Monat, 50 Threads) wird erst bei sehr großen Matrizen relevant.

Die Abrechnung erfolgt in US-Dollar.

Häufige Fragen

Wie lange bleibt ein gelöstes Token gültig?

Rund zwei Minuten. Ein reCAPTCHA-v2-Token verfällt nach etwa 120 Sekunden; der Test sollte das Formular deshalb unmittelbar nach dem Eintragen absenden. Starten Sie den Solver-Aufruf erst an der Absende-Stelle, nicht zu Beginn des Durchlaufs.

Warum ruft der Instrumentierungstest die API nicht direkt auf?

Wegen des API-Schlüssels. Ein Instrumentierungstest wird als APK installiert, und alles darin lässt sich auslesen. Der Umweg über einen lokalen Solver hält den Schlüssel im Secret-Store der Pipeline – und macht die Logik für iOS- oder Web-Suiten wiederverwendbar.

Welche CAPTCHA-Typen deckt der Ansatz außer reCAPTCHA v2 ab?

Alle von CaptchaAI unterstützten Typen, die in einer WebView erscheinen können: reCAPTCHA v2 Invisible und Enterprise, reCAPTCHA v3, Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3 sowie Bild- und Rasterbild-CAPTCHAs; CaptchaFox (Beta), Friendly Captcha (Beta) und Lemin (Beta) kommen dazu. hCaptcha und FunCaptcha werden nicht unterstützt, GeeTest v4 ist nur als bald verfügbar angekündigt. Anzupassen sind der method-Parameter im Solver sowie Selektor und Zielfeld im Helfer.

Was gilt für Testdaten und DSGVO?

Arbeiten Sie in der Staging-Umgebung mit synthetischen Datensätzen statt mit kopierten Produktivdaten. IP-Adressen gelten in der DSGVO als personenbezogene Daten, und Testläufe erzeugen davon reichlich – in Logs von Emulator, Solver und Pipeline. Prüfen Sie Aufbewahrungsfristen und Rechtsgrundlage für Ihre eigenen Datenflüsse, bevor eine Suite dauerhaft in der CI läuft.

Wie halte ich den Testhelfer aus dem Release-Build heraus?

Über die Build-Varianten. Alles unter src/debug/java/ landet nur im Debug-Build; das Release-Artefakt enthält weder die Klasse noch das JavaScript-Interface. Ein Lint-Check auf addJavascriptInterface im Release-Source-Set fängt versehentliche Verschiebungen ab.

Fazit

Der Kern der Integration ist unspektakulär: auslesen, lösen lassen, eintragen. Aufwand entsteht an den Rändern – Wartelogik statt fester Sleeps, Erreichbarkeit zwischen Gerät und Solver, Schlüsselverwaltung in der Pipeline.


Verwandte Leitfäden

Kommentare sind für diesen Artikel deaktiviert.