Integrations

使用 Espresso 和 CaptchaAI 进行 Android CAPTCHA 测试

Espresso 测试卡在 WebView 里的验证码上,本质上是缺一座桥:拿到 sitekey,交给识别接口,把 token 写回页面。

CaptchaAI 补上这座桥,下面这套方案能直接搬进项目里用,分三步:

  • 用仅在 debug 环境生效的 JavascriptInterface 探测 WebView 里的验证码
  • 把 sitekey、pageurl 交给 CaptchaAI API 完成识别
  • 把 token 注入 WebView,让 Espresso 用例接着跑完登录、注册或支付流程

什么时候会卡在验证码上

跨境电商类 App 经常会在 WebView 里内嵌第三方支付页面,页面在放行付款前弹出 reCAPTCHA v2。

这是很多做海外业务的国内团队都会遇到的场景。

Espresso 的插桩测试一旦跑到这一步就会卡住:没有人手动点验证码,测试直接超时失败。

本文用到的环境:Android Studio、Kotlin、Espresso、AndroidX Test、CaptchaAI API、Python。

第一步:在 App 里加一个调试专用的验证码探测助手

在 debug source set 里加一个 helper。

它能对 WebView 执行 JavaScript,拿到 sitekey 和当前页面地址:

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

第二步:用 Python 写一个后端求解器

测试运行期间,在开发机上跑这个 Flask 服务。

它负责把 sitekey 和 pageurl 提交给 CaptchaAI,再轮询拿到 token:

# 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)

第三步:在 Espresso 用例里接入验证码处理逻辑

把探测、识别、注入这三步,串进真实的结账验证测试:

// 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 提交
        Thread.sleep(1000)

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

常见报错与排查

问题 原因 处理方式
10.0.2.2 连不上 没有在 Android 模拟器里跑 真机测试改用宿主机实际 IP
evaluateJavascript 回调是空的 WebView 还没加载完 在执行脚本前,先加一个 WebViewClient.onPageFinished() 监听
addJavascriptInterface 没反应 JavaScript 被禁用了 调用 webView.settings.javaScriptEnabled = true
网络请求被明文策略拦截 Android 9 及以上访问 HTTP 本地地址 AndroidManifest.xml 里加 android:usesCleartextTraffic="true"(仅 debug 用)

表格没解决问题时,先确认:

  • Flask 有没有在跑
  • 模拟器网络是否互通
  • API_KEY 是否读取正确

常见问题

Espresso 能不能直接操作 WebView 里的验证码?

不能,onWebView() 只支持基础交互,验证码处理要用 evaluateJavascript()

国内环境跑这套测试,会不会因为连不上 Google 服务而卡住?

reCAPTCHA 脚本走 Google 域名,加载可能不稳定,但识别请求直接打给 CaptchaAI,不受影响,放宽两步超时即可。

CaptchaTestHelper 会不会混进正式包?

不会,放进 src/debug/java/ source set 即可,Android 构建变体会自动从 release 包排除。

多台模拟器并行跑测试,需要开更多 CaptchaAI 线程吗?

会有影响,CaptchaAI 按线程计费,BASIC $15/月含 5 线程;并行数上去了就对齐线程数。

WebView 里出现的是极验(GeeTest)而不是 reCAPTCHA,这套方案还能用吗?

思路一样:selector 换成对应 DOM 结构,method 参数换成 CaptchaAI 支持的类型。

GeeTest 目前支持到 v3,注入方式按回调机制调整即可。

下一步

想落地?获取 CaptchaAI API Key,部署好本文的求解器就能直接用。

相关指南:

相关文章

该文章已禁用评论。