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:
- das CAPTCHA im WebView während der Testausführung erkennen
- den Sitekey programmatisch auslesen
- die Abfrage über CaptchaAI lösen
- 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-recaptchaerkennen 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.phpan CaptchaAI übermitteln - das Ergebnis per Polling über
res.phpabfragen 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:
- CAPTCHA-Tests mit Espresso unter Android
- Mobile Browser-Automatisierung mit CAPTCHA-Lösung
- CAPTCHAs in React-Native-WebViews lösen