Integrations

使用 XCUITest 和 CaptchaAI 进行 iOS 自动化验证码处理

写 iOS UI 自动化测试的人大多踩过这个坑:注册表单套了一层 WKWebView,页面里嵌了 reCAPTCHA v2,XCUITest 却碰不到 WebView 内部的 DOM,测试一到这一步就卡死,没法继续跑完整个流程。

这篇指南给出一套可落地的方案:用一个测试期专用的辅助服务,在 XCUITest 运行时检测 WKWebView 里的验证码,通过 CaptchaAI 完成识别,再把 token 写回网页内容,让表单能正常提交。

适用范围:以下代码只用于 QA 环境,不进正式包。

典型场景:验证码卡在 WKWebView 里,测试跑不下去

App 在 WKWebView 中加载注册表单,表单里带 reCAPTCHA v2。

典型症状:测试卡住直到超时,日志里也看不到 JS 报错。

自动化测试跑到这一步就会被挡住,因为 XCUITest 只能操作 UI 元素,摸不到 WebView 里的 JavaScript 环境。要打通整条链路,需要做到:

  1. 在测试执行期间检测出 WebView 里的验证码
  2. 用代码提取 sitekey
  3. 调用 CaptchaAI 完成识别
  4. 把 token 注入页面,让表单可以提交

环境要求:

  • Xcode 15+
  • Swift
  • XCUITest
  • macOS 测试机
  • CaptchaAI API

方案设计:测试助手服务如何对接 CaptchaAI

XCUITest 不能直接在 WKWebView 里执行 JavaScript,所以这套方案用一个测试期辅助接口,把检测、识别、注入串起来,各组件分工是:

  • XCUITest:驱动 UI,通过测试助手触发验证码识别
  • 测试助手 API:接收 sitekey + URL,调用 CaptchaAI,返回 token
  • App 内测试钩子:在 WKWebView 中执行 JavaScript,负责检测和注入
  • CaptchaAI API:完成验证码识别

测试助手接口的约定

  • 请求体带上目标 URL 和 sitekey 等元数据,发给辅助服务。
  • 响应带 token、过期时间和失败原因,测试层据此分支判断。
  • 模拟器日志和辅助服务日志共用一个跟踪 ID,排查更快。

步骤 1:给 App 加一个测试专用的验证码钩子

在 WKWebView 控制器里加一个测试模式下的验证码处理器,通过辅助功能标识符或 URL scheme 触发:

触发按钮用稳定的 accessibilityIdentifier 定位,别用 label,文案一改测试就挂。

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

步骤 2:搭建后端识别服务

测试期间,本机跑一个和 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)

提示:测试机在国内的话,装依赖时加个镜像参数,比如 pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt,能省不少等待时间。

步骤 3:接入 XCUITest 用例

在 XCUITest 里,WebView 加载出验证码时触发识别流程:

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

常见问题

CaptchaAI 求解超时应该设多长?

轮询间隔通常是 5 秒一次,reCAPTCHA v2 多数在 30~60 秒内出结果,排队高峰期会更久。把 XCUITest 用例超时设到 120 秒以上,能避免正常延迟被误判成失败。

测试挂钩会不会不小心带到正式版本里?

不会,只要包在 #if DEBUG 里。Release 构建会把这段代码整个去掉,不需要手动删代码。

WebView 是第三方 SDK 加载的,还能用这套方案吗?

如果你控制不了 WebView(比如第三方支付 SDK 的窗口),改用 Appium 更合适——它跨任意 WebView 提供 execute_script,不需要预埋测试钩子。

除了 reCAPTCHA v2,这套思路还能处理其它验证码吗?

检测、识别、注入的架构本身和验证码类型无关,重点是先确认目标页面用哪种验证码:

  • ✅ reCAPTCHA(v2 / v3 / Enterprise)
  • ✅ Cloudflare Turnstile
  • ✅ GeeTest v3
  • ❌ hCaptcha(暂不支持)
  • ❌ FunCaptcha(暂不支持)

选型前先对一下这份清单,能省不少返工。

故障排查

问题 原因 处理方式
evaluateJavaScript 返回 nil WebView 还没加载完 webView.isLoading == false 再注入 JS
模拟器连不上后端 localhost 在模拟器里不可达 换成 127.0.0.1 或 Mac 的局域网 IP,同时检查 App Transport Security 设置
token 注入后回调没触发 reCAPTCHA 的回调嵌套在多层对象里 递归遍历 ___grecaptcha_cfg.clients 的所有属性
XCUITest 等识别结果时超时 CaptchaAI 识别耗时较长 涉及验证码的用例,把测试超时设到 120 秒以上

提示:CI 并行跑多台模拟器时,CaptchaAI 按并发线程计费、单线程解决次数不限:跑 5 台模拟器至少要选 BASIC($15/月,5 个线程),线程不够请求会排队,拖慢流水线。

相关文章

还在处理其他验证码类型?这几篇也值得看看:

下一步

把 CaptchaAI 接入你的 iOS 测试流水线——获取 API 密钥,让验证码不再挡住自动化测试。

Android 用 Espresso 也是同一个思路,换成 UiAutomator 桥接即可。

相关指南

该文章已禁用评论。