Use Cases

Решение CAPTCHA для тестирования конечных точек API в веб-формах

Если бэкенд формы защищён reCAPTCHA или Cloudflare Turnstile, тестировать его через UI — долго и хрупко: Selenium-сценарий ждёт отрисовки виджета, ловит таймауты и падает при любом изменении вёрстки. Быстрее и надёжнее решить CAPTCHA через API CaptchaAI, получить токен и отправить его тем же запросом, что и остальные поля формы, — эндпоинт получает обычный POST и не знает, что за ним не стоит браузер.


Зачем тестировать CAPTCHA-эндпоинты через API, а не через браузер

QA-команде такой подход даёт четыре сценария, которые через UI либо медленные, либо вообще недоступны:

  • Проверка бэкенд-валидации. Токен решён и действителен — тест показывает, действительно ли сервер его проверяет, а не просто доверяет самому факту наличия поля.
  • Нагрузочное тестирование. Десятки и сотни запросов к защищённому эндпоинту без открытия десятков вкладок браузера.
  • Интеграционные проверки в CI/CD. Пайплайн — GitLab CI, GitHub Actions, Jenkins — отправляет запрос к API формы напрямую, без headless-браузера и связанных с ним таймаутов.
  • Проверка обработки ошибок. Как эндпоинт реагирует на просроченный, поддельный или отсутствующий токен — отдельный класс тестов, который UI-автоматизация почти никогда не покрывает.

Показательный случай: команда, тестировавшая форму регистрации на маркетплейсе с Cloudflare Turnstile, после перехода с Selenium-сценария на прямые POST-запросы с токеном от CaptchaAI избавилась от большей части ложных падений в CI — тесты перестали зависеть от того, успел ли виджет отрисоваться в headless-Chrome.


Как это работает: путь запроса от токена до ответа

┌──────────┐     ┌────────────┐     ┌──────────────┐     ┌──────────────┐
│ Solve    │────▶│ Build      │────▶│ POST to      │────▶│ Validate     │
│ CAPTCHA  │     │ Request    │     │ Endpoint     │     │ Response     │
│ (API)    │     │ Payload    │     │              │     │              │
└──────────┘     └────────────┘     └──────────────┘     └──────────────┘

Здесь четыре шага, и ни один не требует браузера: решить CAPTCHA, собрать payload, отправить POST, проверить, что вернул сервер. Для большинства тестов конечных точек этого достаточно — браузер нужен, только если сам эндпоинт ожидает cookie сессии или CSRF-токен, полученный со страницы формы (см. раздел «Типичные проблемы» ниже).


Реализация: провайдер токенов и тестовый раннер

Ниже два класса на Python: TokenProvider решает CAPTCHA через in.php/res.php и возвращает готовый токен, EndpointTester собирает payload, отправляет запрос и проверяет ответ. Разделение оправдано — провайдер токенов переиспользуется в любых тестах, а логика проверки своя для каждого проекта.

Провайдер токенов: reCAPTCHA v2/v3 и Turnstile

Для reCAPTCHA v3 нужен action, совпадающий с тем, что вызывает виджет на реальной странице (чаще всего submit при отправке формы), и более долгое первое ожидание — 15 секунд против 10 у v2 и Turnstile, потому что оценка v3 занимает больше времени на стороне решателя. Опрос res.php идёт с шагом 5 секунд и обрывается после 60 попыток (около 5 минут) — этого хватает с запасом даже при кратковременных просадках очереди.

import time
import requests

class TokenProvider:
    BASE = "https://ocr.captchaai.com"

    def __init__(self, api_key):
        self.api_key = api_key

    def get_recaptcha_token(self, sitekey, pageurl, version="v2"):
        params = {
            "method": "userrecaptcha",
            "googlekey": sitekey,
            "pageurl": pageurl,
        }
        if version == "v3":
            params["version"] = "v3"
            params["action"] = "submit"
        return self._solve(params, initial_wait=15 if version == "v3" else 10)

    def get_turnstile_token(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")

Тестовый раннер: сборка payload и проверка ответа

EndpointTester принимает конфиг с URL, типом CAPTCHA и ожидаемым результатом, подставляет токен в нужное поле (g-recaptcha-response для reCAPTCHA, cf-turnstile-response для Turnstile) и отправляет запрос как форму или как JSON — в зависимости от флага json_body. Три метода теста покрывают три сценария: валидный токен, заведомо неверный токен и полное отсутствие токена — минимальный набор, который обязана пройти любая форма, защищённая CAPTCHA.

import json
import time

class EndpointTester:
    def __init__(self, api_key):
        self.token_provider = TokenProvider(api_key)
        self.session = requests.Session()
        self.results = []

    def test_endpoint(self, config):
        """
        config: {
            "name": "test name",
            "url": "endpoint URL",
            "method": "POST",
            "captcha_type": "recaptcha_v2" | "recaptcha_v3" | "turnstile",
            "sitekey": "...",
            "pageurl": "...",
            "captcha_field": "g-recaptcha-response",
            "payload": { ... form data ... },
            "expected_status": 200,
            "expected_contains": "success",
        }
        """
        start = time.time()
        result = {"name": config["name"], "passed": False}

        try:
            # Get CAPTCHA token
            captcha_type = config.get("captcha_type", "recaptcha_v2")
            if captcha_type == "recaptcha_v2":
                token = self.token_provider.get_recaptcha_token(
                    config["sitekey"], config["pageurl"]
                )
            elif captcha_type == "recaptcha_v3":
                token = self.token_provider.get_recaptcha_token(
                    config["sitekey"], config["pageurl"], version="v3"
                )
            elif captcha_type == "turnstile":
                token = self.token_provider.get_turnstile_token(
                    config["sitekey"], config["pageurl"]
                )
            else:
                raise ValueError(f"Unknown captcha type: {captcha_type}")

            # Build payload
            payload = {**config.get("payload", {})}
            captcha_field = config.get("captcha_field", "g-recaptcha-response")
            payload[captcha_field] = token

            # Submit request
            method = config.get("method", "POST").upper()
            headers = config.get("headers", {})

            if config.get("json_body"):
                resp = self.session.request(
                    method, config["url"], json=payload, headers=headers
                )
            else:
                resp = self.session.request(
                    method, config["url"], data=payload, headers=headers
                )

            # Validate response
            result["status_code"] = resp.status_code
            result["response_length"] = len(resp.text)
            result["elapsed"] = round(time.time() - start, 2)

            # Check expected status
            expected_status = config.get("expected_status", 200)
            if resp.status_code != expected_status:
                result["error"] = f"Expected {expected_status}, got {resp.status_code}"
                self.results.append(result)
                return result

            # Check expected content
            expected = config.get("expected_contains")
            if expected and expected.lower() not in resp.text.lower():
                result["error"] = f"Response missing: '{expected}'"
                self.results.append(result)
                return result

            result["passed"] = True

        except Exception as e:
            result["error"] = str(e)
            result["elapsed"] = round(time.time() - start, 2)

        self.results.append(result)
        return result

    def test_invalid_token(self, config):
        """Test that endpoint rejects invalid CAPTCHA tokens."""
        invalid_config = {**config}
        invalid_config["name"] = f"{config['name']} (invalid token)"

        # Override with fake token
        payload = {**config.get("payload", {})}
        captcha_field = config.get("captcha_field", "g-recaptcha-response")
        payload[captcha_field] = "INVALID_TOKEN_12345"

        start = time.time()
        result = {"name": invalid_config["name"], "passed": False}

        try:
            resp = self.session.post(config["url"], data=payload)
            result["status_code"] = resp.status_code
            result["elapsed"] = round(time.time() - start, 2)

            # Should reject — 4xx or error message
            if resp.status_code >= 400 or "error" in resp.text.lower() or "invalid" in resp.text.lower():
                result["passed"] = True
            else:
                result["error"] = "Endpoint accepted invalid CAPTCHA token"

        except Exception as e:
            result["error"] = str(e)
            result["elapsed"] = round(time.time() - start, 2)

        self.results.append(result)
        return result

    def test_missing_token(self, config):
        """Test that endpoint rejects missing CAPTCHA token."""
        start = time.time()
        result = {"name": f"{config['name']} (missing token)", "passed": False}

        try:
            payload = config.get("payload", {})
            resp = self.session.post(config["url"], data=payload)
            result["status_code"] = resp.status_code
            result["elapsed"] = round(time.time() - start, 2)

            if resp.status_code >= 400 or "captcha" in resp.text.lower():
                result["passed"] = True
            else:
                result["error"] = "Endpoint accepted request without CAPTCHA"

        except Exception as e:
            result["error"] = str(e)
            result["elapsed"] = round(time.time() - start, 2)

        self.results.append(result)
        return result

    def run_suite(self, configs):
        """Run a full test suite against multiple endpoints."""
        for config in configs:
            self.test_endpoint(config)
            self.test_invalid_token(config)
            self.test_missing_token(config)
        return self.report()

    def report(self):
        passed = sum(1 for r in self.results if r["passed"])
        total = len(self.results)
        lines = [f"Endpoint Tests: {passed}/{total} passed", "=" * 50]
        for r in self.results:
            status = "PASS" if r["passed"] else "FAIL"
            elapsed = r.get("elapsed", "?")
            lines.append(f"  [{status}] {r['name']} ({elapsed}s)")
            if r.get("error"):
                lines.append(f"         Error: {r['error']}")
        return "\n".join(lines)

Пример запуска тестового набора

Собираем конфиг для двух форм — контактной с reCAPTCHA v2 и подписки на рассылку с Turnstile — и прогоняем run_suite, которая для каждой формы дополнительно проверяет invalid- и missing-token сценарии:

tester = EndpointTester("YOUR_API_KEY")

configs = [
    {
        "name": "Contact form submission",
        "url": "https://example.com/api/contact",
        "captcha_type": "recaptcha_v2",
        "sitekey": "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
        "pageurl": "https://example.com/contact",
        "captcha_field": "g-recaptcha-response",
        "payload": {
            "name": "Test User",
            "email": "[email protected]",
            "message": "Automated test message",
        },
        "expected_status": 200,
        "expected_contains": "success",
    },
    {
        "name": "Newsletter signup",
        "url": "https://example.com/api/subscribe",
        "captcha_type": "turnstile",
        "sitekey": "0x4AAAA...",
        "pageurl": "https://example.com/newsletter",
        "captcha_field": "cf-turnstile-response",
        "payload": {
            "email": "[email protected]",
        },
        "expected_status": 200,
    },
]

report = tester.run_suite(configs)
print(report)

Результат в консоли:

Endpoint Tests: 5/6 passed
==================================================
  [PASS] Contact form submission (18.5s)
  [PASS] Contact form submission (invalid token) (0.3s)
  [PASS] Contact form submission (missing token) (0.2s)
  [PASS] Newsletter signup (14.2s)
  [FAIL] Newsletter signup (invalid token) (0.3s)
         Error: Endpoint accepted invalid CAPTCHA token
  [PASS] Newsletter signup (missing token) (0.2s)

Пятый тест (Newsletter signup (invalid token)) провален не из-за бага в примере, а потому что тестовый бэкенд из демо принимает произвольную строку в поле cf-turnstile-response. Именно такой отчёт и должен насторожить: если это не тестовый стенд, а прод, эндпоинт вообще не проверяет CAPTCHA на сервере — это репортится как security-баг, а не как «нестабильный тест».


Типичные проблемы и их причины

Проблема Причина Что делать
Валидный токен отклонён Токен «протух» до отправки — между решением и POST прошло слишком много времени Сокращайте задержку между получением токена и отправкой запроса, отправляйте сразу после _solve
Заведомо неверный токен принят Бэкенд не проверяет CAPTCHA на сервере, доверяет самому факту наличия поля Заводите баг — это проблема безопасности, а не хрупкий тест
403 на все запросы Эндпоинту не хватает CSRF-токена или cookie сессии, которые обычно ставит браузер Добавьте cookie сессии или CSRF-заголовок в запрос перед отправкой формы
JSON-эндпоинт отклоняет данные формы Сервер ждёт Content-Type: application/json, а payload ушёл как form-data Установите json_body: True в конфиге теста

Нагрузочное тестирование и лимит потоков

При параллельном прогоне десятков конфигов упираются не в API CaptchaAI, а в тарифный план: каждый решаемый токен занимает один поток, и пока он не вернулся, следующая задача из того же потока ждёт. Для CI, где тесты гоняются пачкой, обычно достаточно плана STANDARD ($30/мес, 15 потоков); агентствам, которые прогоняют regression-suite сразу по нескольким проектам, чаще подходит ADVANCE ($90/мес, 50 потоков) — цена в USD фиксирована и не зависит от того, сколько тестов запущено, что удобно закладывать в бюджет CI отдельной строкой.

Проверку CAPTCHA в модульных тестах имитируйте заглушкой — настоящий токен там не нужен и только замедлит прогон. Реальные токены от CaptchaAI нужны в интеграционных и e2e-тестах, где важно, что происходит на бэкенде после отправки.


Что учитывать с тестовыми данными

Формы, которые вы тестируете через API, часто ждут email, телефон или ФИО — реальных персональных данных в тестовом прогоне быть не должно. Для российских и белорусских проектов это ещё и требование 152-ФЗ «О персональных данных» (для трансграничных команд действует та же логика в духе GDPR): собирайте и отправляйте только те данные, которые вы вправе обрабатывать, и держите тестовые email и телефоны отдельно от реальной клиентской базы. Это вопрос гигиены тестового окружения, а не юридическая консультация — при сомнениях уточняйте у безопасности проекта.


Часто задаваемые вопросы

Нужен ли настоящий токен CaptchaAI для теста невалидного или отсутствующего токена?

Нет. Для этих двух сценариев решение CAPTCHA не требуется вовсе — просто отправьте запрос с заведомо неверной строкой в поле токена или вообще без него. Настоящий токен от CaptchaAI нужен только для проверки успешной отправки.

Сколько потоков нужно, чтобы нагрузочно протестировать 100+ отправок формы в час?

Зависит от времени решения конкретного типа CAPTCHA и от того, сколько запросов идут параллельно, а не последовательно. Для reCAPTCHA v2/Turnstile и десятка параллельных прогонов плана STANDARD (15 потоков) обычно достаточно; для более широкого фронта смотрите в сторону ADVANCE (50 потоков).

Как протестировать конечную точку с ограничением частоты запросов?

Постепенно увеличивайте частоту запросов и добавляйте между ними паузу, фиксируя момент, когда сервер начинает отвечать 429. Это тест лимитера, а не CAPTCHA, но токен всё равно нужен настоящий — иначе запрос отклонится раньше, чем сработает rate limit.

Как хранить sitekey и API-ключ CaptchaAI в CI/CD, не коммитя секреты в репозиторий?

Кладите API_KEY и тестовый sitekey в переменные окружения раннера (secrets в GitLab CI / GitHub Actions), а в конфиге теста читайте их через os.environ, а не хардкодьте рядом с кодом.

Что делать, если задача решения долго висит в статусе CAPCHA_NOT_READY?

Это нормальная часть цикла опроса, а не ошибка — просто дождитесь следующего опроса res.php. Если статус не меняется дольше 2–3 минут при обычной нагрузке, проверьте баланс потоков в панели управления и корректность sitekey/pageurl в запросе.


Смежные темы


Добавьте проверку CAPTCHA-эндпоинтов в тестовый набор — подключите CaptchaAI и получайте токены прямо из CI.

Комментарии для этой статьи отключены.