Test end-to-end chạy trong GitHub Actions, GitLab CI hay Jenkins không có ai ngồi giải CAPTCHA bằng tay — nên ngay khi trang đăng nhập hoặc form liên hệ gặp reCAPTCHA v2 hay Cloudflare Turnstile, cả pipeline chuyển đỏ. Cách xử lý không phải là loại các trang có CAPTCHA khỏi bộ test, mà là gắn một bước giải CAPTCHA tự động ngay trong test suite, dùng API key lưu ở CI secret. Bài này đi thẳng vào phần khó nhất: viết class helper gọi CaptchaAI, gắn vào pytest, rồi cấu hình cho ba nền tảng CI phổ biến nhất.
Vì sao CAPTCHA làm pipeline CI đỏ liên tục
Pipeline CI/CD chạy hoàn toàn tự động — không có trình duyệt thật do người điều khiển, không ai tick "Tôi không phải robot". CAPTCHA sinh ra để phân biệt người và máy, nên bất kỳ trang nào có reCAPTCHA, Turnstile hay các loại tương tự đều khiến test end-to-end dừng lại ở đúng bước đó, mỗi lần chạy, cho tới khi có người can thiệp thủ công.
Giải pháp: gọi API của CaptchaAI ngay trong test suite. Khóa API lưu dưới dạng CI secret; class helper gửi task tới CaptchaAI, polling kết quả rồi đưa token vào ô CAPTCHA — toàn bộ diễn ra trong vài giây, không cần sửa code của ứng dụng đang test.
Sơ đồ luồng xử lý
Bốn khối nối tiếp nhau tạo thành luồng CI có CAPTCHA: git push kích hoạt CI runner, runner mở Chrome headless chạy test E2E, test nào gặp CAPTCHA sẽ gọi sang CaptchaAI để lấy token, rồi toàn bộ kết quả đổ vào báo cáo test.
┌──────────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────────┐
│ Git Push │────▶│ CI Runner │────▶│ E2E Tests │────▶│ Test Report │
│ │ │ (headless │ │ + CAPTCHA │ │ │
│ │ │ Chrome) │ │ solving │ │ │
└──────────────┘ └──────────────┘ └────────────┘ └──────────────┘
│
▼
┌────────────┐
│ CaptchaAI │
│ API │
└────────────┘
Class helper giải CAPTCHA cho test suite
Class dưới đây gói gọn logic gửi task tới in.php, polling res.php và trả về token — viết một lần, tái sử dụng cho mọi test file trong repo.
import os
import time
import requests
class CICaptchaSolver:
"""CAPTCHA solver designed for CI environments."""
BASE = "https://ocr.captchaai.com"
def __init__(self):
self.api_key = os.environ.get("CAPTCHAAI_API_KEY")
if not self.api_key:
raise EnvironmentError("CAPTCHAAI_API_KEY not set")
def solve(self, params, initial_wait=10, timeout=120):
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(f"CAPTCHA submit failed: {resp['request']}")
task_id = resp["request"]
time.sleep(initial_wait)
deadline = time.time() + timeout
while time.time() < deadline:
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(f"CAPTCHA solve failed: {result['request']}")
raise TimeoutError("CAPTCHA solve timed out in CI")
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,
})
initial_wait mặc định 10 giây trước khi polling lần đầu, timeout 120 giây là đủ cho phần lớn reCAPTCHA v2 và Turnstile. Nếu CI runner của bạn ở xa server CaptchaAI về mặt địa lý, cứ tăng timeout thay vì để test fail oan.
Gắn helper vào pytest
conftest.py
Fixture browser bật Chrome ở chế độ headless đúng kiểu CI: không sandbox, không GPU, không cần màn hình ảo riêng.
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
@pytest.fixture(scope="session")
def captcha_solver():
return CICaptchaSolver()
@pytest.fixture(scope="function")
def browser():
options = Options()
options.add_argument("--headless")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-gpu")
driver = webdriver.Chrome(options=options)
driver.set_window_size(1920, 1080)
yield driver
driver.quit()
File test đăng nhập và form liên hệ
Hai class dưới đây minh hoạ hai luồng thật gặp trong hầu hết dự án: đăng nhập có reCAPTCHA v2, và form liên hệ có Turnstile. Token lấy từ CaptchaAI được gán thẳng vào input ẩn bằng execute_script trước khi submit form.
import time
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class TestLoginFlow:
SITEKEY = "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-"
LOGIN_URL = "https://staging.example.com/qa-login"
def test_login_with_captcha(self, browser, captcha_solver):
browser.get(self.LOGIN_URL)
# Fill credentials
browser.find_element(By.ID, "username").send_keys("testuser")
browser.find_element(By.ID, "password").send_keys("testpass123")
# Solve CAPTCHA
token = captcha_solver.solve_recaptcha(self.SITEKEY, self.LOGIN_URL)
browser.execute_script(
f'document.querySelector("[name=g-recaptcha-response]").value = "{token}";'
)
# Submit
browser.find_element(By.ID, "login-btn").click()
time.sleep(3)
# Verify login success
assert "dashboard" in browser.current_url.lower()
def test_login_wrong_password(self, browser, captcha_solver):
browser.get(self.LOGIN_URL)
browser.find_element(By.ID, "username").send_keys("testuser")
browser.find_element(By.ID, "password").send_keys("wrongpass")
token = captcha_solver.solve_recaptcha(self.SITEKEY, self.LOGIN_URL)
browser.execute_script(
f'document.querySelector("[name=g-recaptcha-response]").value = "{token}";'
)
browser.find_element(By.ID, "login-btn").click()
time.sleep(3)
error = browser.find_element(By.CSS_SELECTOR, ".error-message")
assert error.is_displayed()
class TestContactForm:
SITEKEY = "0x4AAAA..."
FORM_URL = "https://staging.example.com/contact"
def test_contact_form_submission(self, browser, captcha_solver):
browser.get(self.FORM_URL)
browser.find_element(By.ID, "name").send_keys("CI Test")
browser.find_element(By.ID, "email").send_keys("[email protected]")
browser.find_element(By.ID, "message").send_keys("Automated CI test")
token = captcha_solver.solve_turnstile(self.SITEKEY, self.FORM_URL)
browser.execute_script(
f'document.querySelector("[name=cf-turnstile-response]").value = "{token}";'
)
browser.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
WebDriverWait(browser, 10).until(
EC.presence_of_element_located((By.CSS_SELECTOR, ".success-message"))
)
Lưu ý tên field trong DOM: g-recaptcha-response cho reCAPTCHA, cf-turnstile-response cho Turnstile. Gõ sai tên field là nguyên nhân phổ biến nhất khiến token "đúng" mà form vẫn báo lỗi.
Workflow GitHub Actions
Khóa API nằm trong secrets.CAPTCHAAI_API_KEY của repo — không bao giờ xuất hiện trong log build, kể cả khi test fail.
name: E2E Tests with CAPTCHA
on:
push:
branches: [main, staging]
pull_request:
branches: [main]
jobs:
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Chrome
uses: browser-actions/setup-chrome@v1
with:
chrome-version: stable
- name: Install ChromeDriver
uses: nanasess/setup-chromedriver@v2
- name: Install dependencies
run: |
pip install selenium requests pytest pytest-html
- name: Run E2E tests
env:
CAPTCHAAI_API_KEY: ${{ secrets.CAPTCHAAI_API_KEY }}
run: |
pytest tests/e2e/ -v --html=report.html --self-contained-html
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-report
path: report.html
Cấu hình GitLab CI
Job dùng image Selenium standalone Chrome làm service riêng thay vì cài Chrome trực tiếp lên runner — ổn định hơn trên shared runner của GitLab và không phải tự vá driver mỗi lần Chrome cập nhật.
e2e_tests:
stage: test
image: python:3.11
services:
- selenium/standalone-chrome:latest
variables:
SELENIUM_REMOTE_URL: "http://selenium__standalone-chrome:4444/wd/hub"
script:
- pip install selenium requests pytest
- pytest tests/e2e/ -v
artifacts:
when: always
reports:
junit: report.xml
Pipeline Jenkins
credentials('captchaai-api-key') lấy key từ Jenkins Credentials Store thay vì hardcode trong Jenkinsfile — đúng cách quản lý bí mật mà style guide bảo mật nào cũng yêu cầu.
pipeline {
agent any
environment {
CAPTCHAAI_API_KEY = credentials('captchaai-api-key')
}
stages {
stage('Setup') {
steps {
sh 'pip install selenium requests pytest'
}
}
stage('E2E Tests') {
steps {
sh 'pytest tests/e2e/ -v --junitxml=results.xml'
}
}
}
post {
always {
junit 'results.xml'
}
}
}
Quản lý chi phí giải CAPTCHA trong CI
Chỉ giải khi thực sự cần
import os
def should_run_captcha_tests():
"""Skip CAPTCHA tests in certain environments."""
if os.environ.get("SKIP_CAPTCHA_TESTS"):
return False
if not os.environ.get("CAPTCHAAI_API_KEY"):
return False
return True
# In test
import pytest
@pytest.mark.skipif(
not should_run_captcha_tests(),
reason="CAPTCHA tests disabled or API key not set"
)
class TestWithCaptcha:
def test_login(self, browser, captcha_solver):
pass
Bỏ test có CAPTCHA khỏi mọi PR build, chỉ chạy khi merge vào main hoặc theo lịch chạy đêm — cách rẻ nhất để không đốt số dư CaptchaAI cho code chưa chắc được merge.
Kiểm tra số dư trước khi chạy test suite
@pytest.fixture(scope="session", autouse=True)
def check_captcha_balance(captcha_solver):
import requests
resp = requests.get(
f"{captcha_solver.BASE}/res.php",
params={"key": captcha_solver.api_key, "action": "getbalance"},
)
balance = float(resp.text)
if balance < 0.50:
pytest.skip(f"CaptchaAI balance too low: ${balance:.2f}")
Ngưỡng $0,50 trong ví dụ trên chỉ là mốc an toàn tối thiểu — đặt ngưỡng thực tế theo số CAPTCHA mà bộ test của bạn giải mỗi lần chạy, để tránh suite fail giữa chừng vì hết số dư.
CaptchaAI tính phí theo thread chạy đồng thời, không theo số lần giải — đội QA chạy pytest-xdist với 5 worker song song vẫn vừa gói BASIC ($15/tháng, 5 thread), số lần giải không giới hạn. Nhiều job đồng thời hơn số thread sẽ phải xếp hàng chờ (không lỗi, chỉ chậm hơn); lúc đó cân nhắc STANDARD ($30/tháng, 15 thread) — mô hình này dễ dự đoán cho đội QA outsourcing ở TP.HCM, Hà Nội chạy CI cho nhiều client cùng lúc.
Khắc phục sự cố
| Vấn đề | Nguyên nhân | Cách xử lý |
|---|---|---|
CAPTCHAAI_API_KEY not set |
Bí mật chưa được cấu hình | Thêm khóa vào CI secret |
| Chrome gặp sự cố trong CI | Thiếu cờ --no-sandbox |
Thêm đủ các cờ Chrome headless |
| Test đạt cục bộ, thất bại trong CI | Phiên bản trình duyệt khác nhau | Ghim phiên bản Chrome trong CI |
| CAPTCHA hết thời gian chờ | Mạng CI chậm hoặc runner ở xa | Tăng tham số timeout |
ERROR_WRONG_GOOGLEKEY hoặc ERROR_WRONG_USER_KEY |
Sai sitekey hoặc API key | Kiểm tra lại sitekey lấy từ trang, xác nhận CI secret đúng key |
| Test rất tốn kém | Quá nhiều lần giải CAPTCHA mỗi lần chạy | Dùng SKIP_CAPTCHA_TESTS cho PR build |
Câu hỏi thường gặp
Chạy CAPTCHA test trên mọi PR có đáng không?
Không. Chạy test có CAPTCHA khi merge vào main hoặc theo lịch đêm là đủ, dùng cờ SKIP_CAPTCHA_TESTS để bỏ qua ở PR build — vừa tiết kiệm số dư, vừa giữ PR pipeline nhanh.
CAPTCHA timeout trong CI thường do đâu?
Thường do runner CI ở xa hoặc mạng chậm hơn máy dev, khiến timeout 120 giây mặc định không đủ. Tăng timeout trong solve() trước; nếu vẫn timeout thường xuyên, kiểm tra xem mạng ra ngoài của runner có bị giới hạn tốc độ không.
Nightly build có cần retry khi CaptchaAI báo lỗi tạm thời không?
Có, nên bọc solve() bằng retry ngắn (2–3 lần, giãn cách vài giây) cho lỗi mạng thoáng qua — khác với CAPCHA_NOT_READY, vốn đã được polling xử lý sẵn trong helper. Đừng retry vô hạn; sau vài lần vẫn lỗi thì để test fail và báo cho đội.
pytest-xdist chạy song song có làm tăng chi phí CaptchaAI không?
Không, ngoài gói bạn đang trả. CaptchaAI tính theo thread chạy đồng thời chứ không theo số lần giải, nên chạy song song chỉ cần đủ thread trong plan — xem phần quản lý chi phí ở trên.
Self-hosted runner có cần cấu hình gì khác khi giải CAPTCHA không?
Không cần đổi code — chỉ cần runner có Chrome, ChromeDriver tương thích, và IP outbound không bị firewall hay proxy công ty chặn khi gọi ocr.captchaai.com.
Hướng dẫn liên quan
Đưa bước giải CAPTCHA vào CI của bạn ngay hôm nay — bắt đầu với CaptchaAI.