Integrations

Manipulação de CAPTCHA de automação iOS com XCUITest e CaptchaAI

O WKWebView não expõe uma ponte de JavaScript para o XCUITest — então, quando um formulário carregado nesse WebView traz um reCAPTCHA v2, o teste trava ali até alguém resolver o desafio manualmente. A saída é resolver o CAPTCHA por fora: um serviço auxiliar chama a API da CaptchaAI, recebe o token de volta e você injeta esse token no WebView via JavaScript, como o callback do reCAPTCHA espera.

Este guia percorre três peças:

  • um gancho de teste dentro do próprio app, ativo só na build de debug
  • um serviço solucionador em Python que conversa com a CaptchaAI
  • a chamada no XCUITest que aciona tudo isso durante a execução do teste

Arquitetura: uma ponte fora do WebView

O XCUITest não consegue rodar JavaScript diretamente dentro de um WKWebView. A solução usa um endpoint auxiliar que o próprio app chama durante o teste:

  • Teste XCUITest — conduz a UI e aciona a resolução do CAPTCHA por meio do auxiliar de teste
  • API auxiliar de teste — recebe sitekey + URL, chama a CaptchaAI, devolve o token
  • Gancho de teste do app — avalia JavaScript no WKWebView para detectar e injetar
  • API da CaptchaAI — resolve o desafio CAPTCHA

Onde o CAPTCHA trava o teste de iOS

Um cenário comum: o app carrega uma tela de cadastro num WKWebView, e o formulário embute reCAPTCHA v2 — típico de apps que reaproveitam um formulário web para login, pagamento ou onboarding. Na suíte de testes de UI, esse desafio interrompe o fluxo antes da tela de confirmação.

Para destravar isso sem intervenção manual, o teste precisa passar por quatro etapas: detectar o CAPTCHA no WebView durante a execução, extrair a sitekey programaticamente, resolver o desafio via CaptchaAI e injetar o token de volta para o formulário ser enviado.

Ambiente: Xcode 15+, Swift, XCUITest, executor de testes macOS, API da CaptchaAI.

Passo 1: adicione um gancho de teste ao app

No controlador do WKWebView do seu app, adicione um manipulador de CAPTCHA ativo somente em modo de teste, acionável por identificador de acessibilidade ou esquema de URL:

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

Passo 2: o serviço solucionador no back-end

Rode um serviço de resolução local durante o teste, responsável por conversar com a 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)

Passo 3: a chamada no XCUITest

No teste, acione a resolução do CAPTCHA assim que o WebView com o desafio terminar de carregar:

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

Perguntas frequentes

Como evito expor a chave de API da CaptchaAI no repositório de testes?

Nunca hardcode a chave no código-fonte. Guarde-a em um destes dois lugares:

  • Uma variável de ambiente do CI, lida como no exemplo os.environ.get("CAPTCHAAI_API_KEY", ...).
  • Um secret do próprio provedor de CI (GitHub Actions, Bitrise), nunca commitado no repositório.

O serviço solucionador precisa rodar na mesma máquina do simulador?

Não precisa, só uma rede acessível pelo Simulador. Em CI hospedado, é mais simples apontar para 127.0.0.1 ou localhost no mesmo executor macOS.

Quanto tempo esse fluxo acrescenta à suíte de testes?

Depende de dois fatores:

  • o tipo de CAPTCHA e a fila da CaptchaAI no momento da execução
  • 120 s de timeout por teste, no mínimo — melhor superestimar do que ver o CI falhar num dia de pico

Esse fluxo funciona em runners de CI, como GitHub Actions ou Bitrise?

Sim, desde que o back-end solucionador rode na mesma máquina do runner (ou do Simulador).

O XCUITest consegue executar JavaScript diretamente no WKWebView?

Não — e essa é a raiz do problema:

  • interage com elementos de UI (botões, campos, textos)
  • não avalia JavaScript dentro do WKWebView

Por isso o gancho de teste no código do app (só em build de debug) é o que fecha essa lacuna.

Contrato da ponte e problemas comuns

  1. Defina o payload que o teste envia ao serviço auxiliar: URL de destino e metadados do desafio.
  2. Devolva uma resposta estruturada com token, expiração e motivo do erro, para a camada de teste ramificar sem travar.
  3. Ligue os logs do simulador aos do serviço auxiliar por um identificador de rastreamento comum — isso acelera a triagem de falhas intermitentes. Se algum log guardar dado de um usuário de teste real, e não só dado fictício de QA, trate-o como dado pessoal sob a LGPD e não o deixe em um pipeline de CI público.

Problemas frequentes nessa ponte:

  • evaluateJavaScript retorna nulo — o WebView não terminou de carregar; aguarde webView.isLoading == false antes de injetar o JS.
  • Simulador não alcança o back-endlocalhost não é acessível a partir do Simulador; use 127.0.0.1 ou o IP de rede do Mac e confira o App Transport Security.
  • Token injetado não dispara o callback — o callback do reCAPTCHA está aninhado em objeto complexo; percorra recursivamente as propriedades de ___grecaptcha_cfg.clients.
  • XCUITest estoura o tempo limite — a resolução da CaptchaAI variou naquela execução; configure o timeout do teste para 120 s ou mais.

Próximos passos

Leve esse fluxo para o seu pipeline de testes de iOS — obtenha sua chave de API e automatize os fluxos protegidos por CAPTCHA na sua suíte. Guias relacionados:

  1. Manipulação de CAPTCHA na automação de apps móveis com Appium
  2. Manipulação de CAPTCHA no WebView do Flutter
  3. Extraindo parâmetros do reCAPTCHA a partir do código-fonte da página

Artigos relacionados

Os comentários estão desativados para este artigo.