Регрессия на форме регистрации у многих QA-команд стабильно падает не из-за багов в самой форме, а из-за CAPTCHA: Selenium не может пройти reCAPTCHA или Turnstile так, как это делает живой пользователь. Отключать проверку в staging — плохая идея: тогда тесты проверяют не тот код, который уедет в прод. Рабочий подход — встроить решение CAPTCHA в сам тестовый сценарий как обычный шаг: получить токен через API CaptchaAI и подставить его в форму до сабмита. Ниже — готовая архитектура на Python, Selenium и pytest, которая уже отрабатывает валидную регистрацию, дубли email и слабые пароли.
Зачем автотестам регистрации нужен решатель CAPTCHA
Форма регистрации почти всегда закрыта CAPTCHA — это первая линия защиты от мусорных аккаунтов, и именно она чаще всего ломает автоматизацию QA. Задачи, которые нужно закрыть тестами, стандартны для любого продукта с онбордингом:
- проверить, что регистрация проходит одинаково в staging и в проде;
- прогнать граничные случаи: дублирующийся email, слабый пароль, пустые обязательные поля;
- гонять регрессию на каждый деплой, а не только вручную перед релизом;
- убедиться, что виджет CAPTCHA не ломает вёрстку формы и не блокирует сабмит на мобильных экранах.
Если тесты просто пропускают шаг с CAPTCHA (мок, отключение проверки в staging), защита от ботов и реальный прод-код начинают расходиться: баг в интеграции CAPTCHA всплывёт только после релиза, когда его увидят живые пользователи.
Какие CAPTCHA чаще всего стоят на форме регистрации
На практике регистрационные формы чаще всего защищают reCAPTCHA v2 (чекбокс или невидимый режим) и Cloudflare Turnstile — оба типа CaptchaAI решает через один и тот же REST API (in.php/res.php), поэтому логику решения можно переиспользовать между проектами. Если на вашей форме стоит Cloudflare Challenge или GeeTest v3, схема ниже расширяется тем же образом — меняется только method в запросе к API, остальной каркас теста остаётся прежним.
Как устроен тестовый сценарий
Сценарий линейный и повторяется в каждом тесте: сгенерировать тестового пользователя, заполнить форму с учётом граничного случая, решить CAPTCHA и проверить результат.
┌──────────┐ ┌───────────────┐ ┌────────────┐ ┌────────────┐
│ Test Data │────▶│ Fill Form + │────▶│ Solve │────▶│ Verify │
│ Generator │ │ Edge Cases │ │ CAPTCHA │ │ Account │
└──────────┘ └───────────────┘ └────────────┘ └────────────┘
Единственный шаг, который отличает такой тест от обычного UI-теста, — блок «Solve CAPTCHA»: он делает реальный запрос к API решения, ждёт токен и подставляет его в скрытое поле формы перед сабмитом.
Как это реализовано
Ниже — минимальный рабочий набор классов: генератор тестовых данных, решатель CAPTCHA и раннер тестов поверх Selenium.
Генератор тестовых пользователей
Класс TestUser собирает уникальные email, username и пароль на основе таймстемпа и случайного суффикса — этого достаточно, чтобы каждый прогон не конфликтовал с предыдущим и не оставлял в базе персональные данные реальных людей: значения полностью синтетические, что снимает лишние вопросы по 152-ФЗ «О персональных данных» при хранении тестовых артефактов.
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,
}
Класс тестирования регистрации
CaptchaSolver — тонкая обёртка над API CaptchaAI: solve_recaptcha() и solve_turnstile() собирают параметры под нужный method и передают их в _solve(), который отправляет задачу в in.php, опрашивает res.php и возвращает токен или бросает исключение.
RegistrationTester оборачивает Selenium: _solve_captcha() определяет, какой виджет отрисован на странице — Turnstile или reCAPTCHA, — получает токен и подставляет его в скрытое поле через execute_script. Три метода, test_valid_registration, test_duplicate_email и test_weak_password, покрывают счастливый путь и два граничных случая; run_all() прогоняет их по очереди и собирает результат в self.results, а report() печатает читаемую сводку.
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()
Запуск тестов
Тестер создаётся один раз с API-ключом и базовым URL стейджинга, после чего run_all() прогоняет весь набор, а report() выводит короткую сводку по каждому тесту.
tester = RegistrationTester("YOUR_API_KEY", "https://staging.example.com")
try:
tester.run_all()
print(tester.report())
finally:
tester.close()
Вывод:
Registration Tests: 3/3 passed
----------------------------------------
[PASS] valid_registration
[PASS] duplicate_email
[PASS] weak_password
Подключение к pytest
Тот же класс RegistrationTester подключается к pytest через fixture с областью видимости module — драйвер Selenium поднимается один раз на файл, а не на каждый тест, что заметно ускоряет прогон в CI.
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"
Пример из практики: распределённая QA-команда
Команда QA, разбросанная между Москвой, Алматы и Минском, обычно гоняет регрессию не локально, а в общем CI-раннере — и здесь важна пропускная способность API, а не только код. Для набора из 20–30 тестов регистрации, запускаемых параллельно на нескольких воркерах CI, плана BASIC ($15/мес, 5 потоков) обычно не хватает: параллельные запросы начинают вставать в очередь, и тесты падают по таймауту, а не по багу в форме. STANDARD ($30/мес, 15 потоков) или ADVANCE ($90/мес, 50 потоков) закрывают параллельный прогон с запасом.
Второй момент — нестабильные мобильные каналы у части инфраструктуры в регионе: если тесты изредка падают по таймауту решения, а не по логике формы, увеличьте initial_wait и число повторов в _solve вместо того, чтобы гоняться за призрачным багом в UI.
Диагностика типичных проблем
Большинство сбоев в таких тестах — не про CAPTCHA, а про синхронизацию UI и таймауты:
| Проблема | Причина | Как исправить |
|---|---|---|
| Регистрация прошла, а тест считает её проваленной | Индикатор успеха не совпадает с реальным текстом на странице | Добавьте текст успеха вашего сайта в _check_success() |
| CAPTCHA не находится на странице | Виджет подгружается с задержкой (ленивая загрузка) | Добавьте time.sleep(2) перед _solve_captcha() |
| Селекторы не находят поля формы | Другая структура HTML на конкретном окружении | Обновите CSS-селекторы под ваш сайт |
| Токен уже истёк к моменту сабмита | CAPTCHA решена слишком рано, форма заполнялась долго | Вызывайте _solve_captcha() непосредственно перед кликом на submit |
| Тест падает по таймауту решения, а не по логике формы | Нестабильный канал или нехватка потоков при параллельном прогоне | Увеличьте initial_wait, поднимите план по потокам (см. пример выше) |
Часто задаваемые вопросы
Нужно ли разрешение владельца сайта, чтобы гонять такие тесты?
Да. Такой сценарий предназначен для тестирования собственных приложений или площадок, на автоматизацию которых у вас есть письменное разрешение. Запускать его на чужой продакшен-форме без согласия — нарушение условий использования сайта.
Сколько потоков CaptchaAI нужно для регрессии из 20+ тестов?
Зависит от параллелизма CI. При последовательном прогоне хватает и BASIC ($15/мес, 5 потоков); если воркеры CI бьют тесты на 10–20 параллельных джобов, берите STANDARD ($30/мес, 15 потоков) или ADVANCE ($90/мес, 50 потоков) — иначе часть запросов будет ждать свободный поток, и тест упадёт по таймауту, а не по багу.
Что делать, если CAPTCHA на форме подгружается не сразу?
Добавьте короткую паузу или явное ожидание появления .cf-turnstile / [data-sitekey] перед вызовом _solve_captcha() — виджет часто дорисовывается скриптом уже после DOMContentLoaded, и попытка решить его слишком рано просто ничего не найдёт на странице.
Можно ли переиспользовать один и тот же тестовый аккаунт в разных прогонах?
Не рекомендуется для позитивного сценария регистрации — параллельные прогоны начнут конфликтовать за один и тот же email. TestUser генерирует уникальные данные на каждый вызов специально для этого; переиспользуйте аккаунт только там, где тесту нужен уже существующий пользователь, например для повторного логина.
Можно ли запускать эти тесты в GitHub Actions или GitLab CI?
Да, без изменений в логике: поднимайте headless Chrome (options.add_argument("--headless")) и передавайте API-ключ CaptchaAI через секреты CI, а не хардкодьте его в репозитории.
Связанные руководства
Уберите CAPTCHA из списка блокеров регрессии на регистрации — подключите CaptchaAI.