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
- Phát hiện
sitekey/pageurltrong WebView - Gửi sang backend giải CAPTCHA
- Backend gọi CaptchaAI qua
in.php, pollingres.phptới khi có token - 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ý
JavascriptInterfacetênCaptchaHelper, dò.g-recaptchalấydata-sitekey - Gọi backend giải CAPTCHA qua OkHttp
- Dùng
evaluateJavascript()gán token vàog-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ọidetectCaptcha() - 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: