XCUITest не умеет исполнять JavaScript внутри WKWebView — это ограничение фреймворка, а не баг вашего приложения. Как только форма регистрации, платёжный шаг или сторонняя интеграция подгружает reCAPTCHA v2 во встроенном веб-виде, UI-тест упирается в задачу, которую он физически не может решить сам. CaptchaAI закрывает этот разрыв: тестовый хук в приложении достаёт sitekey и pageurl, вспомогательный сервис отправляет их в CaptchaAI, а токен возвращается обратно в WebView — без участия человека и без хрупких обходных путей вроде ручного клика по CAPTCHA перед каждым прогоном.
Ниже — рабочая связка из трёх частей: тестовый хук на Swift, сервис-решатель на Python и сам сценарий XCUITest, который их запускает.
Когда CAPTCHA в WKWebView блокирует iOS-тесты на XCUITest
Приложение загружает форму регистрации в WKWebView, форма содержит reCAPTCHA v2, и на этом автоматический прогон теста останавливается. Чтобы тест прошёл до конца, тестовый хук должен успеть сделать четыре вещи одну за другой: обнаружить CAPTCHA в WebView во время выполнения теста, программно извлечь sitekey, решить задачу через CaptchaAI и ввести токен обратно в форму, чтобы она отправилась как обычно. Окружение для примеров ниже: Xcode 15+, Swift, XCUITest, тест-раннер на macOS, CaptchaAI API.
Если тесты гоняются в CI на нескольких параллельных симуляторах, стоит заранее прикинуть нагрузку по потокам: план BASIC ($15/мес, 5 потоков) обычно хватает небольшой команде, которая держит до пяти симуляторов одновременно, а для CI-фермы побольше логичнее смотреть на STANDARD ($30/мес, 15 потоков) — тарификация у CaptchaAI идёт по одновременным потокам, а не по числу решений, так что цена предсказуема и не растёт от количества CAPTCHA внутри одного прогона.
Архитектура: как XCUITest достаёт до CAPTCHA внутри WKWebView
Поскольку XCUITest не может напрямую выполнить JavaScript в WebView, между тестом и CaptchaAI встаёт вспомогательная конечная точка, которую приложение вызывает во время прогона:
- XCUITest — управляет UI, запускает решение CAPTCHA через тестовый хук.
- API вспомогательного сервиса — принимает
sitekeyи URL, вызывает CaptchaAI, возвращает токен. - Тестовый хук в приложении — выполняет JavaScript в WKWebView для обнаружения CAPTCHA и инъекции токена.
- CaptchaAI API — решает саму задачу CAPTCHA.
Контракт между тестом и сервисом-решателем CaptchaAI
Вспомогательный сервис из шага 2 работает по фиксированному контракту, и от того, насколько строго он соблюдается, зависит стабильность всей цепочки тестов:
- Запрос от тестового хука несёт тип CAPTCHA,
sitekeyи текущийpageurlWebView — ровно то, чтоdetectCaptchaизвлекает черезevaluateJavaScript. - Ответ — либо
{"token": "..."}при успехе, либо{"error": "..."}с HTTP-кодом, отличным от 200: тестовый уровень должен корректно завершаться в обоих случаях, а не только на happy path. - Логи симулятора и логи вспомогательного сервиса стоит связывать общим идентификатором трассировки — иначе при параллельных прогонах в CI сложно понять, какой лог относится к какому конкретному тесту.
Шаг 1. Тестовый хук для CAPTCHA в приложении
Прежде чем писать хук, проверьте три вещи:
- таргет тестовой сборки собирается с флагом
DEBUG; - у вас есть доступ к контроллеру, где создаётся
WKWebView; - адрес вспомогательного сервиса из шага 2 известен заранее — его пропишете в
solveCaptchaViaBackend.
В контроллере WKWebView добавьте обработчик CAPTCHA тестового режима, который активируется через идентификаторы доступности или схему URL. Он оборачивается в #if DEBUG, чтобы ни при каких условиях не попасть в релизную сборку:
// 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
URL
http://localhost:3000/api/solve-captchaв примере предполагает, что вспомогательный сервис из шага 2 крутится на том же Mac, что и симулятор. В CI-раннере такой адрес обычно недоступен — деталь разобрана в разделе «Частые проблемы» ниже.
Шаг 2. Вспомогательный сервис-решатель на Python
Во время тестового прогона на той же машине (или в CI-раннере) поднимите лёгкий сервис на Flask, который принимает запрос от хука и общается с 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)
Ключ CaptchaAI сервис читает из переменной окружения CAPTCHAAI_API_KEY, а не из кода приложения. Причины:
- секрет не попадает в бандл iOS-приложения при архивации;
- один и тот же
CAPTCHAAI_API_KEYиспользуется и локально, и в CI без изменений кода; - ключ не утекает вместе с тестовой сборкой, если она случайно попадёт не в те руки.
Шаг 3. Запуск решения CAPTCHA из XCUITest
В самом тесте XCUITest запустите поток решения CAPTCHA, когда в WebView загружается форма с CAPTCHA:
// 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))
}
}
Обратите внимание на тайм-аут в 120 секунд для индикатора CaptchaSolved — он закладывает запас на решение самой CAPTCHA плюс сетевые задержки между симулятором, вспомогательным сервисом и CaptchaAI, а не только на отрисовку UI.
Часто задаваемые вопросы
Сколько времени уходит на решение reCAPTCHA v2 и почему тест ждёт до 120 секунд?
Само решение обычно укладывается в десятки секунд, но тайм-аут в коде теста закладывает дополнительный запас. Бюджет в 120 секунд распределяется примерно так:
- 10–30 с — собственно решение CAPTCHA на стороне CaptchaAI;
- часть запаса — очередь и сетевые скачки, характерные для CI;
- остаток — страховка на нестабильную сеть симулятора.
Ставить тайм-аут строго по среднему времени решения — частая причина ложных падений тестов.
Что делать, если в WebView стоит не reCAPTCHA v2, а Cloudflare Turnstile?
Логика та же: тестовый хук достаёт параметры виджета, вспомогательный сервис вызывает соответствующий метод CaptchaAI и возвращает токен для инъекции. Меняется только код виджета в запросе (гайд по Cloudflare Turnstile — в разделе «Следующие шаги» ниже).
Сама архитектура моста между XCUITest и CaptchaAI не зависит от типа CAPTCHA — меняется только endpoint CaptchaAI, который вызывает вспомогательный сервис, и параметры, которые тестовый хук достаёт из WebView.
Как не оставить тестовый хук в релизной сборке для App Store?
Весь код хука оборачивается в #if DEBUG. При сборке в релизном конфиге компилятор просто вырезает этот блок — в App Store он физически не попадает. Перед архивацией стоит убедиться, что:
- тестовая схема собирается в конфигурации
Debug; - хук не импортируется напрямую в основной таргет.
Обязательно ли поднимать отдельный Python-сервис, или можно вызывать CaptchaAI прямо из Swift-кода?
Технически можно обратиться к CaptchaAI напрямую из URLSession в тестовом таргете. Отдельный сервис на Python — не жёсткое требование, а способ держать API-ключ вне бандла приложения и переиспользовать одну и ту же логику решения CAPTCHA между iOS, Android и веб-тестами в одной команде.
Как передать API-ключ CaptchaAI в CI, не закоммитив его в репозиторий?
Храните ключ в секретах CI (переменных окружения раннера) и пробрасывайте его в процесс через CAPTCHAAI_API_KEY, как в примере из шага 2 — сервис уже читает ключ из окружения с плейсхолдером YOUR_API_KEY как запасным значением. Хардкодить ключ в CaptchaTestHelper.swift или в самом тесте не нужно.
Частые проблемы при решении CAPTCHA в XCUITest-тестах
| Проблема | Причина | Решение |
|---|---|---|
evaluateJavaScript возвращает nil |
WebView ещё не закончил загрузку | Дождитесь webView.isLoading == false, прежде чем инъецировать JS |
| Сервис недоступен из симулятора | localhost не резолвится так, как ожидается |
Используйте 127.0.0.1 или сетевой IP Mac; проверьте настройки App Transport Security |
| Токен не запускает callback reCAPTCHA | Callback вложен глубже, чем ожидает скрипт | Переберите все свойства ___grecaptcha_cfg.clients рекурсивно |
| Тест падает по тайм-ауту в ожидании решения | Решение CAPTCHA заняло больше времени, чем заложено в тесте | Ставьте тайм-аут 120+ секунд для CAPTCHA-сценариев |
Следующие шаги
Дальше по теме — смежные интеграции CaptchaAI, которые пригодятся в том же тестовом пайплайне: