Test đăng ký tự động chạy ngon trên local rồi đỏ lòm trên CI chỉ vì một ô reCAPTCHA hoặc Turnstile chưa ai xử lý — tình huống quen thuộc với gần như mọi đội QA từng automation hóa luồng sign-up. Cách xử lý không phức tạp: gắn một bước giải CAPTCHA vào đúng chỗ trong pipeline test, trước khi bấm submit, để toàn bộ suite chạy lại bình thường trên cả staging lẫn CI/CD. Bài này đi thẳng vào code: một class sinh dữ liệu test, một class điền form kiêm giải CAPTCHA, ba test case (đăng ký hợp lệ, email trùng, mật khẩu yếu), và cách gắn vào pytest.
Vì sao test đăng ký luôn "va" phải CAPTCHA
Gần như mọi biểu mẫu đăng ký công khai đều có CAPTCHA ở bước cuối để chặn tài khoản bot hàng loạt — đó chính là lý do nó tồn tại, và cũng là lý do bộ test tự động của bạn bị chặn theo. Với một đội QA, việc này kéo theo bốn việc cần làm thường xuyên chứ không phải một lần:
- Xác minh luồng đăng ký chạy đúng trên cả staging và production sau mỗi lần deploy
- Test các trường hợp biên: email trùng lặp, mật khẩu yếu, thiếu trường bắt buộc
- Chạy lại test hồi quy (regression) mỗi khi có bản deploy mới
- Kiểm tra vị trí đặt CAPTCHA trên form không vô tình phá layout hoặc chặn nút submit
Nếu không tự động giải được CAPTCHA, đội test buộc phải làm thủ công bước này — vừa chậm, vừa không thể chạy trong CI/CD.
Ví dụ thực tế: nhiều công ty gia công phần mềm và đội QA nội bộ ở TP.HCM, Hà Nội nhận hợp đồng kiểm thử tự động cho các nền tảng thương mại điện tử hoặc SaaS của khách hàng nước ngoài. Luồng đăng ký gần như luôn nằm trong scope test đầu tiên, và gần như luôn có reCAPTCHA hoặc Cloudflare Turnstile chặn ngay bước submit. Gắn một bước giải CAPTCHA qua API vào bộ test Selenium/pytest sẵn có giúp đội QA giữ nguyên bộ test hiện tại thay vì phải viết lại luồng chỉ vì một ô CAPTCHA.
Luồng test 4 bước, từ dữ liệu đến xác minh tài khoản
┌──────────┐ ┌───────────────┐ ┌────────────┐ ┌────────────┐
│ Test Data │────▶│ Fill Form + │────▶│ Solve │────▶│ Verify │
│ Generator │ │ Edge Cases │ │ CAPTCHA │ │ Account │
└──────────┘ └───────────────┘ └────────────┘ └────────────┘
Bốn khối trên ứng với bốn phần code bên dưới: sinh dữ liệu ngẫu nhiên, điền form kèm các case biên, giải CAPTCHA, rồi kiểm tra tài khoản đã tạo thành công hay chưa. _solve_captcha() chỉ xử lý hai loại phổ biến nhất trên form đăng ký:
| Loại CAPTCHA | Trạng thái CaptchaAI | Field token gắn vào form |
|---|---|---|
| reCAPTCHA v2 (kể cả Invisible) | ✅ Hỗ trợ | g-recaptcha-response |
| Cloudflare Turnstile | ✅ Hỗ trợ | cf-turnstile-response |
| hCaptcha | ❌ Chưa hỗ trợ | — |
Xây dựng bộ test
Sinh dữ liệu người dùng test (TestUser)
Mỗi lần chạy cần một email/username mới để tránh đụng dữ liệu của lần chạy trước — class dưới đây gắn timestamp và chuỗi ngẫu nhiên vào prefix test cho việc này.
import random
import string
import time
class TestUser:
def __init__(self, prefix="test"):
ts = int(time.time())
rand = ''.join(random.choices(string.ascii_lowercase, k=4))
self.first_name = f"{prefix}_{rand}"
self.last_name = "User"
self.email = f"{prefix}_{ts}_{rand}@testmail.example.com"
self.username = f"{prefix}_{ts}_{rand}"
self.password = f"Test!{ts}{rand.upper()}"
def as_dict(self):
return {
"first_name": self.first_name,
"last_name": self.last_name,
"email": self.email,
"username": self.username,
"password": self.password,
}
Class điền form, giải CAPTCHA và kiểm tra kết quả (RegistrationTester)
Đây là phần lõi: CaptchaSolver gửi task tới in.php rồi polling res.php cho đến khi có token; RegistrationTester dùng Selenium điền form, gọi _solve_captcha() để phát hiện Turnstile hoặc reCAPTCHA trên trang và điền token vào đúng field trước khi submit. Ba test case bên dưới (đăng ký hợp lệ, email trùng, mật khẩu yếu) đều tái sử dụng cùng một luồng này.
import time
import requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class CaptchaSolver:
BASE = "https://ocr.captchaai.com"
def __init__(self, api_key):
self.api_key = api_key
def solve_recaptcha(self, sitekey, pageurl):
return self._solve({
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": pageurl,
})
def solve_turnstile(self, sitekey, pageurl):
return self._solve({
"method": "turnstile",
"sitekey": sitekey,
"pageurl": pageurl,
})
def _solve(self, params, initial_wait=10):
params["key"] = self.api_key
params["json"] = 1
resp = requests.post(f"{self.BASE}/in.php", data=params).json()
if resp["status"] != 1:
raise Exception(resp["request"])
task_id = resp["request"]
time.sleep(initial_wait)
for _ in range(60):
result = requests.get(
f"{self.BASE}/res.php",
params={"key": self.api_key, "action": "get", "id": task_id, "json": 1},
).json()
if result["request"] == "CAPCHA_NOT_READY":
time.sleep(5)
continue
if result["status"] == 1:
return result["request"]
raise Exception(result["request"])
raise TimeoutError("Timed out")
class RegistrationTester:
def __init__(self, api_key, base_url):
self.solver = CaptchaSolver(api_key)
self.base_url = base_url
self.driver = webdriver.Chrome()
self.wait = WebDriverWait(self.driver, 10)
self.results = []
def _fill(self, selector, value):
el = self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, selector)))
el.clear()
el.send_keys(value)
def _solve_captcha(self):
html = self.driver.page_source
page_url = self.driver.current_url
# Turnstile
turnstile = self.driver.find_elements(By.CSS_SELECTOR, ".cf-turnstile")
if turnstile:
sitekey = turnstile[0].get_attribute("data-sitekey")
token = self.solver.solve_turnstile(sitekey, page_url)
self.driver.execute_script(
f'document.querySelector("[name=cf-turnstile-response]").value = "{token}";'
)
return
# reCAPTCHA
recaptcha = self.driver.find_elements(By.CSS_SELECTOR, "[data-sitekey]")
if recaptcha and "recaptcha" in html.lower():
sitekey = recaptcha[0].get_attribute("data-sitekey")
token = self.solver.solve_recaptcha(sitekey, page_url)
self.driver.execute_script(
f'document.querySelector("[name=g-recaptcha-response]").value = "{token}";'
)
def _get_errors(self):
"""Collect any visible error messages on the page."""
error_selectors = [
".error", ".alert-danger", ".form-error",
"[role='alert']", ".validation-error",
]
errors = []
for sel in error_selectors:
for el in self.driver.find_elements(By.CSS_SELECTOR, sel):
text = el.text.strip()
if text:
errors.append(text)
return errors
def _check_success(self):
"""Check if registration succeeded."""
html = self.driver.page_source.lower()
url = self.driver.current_url.lower()
success_indicators = [
"welcome", "account created", "verify your email",
"registration successful", "thank you for registering",
]
return any(ind in html or ind in url for ind in success_indicators)
# --- Test Cases ---
def test_valid_registration(self):
"""Test: Valid registration should succeed."""
user = TestUser()
self.driver.get(f"{self.base_url}/register")
self._fill("[name='firstName'], #first-name", user.first_name)
self._fill("[name='lastName'], #last-name", user.last_name)
self._fill("[name='email'], #email", user.email)
self._fill("[name='username'], #username", user.username)
self._fill("[name='password'], #password", user.password)
confirm_fields = self.driver.find_elements(By.CSS_SELECTOR, "[name='confirmPassword'], #confirm-password")
if confirm_fields:
confirm_fields[0].clear()
confirm_fields[0].send_keys(user.password)
self._solve_captcha()
self.driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
time.sleep(3)
success = self._check_success()
self.results.append({
"test": "valid_registration",
"passed": success,
"user": user.email,
"errors": self._get_errors() if not success else [],
})
return success
def test_duplicate_email(self):
"""Test: Duplicate email should show error."""
user = TestUser()
# First registration
self.driver.get(f"{self.base_url}/register")
self._fill("[name='email'], #email", user.email)
self._fill("[name='password'], #password", user.password)
self._fill("[name='firstName'], #first-name", user.first_name)
self._solve_captcha()
self.driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
time.sleep(3)
# Second registration with same email
self.driver.get(f"{self.base_url}/register")
self._fill("[name='email'], #email", user.email)
self._fill("[name='password'], #password", user.password)
self._fill("[name='firstName'], #first-name", "Duplicate")
self._solve_captcha()
self.driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
time.sleep(3)
errors = self._get_errors()
has_error = len(errors) > 0 or not self._check_success()
self.results.append({
"test": "duplicate_email",
"passed": has_error,
"errors": errors,
})
return has_error
def test_weak_password(self):
"""Test: Weak password should be rejected."""
user = TestUser()
self.driver.get(f"{self.base_url}/register")
self._fill("[name='email'], #email", user.email)
self._fill("[name='password'], #password", "123") # Weak password
self._fill("[name='firstName'], #first-name", user.first_name)
self._solve_captcha()
self.driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
time.sleep(3)
errors = self._get_errors()
rejected = len(errors) > 0 or not self._check_success()
self.results.append({
"test": "weak_password",
"passed": rejected,
"errors": errors,
})
return rejected
def run_all(self):
"""Run all registration tests."""
tests = [
self.test_valid_registration,
self.test_duplicate_email,
self.test_weak_password,
]
for test_fn in tests:
try:
test_fn()
except Exception as e:
self.results.append({
"test": test_fn.__name__,
"passed": False,
"errors": [str(e)],
})
return self.results
def report(self):
passed = sum(1 for r in self.results if r["passed"])
total = len(self.results)
lines = [f"Registration Tests: {passed}/{total} passed", "-" * 40]
for r in self.results:
status = "PASS" if r["passed"] else "FAIL"
lines.append(f" [{status}] {r['test']}")
if r.get("errors"):
for err in r["errors"]:
lines.append(f" {err}")
return "\n".join(lines)
def close(self):
self.driver.quit()
Lỗi thường gặp khi gắn CAPTCHA vào bộ test
Trước khi chạy thử, đây là bốn lỗi chiếm phần lớn các ca fail khi mới gắn CaptchaSolver vào bộ test có sẵn — thường do selector khác trang thật hoặc thứ tự gọi hàm sai, không phải do bản thân API giải CAPTCHA:
| Vấn đề | Nguyên nhân | Cách xử lý |
|---|---|---|
| Đăng ký thành công nhưng test vẫn báo fail | success_indicators chưa khớp với trang thật của bạn |
Thêm văn bản/URL báo thành công riêng của site vào _check_success() |
| CAPTCHA không được phát hiện | Widget CAPTCHA render trễ hơn thời điểm gọi _solve_captcha() |
Thêm time.sleep(2) trước khi gọi _solve_captcha() |
| Không tìm thấy trường form | Cấu trúc HTML/selector khác với site đang test | Cập nhật CSS selector trong _fill() cho đúng site của bạn |
| Token hết hạn trước khi submit | Gọi _solve_captcha() quá sớm, có độ trễ giữa lúc giải và lúc bấm submit |
Đặt _solve_captcha() ngay sát bước click submit |
Chạy test suite
Khởi tạo RegistrationTester với API key và URL môi trường test, sau đó gọi run_all():
tester = RegistrationTester("YOUR_API_KEY", "https://staging.example.com")
try:
tester.run_all()
print(tester.report())
finally:
tester.close()
Đầu ra:
Registration Tests: 3/3 passed
----------------------------------------
[PASS] valid_registration
[PASS] duplicate_email
[PASS] weak_password
Tích hợp vào pytest cho CI/CD
Ba test case ở trên map thẳng vào ba hàm test pytest, dùng chung một fixture tester ở scope module để không phải khởi tạo lại Selenium driver mỗi lần:
import pytest
@pytest.fixture(scope="module")
def tester():
t = RegistrationTester("YOUR_API_KEY", "https://staging.example.com")
yield t
t.close()
def test_valid_registration(tester):
assert tester.test_valid_registration(), "Valid registration should succeed"
def test_duplicate_email_rejected(tester):
assert tester.test_duplicate_email(), "Duplicate email should be rejected"
def test_weak_password_rejected(tester):
assert tester.test_weak_password(), "Weak password should be rejected"
Câu hỏi thường gặp
Bộ test này chạy trong CI/CD được không?
Được. Chạy Chrome ở chế độ headless (options.add_argument("--headless")), đặt API key CaptchaAI làm biến môi trường của pipeline, và toàn bộ ba test case chạy y hệt như trên máy local — không cần màn hình.
CaptchaAI hỗ trợ loại CAPTCHA nào cho luồng đăng ký này?
Ví dụ trong bài xử lý reCAPTCHA v2 (bao gồm Invisible) và Cloudflare Turnstile — hai loại phổ biến nhất trên form đăng ký. CaptchaAI còn giải được Cloudflare Challenge, GeeTest v3, CAPTCHA ảnh/OCR và dạng lưới ảnh; hCaptcha và FunCaptcha thì chưa hỗ trợ, nên nếu site bạn test dùng hai loại đó, _solve_captcha() cần logic khác.
Vì sao vẫn thấy lỗi "token hết hạn" dù CAPTCHA đã giải xong?
Token của reCAPTCHA/Turnstile chỉ có hiệu lực trong một khoảng thời gian ngắn sau khi giải. Nếu giữa bước _solve_captcha() và bước bấm submit có quá nhiều thao tác khác (điền thêm field, chờ animation…), token hết hạn trước khi form được gửi — dời _solve_captcha() lại gần bước submit nhất có thể để tránh việc này.
Có được tự động hóa đăng ký trên website không phải của mình không?
Không. Bộ test trong bài chỉ dành cho ứng dụng/staging bạn hoặc công ty bạn sở hữu và có quyền kiểm thử. Tự động tạo tài khoản hàng loạt trên site của bên thứ ba nằm ngoài phạm vi hướng dẫn này.
Chạy CAPTCHA cho hàng trăm lượt test mỗi ngày có tốn nhiều không?
Không tính theo từng lần giải. CaptchaAI tính phí theo số thread chạy đồng thời — gói BASIC ($15/tháng, 5 thread) đã đủ cho vài luồng test song song trong CI, và mỗi thread được giải không giới hạn số CAPTCHA trong tháng.
Bài viết liên quan
Xem thêm: tự động gửi biểu mẫu có CAPTCHA và bảng tham chiếu API CaptchaAI.
Đừng để CAPTCHA làm gián đoạn pipeline test đăng ký của bạn — dùng CaptchaAI để tự động hóa toàn bộ luồng.