Integrationen

iOS-Automatisierungs-CAPTCHA-Verarbeitung mit XCUITest und CaptchaAI

XCUITest kann in einem eingebetteten WKWebView kein JavaScript ausführen – und genau hier bleiben iOS-UI-Tests hängen, sobald ein Registrierungs- oder Login-Formular ein reCAPTCHA v2 lädt. Der praktikable Weg führt über einen kleinen Begleitdienst: Er liest den Sitekey aus dem WebView, lässt CaptchaAI das CAPTCHA lösen und speist das fertige Token per JavaScript zurück in die Seite. Ihr Test läuft dadurch ohne manuellen Eingriff bis zum Ende durch.

Der Ablauf stützt sich auf drei Bausteine, die sauber getrennt bleiben:

  • ein Test-Hook in der App, der JavaScript im WKWebView auswertet
  • ein lokaler Solver-Dienst, der mit der CaptchaAI-API spricht
  • der XCUITest selbst, der beides während des Laufs orchestriert

Dieser Leitfaden zeigt den kompletten Ablauf: das CAPTCHA im WKWebView erkennen, Sitekey und Page-URL auslesen, die Abfrage über CaptchaAI lösen und das Token einfügen – als reproduzierbarer Schritt in Ihrer XCUITest-Suite und in der CI-Pipeline.

Wann sich dieser Ansatz lohnt – und wann nicht

Der Bridge-Aufwand rechnet sich nur, wenn Ihre App echte Web-Inhalte in einem WebView rendert. Die folgende Tabelle grenzt das ein:

Situation Ansatz passt Eher nicht nötig
Ihre iOS-App lädt kritische Flows in WKWebView Ja
Sie testen primär Safari oder reine Web-Frontends Ein Web-Teststack ist meist einfacher
Ihre CI-Pipeline braucht reproduzierbare E2E-Tests trotz CAPTCHA Ja
Ihre App ist vollständig nativ, ganz ohne WebView Kein zusätzlicher Bridge-Aufwand nötig

Das Szenario: reCAPTCHA v2 im WKWebView

Ihre iOS-App lädt ein Registrierungsformular in einem WKWebView, und dieses Formular enthält ein reCAPTCHA v2. Im automatisierten Test blockiert die Abfrage den weiteren Ablauf. Gebraucht wird also eine Lösung, die vier Schritte selbstständig erledigt:

  1. das CAPTCHA im WebView während der Testausführung erkennen
  2. den Sitekey programmatisch auslesen
  3. die Abfrage über CaptchaAI lösen
  4. das Token einfügen, damit das Formular abgesendet werden kann

Umgebung: Xcode 15+, Swift, XCUITest, ein macOS-Testrunner und die CaptchaAI-API.

Architektur: Wie die Komponenten zusammenspielen

Da XCUITest kein JavaScript direkt im WKWebView ausführen kann, übernimmt ein Hilfsendpunkt die Brücke, den die App während des Tests aufruft:

Komponente Aufgabe
XCUITest Steuert die Benutzeroberfläche und stößt die CAPTCHA-Lösung über den Test-Helper an
Test-Helper-API Nimmt Sitekey und Page-URL entgegen, ruft CaptchaAI auf und liefert das Token zurück
App-Test-Hook Wertet JavaScript im WKWebView aus, um das CAPTCHA zu erkennen und das Token einzuspeisen
CaptchaAI-API Löst das reCAPTCHA v2

Der Datenfluss läuft dabei in einer klaren Kette:

  • XCUITest tippt den Test-Button an und startet den Hook
  • der Hook liest den Sitekey aus und ruft den lokalen Solver-Dienst auf
  • der Solver-Dienst holt das Token von CaptchaAI und gibt es an den Hook zurück, der es einspeist

Schritt 1: Test-Hook in die App einbauen

Ergänzen Sie im WKWebView-Controller Ihrer App einen CAPTCHA-Handler, der nur im Testmodus aktiv ist und sich über eine Accessibility-ID oder ein URL-Schema auslösen lässt. Der Handler übernimmt drei Aufgaben:

  • das CAPTCHA über den Selektor .g-recaptcha erkennen und den Sitekey auslesen
  • den lokalen Solver-Dienst mit Sitekey und Page-URL aufrufen
  • das zurückgegebene Token in das versteckte Formularfeld einfügen und den Callback auslösen
// CaptchaTestHelper.swift — Add to app target (test build only)
import WebKit

#if DEBUG
class CaptchaTestHelper {
    private let webView: WKWebView

    init(webView: WKWebView) {
        self.webView = webView
    }

    func detectCaptcha(completion: @escaping (String?, String?) -> Void) {
        let script = """
        (function() {
            var el = document.querySelector('.g-recaptcha');
            if (el) {
                return JSON.stringify({
                    sitekey: el.getAttribute('data-sitekey'),
                    pageurl: window.location.href
                });
            }
            return null;
        })();
        """

        webView.evaluateJavaScript(script) { result, error in
            guard let jsonString = result as? String,
                  let data = jsonString.data(using: .utf8),
                  let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
                completion(nil, nil)
                return
            }
            completion(json["sitekey"], json["pageurl"])
        }
    }

    func injectToken(_ token: String, completion: @escaping (Bool) -> Void) {
        let script = """
        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) {}
        true;
        """

        webView.evaluateJavaScript(script) { _, error in
            completion(error == nil)
        }
    }

    func solveCaptchaViaBackend(
        sitekey: String, pageurl: String,
        completion: @escaping (Result<String, Error>) -> Void
    ) {
        guard let url = URL(string: "http://localhost:3000/api/solve-captcha") else {
            return
        }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let body: [String: String] = [
            "captchaType": "recaptcha_v2",
            "sitekey": sitekey,
            "pageurl": pageurl
        ]
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)

        URLSession.shared.dataTask(with: request) { data, _, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            guard let data = data,
                  let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                  let token = json["token"] as? String else {
                completion(.failure(NSError(domain: "", code: -1,
                    userInfo: [NSLocalizedDescriptionKey: "No token"])))
                return
            }
            completion(.success(token))
        }.resume()
    }
}
#endif

Schritt 2: Lokaler Solver-Dienst als Brücke zu CaptchaAI

Der Test-Hook aus Schritt 1 spricht keinen externen Dienst direkt an, sondern einen lokalen Solver, der während des Testlaufs mitläuft. Dieser schlanke Flask-Dienst erledigt drei Aufgaben:

  • Sitekey und Page-URL vom Hook entgegennehmen
  • die Aufgabe per in.php an CaptchaAI übermitteln
  • das Ergebnis per Polling über res.php abfragen und als Token zurückgeben
# ios_test_solver.py — Run on test machine during XCUITest execution
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
    sitekey = data["sitekey"]
    pageurl = data["pageurl"]

    # Submit to CaptchaAI
    resp = requests.get("https://ocr.captchaai.com/in.php", params={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "json": "1",
    })
    result = resp.json()

    if result.get("status") != 1:
        return jsonify({"error": result.get("request")}), 400

    task_id = result["request"]

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

Schritt 3: Den Lösungsablauf im XCUITest auslösen

Im XCUITest starten Sie den Ablauf, sobald der WebView mit dem CAPTCHA geladen ist. Der Test tippt den nur im Testmodus sichtbaren Helfer-Button an und wartet auf das Signal, dass das Token eingespeist wurde – erst danach wird das Formular abgesendet:

// CaptchaUITests.swift
import XCTest

class CaptchaUITests: XCTestCase {

    func testRegistrationWithCaptcha() throws {
        let app = XCUIApplication()
        app.launchArguments.append("--captcha-test-mode")
        app.launch()

        // Navigate to registration
        app.buttons["Register"].tap()

        // Wait for WebView to load
        let webView = app.webViews.firstMatch
        XCTAssertTrue(webView.waitForExistence(timeout: 15))

        // Trigger CAPTCHA solve via test helper button
        // (The app shows this button only in test mode)
        let solveButton = app.buttons["SolveCaptchaTestHelper"]
        if solveButton.waitForExistence(timeout: 5) {
            solveButton.tap()

            // Wait for solve completion indicator
            let solved = app.staticTexts["CaptchaSolved"]
            XCTAssertTrue(solved.waitForExistence(timeout: 120),
                "CAPTCHA should be solved within 2 minutes")
        }

        // Continue with form submission
        app.buttons["SubmitForm"].tap()

        // Verify success
        let success = app.staticTexts["Registration Complete"]
        XCTAssertTrue(success.waitForExistence(timeout: 10))
    }
}

Kosten und Durchsatz im CI

CaptchaAI rechnet nach gleichzeitigen Threads ab, nicht pro gelöstem CAPTCHA – jeder Plan enthält unbegrenzte Lösungen pro Thread im Abrechnungsmonat. Für eine XCUITest-Suite, die CAPTCHAs meist sequenziell durchläuft, reicht deshalb der Einstieg: BASIC (15 $/Monat, 5 Threads) deckt bis zu fünf parallele Testläufe ab.

reCAPTCHA v2 löst CaptchaAI typischerweise in unter 60 Sekunden – planen Sie das Test-Timeout entsprechend großzügig. Läuft Ihre iOS-Pipeline wie in vielen DACH-Teams auf einem selbstgehosteten macOS-Runner unter GitLab CI, starten Sie den Solver-Dienst einfach als zusätzlichen Schritt vor der Testphase. Die Kommunikation mit CaptchaAI läuft über HTTPS und funktioniert damit in jeder CI-Umgebung. (Preise in US-Dollar.)

Hinweis: Die genannte Lösungszeit ist eine SLA-Obergrenze, kein Durchschnitt – das reale Timing hängt von Region, Auslastung und CAPTCHA-Konfiguration ab.

Fehlerbehebung

Problem Ursache Lösung
evaluateJavaScript liefert nil Der WebView ist noch nicht fertig geladen Vor der JS-Auswertung auf webView.isLoading == false warten
Backend aus dem Simulator nicht erreichbar localhost zeigt im Simulator nicht auf den Mac 127.0.0.1 oder die Netzwerk-IP des Macs verwenden; App Transport Security prüfen
Token wird erzeugt, aber vom Ziel abgelehnt Sitekey, Page-URL oder Session-Kontext passen nicht zusammen Parameter erneut auslesen und das Token in derselben Sitzung verwenden
Token-Injektion löst den Callback nicht aus Der reCAPTCHA-Callback steckt tief in ___grecaptcha_cfg.clients Alle Eigenschaften rekursiv durchlaufen (siehe Code in Schritt 1)
XCUITest läuft ins Timeout Die Lösungszeit ist länger als das Test-Timeout Test-Timeout für CAPTCHA-Schritte auf mindestens 120 Sekunden setzen

Häufige Fragen

Kurz und knapp: die Fragen, die im iOS-Test-Kontext am häufigsten auftauchen.

Löst CaptchaAI hCaptcha in iOS-WebViews?

Nein. Prüfen Sie also vorab, welcher Typ in Ihrem WebView steckt. Abgedeckt sind:

  • reCAPTCHA v2 (inklusive Invisible und Enterprise) und reCAPTCHA v3
  • Cloudflare Turnstile und Cloudflare Challenge
  • GeeTest v3 sowie Bild-, Grid- und BLS-CAPTCHAs
  • CaptchaFox, Friendly Captcha und Lemin – jeweils in der Beta

hCaptcha und FunCaptcha stehen derzeit nicht auf der Liste.

Welcher Plan reicht für eine XCUITest-Suite?

In der Regel der kleinste. Zwei Faustregeln helfen bei der Wahl:

  • Ein Thread genügt pro Testlauf, der CAPTCHAs sequenziell abarbeitet.
  • Erst wenn mehrere CI-Jobs parallel Abfragen stellen, brauchen Sie mehr.

Für die meisten Suiten reicht damit BASIC (15 $/Monat, 5 Threads) für bis zu fünf gleichzeitige Läufe; jeder Plan enthält unbegrenzte Lösungen pro Thread.

Wie lange dauert das Lösen eines reCAPTCHA v2?

Typischerweise unter 60 Sekunden. Setzen Sie das XCUITest-Timeout für CAPTCHA-Schritte auf mindestens 120 Sekunden, damit auch langsamere Läufe nicht fälschlich als Fehler gewertet werden.

Was tun, wenn das CAPTCHA im WKWebView eines Fremd-SDKs liegt?

Wenn Sie den WebView nicht selbst kontrollieren, greift der Test-Hook nicht. Wechseln Sie in diesem Fall auf Appium: Es bietet execute_script über beliebige WebView-Kontexte hinweg, ganz ohne app-seitigen Hook.

Fazit und nächste Schritte

Der Kern ist die saubere Trennung der Zuständigkeiten: XCUITest steuert die Oberfläche, ein Test-Hook übernimmt die JavaScript-Auswertung im WKWebView und CaptchaAI löst das CAPTCHA im Hintergrund. Kapseln Sie den Hook konsequent in #if DEBUG, damit er nie im Release-Build landet.

Weiterführende Leitfäden:

Kommentare sind für diesen Artikel deaktiviert.