Tích Hợp

Kiểm tra CAPTCHA trên Android với Espresso và CaptchaAI

Test Espresso đứng hình ở màn hình thanh toán, log chỉ ra một ô reCAPTCHA v2 nằm im trong WebView? Espresso lái UI native, không "nhìn" được WebView hay tự giải CAPTCHA. Cách xử lý: dựng service backend gọi CaptchaAI giải reCAPTCHA v2 ngoài luồng test, rồi bơm token vào WebView bằng evaluateJavascript().

Bài viết này giúp bạn:

  • Phát hiện CAPTCHA trong WebView khi Espresso đang chạy
  • Giải reCAPTCHA v2 qua backend gọi CaptchaAI
  • Tiêm token trở lại để test chạy tiếp đến hết luồng

Vì sao CAPTCHA làm gãy pipeline CI của bạn

Nhiều đội QA ở TP.HCM và Hà Nội gặp đúng kịch bản này: app Android load trang checkout đối tác trong WebView, trang bật reCAPTCHA v2 trước khi thanh toán, và test Espresso đứng khựng vì không ai bấm "Tôi không phải robot".

Ba dạng app hay gặp:

  • App thương mại điện tử nhúng cổng thanh toán bên thứ ba
  • App đặt vé, đặt chỗ có bước xác minh qua WebView
  • App nối cổng dịch vụ công có reCAPTCHA v2

Luồng xử lý 4 bước

  1. Phát hiện sitekey/pageurl trong WebView
  2. Gửi sang backend giải CAPTCHA
  3. Backend gọi CaptchaAI qua in.php, polling res.php tới khi có token
  4. Inject token về WebView, kích hoạt callback reCAPTCHA

Môi trường mẫu: Android Studio, Kotlin, Espresso, AndroidX Test, API CaptchaAI, backend Python.

Bước 1: Thêm trình trợ giúp kiểm thử vào ứng dụng

Thêm một helper chỉ tồn tại trong bản debug, chạy JavaScript trong WebView của app để tìm sitekey rồi tiêm token trở lại:

// CaptchaTestHelper.kt — debug source set only
package com.example.app.testing

import android.webkit.JavascriptInterface
import android.webkit.WebView
import kotlinx.coroutines.*
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject

class CaptchaTestHelper(private val webView: WebView) {

    private var detectedSitekey: String? = null
    private var detectedPageUrl: String? = null
    private var solvedToken: String? = null

    @JavascriptInterface
    fun onCaptchaDetected(sitekey: String, pageurl: String) {
        detectedSitekey = sitekey
        detectedPageUrl = pageurl
    }

    fun detectCaptcha() {
        webView.post {
            webView.evaluateJavascript("""
                (function() {
                    var el = document.querySelector('.g-recaptcha');
                    if (el) {
                        CaptchaHelper.onCaptchaDetected(
                            el.getAttribute('data-sitekey'),
                            window.location.href
                        );
                        return 'found';
                    }
                    return 'not_found';
                })();
            """, null)
        }
    }

    suspend fun solveAndInject(): Boolean = withContext(Dispatchers.IO) {
        val sitekey = detectedSitekey ?: return@withContext false
        val pageurl = detectedPageUrl ?: return@withContext false

        // Call backend solver
        val client = OkHttpClient.Builder()
            .callTimeout(java.time.Duration.ofMinutes(3))
            .build()

        val body = JSONObject().apply {
            put("captchaType", "recaptcha_v2")
            put("sitekey", sitekey)
            put("pageurl", pageurl)
        }.toString().toRequestBody("application/json".toMediaType())

        val request = Request.Builder()
            .url("http://10.0.2.2:3000/api/solve-captcha")  // Host loopback for emulator
            .post(body)
            .build()

        val response = client.newCall(request).execute()
        val json = JSONObject(response.body?.string() ?: "")
        val token = json.optString("token", "")

        if (token.isEmpty()) return@withContext false

        solvedToken = token

        // Inject token on main thread
        withContext(Dispatchers.Main) {
            webView.evaluateJavascript("""
                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) {}
            """, null)
        }

        return@withContext true
    }

    companion object {
        fun attach(webView: WebView): CaptchaTestHelper {
            val helper = CaptchaTestHelper(webView)
            webView.addJavascriptInterface(helper, "CaptchaHelper")
            return helper
        }
    }
}

Helper này làm ba việc:

  • Đăng ký JavascriptInterface tên CaptchaHelper, dò .g-recaptcha lấy data-sitekey
  • Gọi backend giải CAPTCHA qua OkHttp
  • Dùng evaluateJavascript() gán token vào g-recaptcha-response, bắn lại callback gốc của Google

Bước 2: Dựng service giải CAPTCHA ở backend

Chạy service Python này song song với suite Espresso:

# android_test_solver.py
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

    # Submit to CaptchaAI
    resp = requests.get("https://ocr.captchaai.com/in.php", params={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": data["sitekey"],
        "pageurl": data["pageurl"],
        "json": "1",
    })
    result = resp.json()
    if result.get("status") != 1:
        return jsonify({"error": result.get("request")}), 400

    task_id = result["request"]

    # Poll for result
    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)

Service Flask nhận sitekey/pageurl, gửi task tới in.php, polling res.php mỗi 5 giây tới khi có token hoặc hết 30 lần thử. 10.0.2.2 là địa chỉ loopback của Android Emulator trỏ về máy host, không phải localhost.

Bước 3: Viết test Espresso xử lý CAPTCHA

Gắn helper vào test Espresso, chạy trước bước xác minh đơn hàng:

// CheckoutCaptchaTest.kt
package com.example.app

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class CheckoutCaptchaTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun testCheckoutWithCaptcha() {
        // Navigate to checkout
        onView(withId(R.id.checkout_button)).perform(click())

        // Wait for WebView to load
        Thread.sleep(5000)

        // Access the WebView and attach helper
        activityRule.scenario.onActivity { activity ->
            val webView = activity.findViewById<android.webkit.WebView>(R.id.webview)

            val helper = CaptchaTestHelper.attach(webView)
            helper.detectCaptcha()

            // Wait for detection
            Thread.sleep(2000)

            // Solve and inject
            runBlocking {
                val solved = helper.solveAndInject()
                assert(solved) { "CAPTCHA should be solved successfully" }
            }
        }

        // Continue with form submission after token injection
        Thread.sleep(1000)

        // Verify checkout completed
        onView(withText("Order Confirmed")).check(
            androidx.test.espresso.assertion.ViewAssertions.matches(isDisplayed())
        )
    }
}

Test này:

  • Gắn helper vào WebView, gọi detectCaptcha()
  • Chạy solveAndInject() trong coroutine sau khi phát hiện xong
  • Kiểm tra màn hình "Order Confirmed" xuất hiện — đúng bước từng bị CAPTCHA chặn

Câu hỏi thường gặp

Cần chuẩn bị gì trước khi thêm bước giải CAPTCHA vào test Espresso?

Một backend chạy song song với test (Python ở đây, viết ngôn ngữ khác cũng được), một API key CaptchaAI, và helper debug-only gắn vào WebView.

Cách này có chạy được trên thiết bị thật trong pipeline CI không?

Có. Thay 10.0.2.2 bằng IP thật của máy chạy backend, đảm bảo thiết bị cùng mạng.

CaptchaAI có xử lý được Cloudflare Turnstile trong WebView tương tự reCAPTCHA v2 không?

Có — cùng nguyên lý phát hiện sitekey/pageurl rồi gọi CaptchaAI, chỉ khác method.

reCAPTCHA Enterprise trong app Android có cần chỉnh gì khác không?

Luồng gọi gần như giữ nguyên, chỉ cần sitekey Enterprise và có thể thêm enterprise: 1.

Chạy giải CAPTCHA ở mỗi lần test có tốn thêm phí theo lượt không?

Không. CaptchaAI tính phí theo thread song song, không theo lượt giải — gói BASIC ($15/tháng, 5 thread) đủ cho suite chạy vài trăm lượt/ngày.

Các lỗi thường gặp và cách xử lý

Vấn đề Nguyên nhân Cách xử lý
Không kết nối được 10.0.2.2 Đang chạy trên thiết bị thật, không phải emulator Dùng IP thật của máy host cho thiết bị vật lý; 10.0.2.2 chỉ dùng cho emulator
Callback evaluateJavascript trả về null WebView chưa load xong khi gọi Thêm listener WebViewClient.onPageFinished() trước khi đánh giá JS
addJavascriptInterface không phản hồi JavaScript đang bị tắt Bật webView.settings.javaScriptEnabled = true
Request bị chặn bởi Cleartext policy Gọi HTTP tới localhost trên Android 9+ Thêm android:usesCleartextTraffic="true" vào AndroidManifest.xml (chỉ bản debug)

Bước tiếp theo và bài viết liên quan

Đưa bước giải CAPTCHA vào test Android — lấy API key CaptchaAI, dựng backend theo Bước 2. Đọc thêm:

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