Integrations

iOS Automation CAPTCHA Handling with XCUITest and CaptchaAI

iOS app testing with XCUITest often hits CAPTCHAs in embedded WKWebViews — login forms, payment gateways, and third-party integrations that present reCAPTCHA challenges. CaptchaAI solves these so your UI tests can complete end-to-end flows without manual intervention.

This guide shows how to detect CAPTCHAs in WKWebView during XCUITest runs, solve them via CaptchaAI from a companion service, and inject the token back into the web content.

Real-World Scenario

Your iOS app loads a registration form in a WKWebView. The form includes reCAPTCHA v2. During automated testing, this CAPTCHA blocks test progression. You need a solution that:

  1. Detects the CAPTCHA in the WebView during test execution
  2. Extracts the sitekey programmatically
  3. Solves it via CaptchaAI
  4. Injects the token so the form can submit

Environment: Xcode 15+, Swift, XCUITest, macOS test runner, CaptchaAI API.

Architecture

XCUITest cannot directly execute JavaScript in a WKWebView. The approach uses a helper endpoint that the app calls during testing:

Component Role
XCUITest Drives the UI, triggers CAPTCHA solve via test helper
Test Helper API Receives sitekey + URL, calls CaptchaAI, returns token
App Test Hook Evaluates JavaScript in WKWebView to detect/inject
CaptchaAI API Solves the CAPTCHA challenge

Step 1: Add a Test Hook to the App

In your app's WKWebView controller, add a test-mode CAPTCHA handler that can be triggered via accessibility identifiers or URL scheme:

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

Step 2: Backend Solver Service

Run a local solver service during testing that communicates with CaptchaAI:

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

Step 3: XCUITest Integration

In your XCUITest, trigger the CAPTCHA solve flow when the WebView with a CAPTCHA loads:

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

Troubleshooting

Problem Cause Fix
evaluateJavaScript returns nil WebView hasn't finished loading Wait for webView.isLoading == false before injecting JS
Backend not reachable from Simulator localhost not accessible Use 127.0.0.1 or the Mac's network IP; check App Transport Security
Token injection doesn't fire callback reCAPTCHA callback nested in complex object Iterate all properties of ___grecaptcha_cfg.clients recursively
XCUITest timeout waiting for solve Long CaptchaAI solve time Set test timeout to 120+ seconds for CAPTCHA-related tests

FAQ

Can XCUITest execute JavaScript directly in WKWebView?

No. XCUITest interacts with UI elements but cannot evaluate JavaScript. You need a test hook in the app code (debug build only) to bridge this gap.

Will this approach work in CI/CD pipelines?

Yes, run the solver backend on the CI machine and the iOS Simulator. The solver service communicates with CaptchaAI over HTTPS, which works in any environment.

How do I prevent the test hook from shipping to production?

Wrap all test helper code in #if DEBUG compiler directives. The code will be stripped from release builds.

What if the CAPTCHA is in a third-party SDK WebView?

If you don't control the WebView, use Appium instead — it provides execute_script capabilities across any WebView context without needing app-side hooks.

Next Steps

Integrate CaptchaAI into your iOS testing pipeline — get your API key and automate through CAPTCHA-protected flows.

Related guides:

Full Working Code

Complete runnable examples for this article in Python, Node.js, PHP, Go, Java, C#, Ruby, Rust, Kotlin & Bash.

View on GitHub →

Discussions (0)

No comments yet.