XCUITest không thể chạy JavaScript trực tiếp trong WKWebView — đó là lý do bộ test iOS bị kẹt cứng ngay tại bước reCAPTCHA v2 của biểu mẫu đăng ký hoặc thanh toán. Cách xử lý thực tế: dựng một dịch vụ trợ giúp cục bộ gọi CaptchaAI để lấy sitekey, gửi task, polling lấy token, rồi tiêm token đó ngược vào WKWebView bằng evaluateJavaScript. Bốn bước này là toàn bộ những gì bài viết dưới đây trình bày.
Dành cho QA engineer và automation developer cần một luồng chạy được trong CI ngay hôm nay — không phải bài tổng quan lý thuyết về CAPTCHA.
Vì sao CAPTCHA chặn test đăng ký trong WKWebView
Ứng dụng iOS của bạn tải biểu mẫu đăng ký bên trong WKWebView, và biểu mẫu đó nhúng reCAPTCHA v2. Khi chạy test tự động, CAPTCHA đứng chắn ngay giữa luồng — test không thể tự vượt qua nó vì XCUITest chỉ điều khiển được UI native, không chạm được vào nội dung bên trong WebView. Muốn test đi tiếp, bạn cần làm đúng bốn việc theo thứ tự: phát hiện CAPTCHA trong WebView khi test đang chạy, trích xuất sitekey bằng script (không phải bằng tay), gửi sitekey đó cho CaptchaAI để giải, rồi tiêm token nhận về vào form để nút gửi hoạt động bình thường.
Môi trường dùng trong bài: Xcode 15+, Swift, XCUITest, trình mô phỏng macOS, CaptchaAI API.
Tình huống này quen thuộc với các đội QA outsourcing tại Việt Nam (FPT Software, VNG, các product team TP.HCM/Hà Nội) chạy CI cho app iOS của khách nước ngoài — CAPTCHA kẹt giữa luồng đăng ký là đủ để pipeline báo đỏ.
Kiến trúc: cầu nối giữa XCUITest và CaptchaAI
Vì XCUITest không đánh giá được JavaScript trong WKWebView, giải pháp phải đi vòng qua một endpoint trung gian mà chính ứng dụng gọi trong lúc test. Bốn mảnh ghép cần có:
- XCUITest — điều khiển UI, bấm nút kích hoạt giải CAPTCHA thông qua test hook.
- API dịch vụ trợ giúp — nhận sitekey + URL từ app, gọi CaptchaAI, trả token về.
- Test hook trong app — đánh giá JavaScript trong WKWebView để phát hiện CAPTCHA và tiêm token.
- CaptchaAI API — giải thử thách reCAPTCHA v2 và trả token.
Bước 1: Thêm test hook vào ứng dụng iOS
Trong controller quản lý WKWebView của app, thêm một handler CAPTCHA chỉ chạy ở chế độ test, kích hoạt được qua accessibility identifier hoặc 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
Bước 2: Dựng dịch vụ trợ giúp gọi CaptchaAI
Dịch vụ này chạy cục bộ trên máy chạy test, nhận sitekey từ app rồi thay app nói chuyện với 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)
Bước 3: Kích hoạt luồng giải CAPTCHA trong XCUITest
Trong test case, gọi luồng giải CAPTCHA ngay khi WebView chứa CAPTCHA vừa tải xong:
// 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))
}
}
Câu hỏi thường gặp
XCUITest có tự chạy được JavaScript trong WKWebView để giải CAPTCHA không?
Không. XCUITest chỉ điều khiển được các thành phần UI native, không đánh giá được JavaScript bên trong WebView. Muốn giải CAPTCHA, bạn bắt buộc phải có một test hook trong code app (chỉ bật ở bản debug) để làm cầu nối.
Luồng helper service này có chạy ổn trên CI như GitHub Actions hay Xcode Cloud không?
Có. Chạy dịch vụ trợ giúp trên cùng máy CI với Simulator là đủ, vì nó chỉ giao tiếp với CaptchaAI qua HTTPS.
Nên đặt timeout bao nhiêu giây cho test case có bước giải CAPTCHA?
Tối thiểu 120 giây cho riêng bước giải, tách biệt khỏi timeout tổng của test case. Thời gian giải reCAPTCHA v2 qua CaptchaAI dao động theo tải hệ thống, nên timeout ngắn hơn dễ gây fail giả (flaky test) chứ không phản ánh lỗi thật.
Chạy song song nhiều test CAPTCHA trên CI thì cần bao nhiêu thread CaptchaAI?
Tuỳ số job chạy đồng thời: gói BASIC ($15/tháng, 5 thread) đủ cho vài job song song của dự án nhỏ; pipeline chạy hàng chục job cùng lúc nên cân nhắc ADVANCE ($90/tháng, 50 thread) trở lên. CaptchaAI tính theo thread, không giới hạn số lần giải mỗi thread.
Có nên dùng webhook thay vì để dịch vụ trợ giúp tự polling res.php không?
Polling như trong bài này đơn giản hơn để chạy trong CI vì không cần expose endpoint public cho CaptchaAI gọi ngược. Webhook hợp lý hơn khi đã có hạ tầng nhận callback sẵn — với service chạy tạm thời lúc test, polling vẫn thực tế hơn.
Các lỗi thường gặp khi giải CAPTCHA trong XCUITest
Xử lý các lỗi hạ tầng phổ biến này trước — phần lớn test đỏ ở bước này không phải do CaptchaAI:
evaluateJavaScripttrả vềnil— WebView chưa tải xong; đợiwebView.isLoading == falserồi mới tiêm JS.- Simulator không gọi được dịch vụ trợ giúp —
localhostkhông phân giải được từ Simulator; dùng127.0.0.1hoặc IP mạng của máy Mac, kiểm tra App Transport Security. - Tiêm token xong nhưng callback không chạy — callback của reCAPTCHA nằm lồng trong object phức tạp; duyệt đệ quy toàn bộ thuộc tính của
___grecaptcha_cfg.clients. - XCUITest timeout khi chờ giải xong — thời gian giải của CaptchaAI kéo dài hơn timeout mặc định; đặt timeout test tối thiểu 120 giây cho case có bước giải CAPTCHA.
Chuẩn hoá dữ liệu trao đổi giữa test và dịch vụ trợ giúp
Khi các lỗi hạ tầng ở trên đã hết, bước tiếp theo là chốt rõ "hợp đồng" giữa test runner và dịch vụ trợ giúp trước khi ghép vào CI:
- Định nghĩa rõ payload test runner gửi cho dịch vụ trợ giúp, gồm URL trang đích và metadata thử thách CAPTCHA.
- Trả về response có cấu trúc gồm token, thời hạn sử dụng (nếu có) và lý do lỗi cụ thể để lớp test rẽ nhánh (retry, fail, skip) thay vì báo lỗi chung chung.
- Gắn cùng một tracking ID vào log của Simulator và log của dịch vụ trợ giúp, để khi test đỏ, bạn tra được đúng request nào đã gửi lên CaptchaAI.
Nếu WebView nằm trong SDK bên thứ ba mà bạn không kiểm soát được, cách trên sẽ không dùng được — chuyển sang Appium, vì nó hỗ trợ execute_script trên mọi ngữ cảnh WebView mà không cần chèn hook vào code app.
Bài viết liên quan
- Giải callback của reCAPTCHA v2 bằng API
- Tự động hoá không cần code với Zapier và CaptchaAI
- Xử lý reCAPTCHA v2 và Turnstile trên cùng một trang
- Xử lý CAPTCHA trong tự động hoá ứng dụng di động với Appium
- Xử lý CAPTCHA trong WebView của Flutter
- Trích xuất tham số reCAPTCHA từ mã nguồn trang
Bước tiếp theo
Ghép CaptchaAI vào pipeline test iOS của bạn — lấy API key tại đây và tự động hoá luôn cả những luồng bị CAPTCHA chặn.