Как решать капчу в pytest, чтобы прогон тестов не зависал

pytest captcha - How to Solve CAPTCHAs in pytest Without Hanging the Run

Чтобы решать капчу в pytest, создайте один клиент CapSkip на сессию в conftest.py и решайте внутри каждого теста, потому что токен работает один раз. Пропускайте тесты с капчей, если CapSkip не запущен. Затем задайте SDK собственные таймауты. В Windows pytest-timeout может использовать только метод thread, и когда он срабатывает, то завершает весь прогон. Python SDK не задаёт HTTP-таймаут, поэтому молчащий CapSkip подвесит тест без всякой ошибки. CapSkip работает на вашем собственном ПК с Windows, так что у набора тестов, который решает капчу сотни раз за ночь, нет платы за каждое решение. Эта настройка pytest для капчи охватывает каждую из этих ловушек, а также параллельные запуски с xdist и асинхронные тесты.

Что понадобится

  • CapSkip, запущенный на ПК с Windows, с включённым решателем reCAPTCHA. Пошаговое руководство по настройке проведёт вас через первый запуск.
  • Python 3.10 или новее. Он нужен и пакету capskip 1.3.0, и pytest 9. Всё ниже запускалось на Windows 11 с Python 3.14.0, pytest 9.1.1, pytest-timeout 2.4.0, pytest-xdist 3.8.0, pytest-asyncio 1.4.0 и requests 2.34.2. Время отказа в подключении на 3.13.7 получилось таким же.
  • Окно PowerShell. Команды написаны в синтаксисе PowerShell.
  • Тестовая копия вашего сайта (staging), которой владеете вы, с чекбоксом reCAPTCHA v2 в форме входа. Пример выполняет вход через форму в стиле Django, в которой есть CSRF-токен. Поменяйте имена полей под свои.
  • Адрес решателя. В режиме Local CapSkip отвечает на 127.0.0.1 и только для этого устройства. В режиме Server он слушает ваш сетевой адрес или публичный IP, чтобы тесты на другой машине могли обращаться к нему через API. Оба режима задаются в разделе Настройки подключения.

Замеры ниже получены на тестовой имитации CapSkip и тестовой странице входа на том же ПК, с настоящим pytest и настоящим SDK. Поведение pytest и поведение SDK верны и для вашего собственного сайта. Время решения, лимиты потоков, а также правила приёма и отклонения на странице входа относятся только к этой имитации.

Шаг 1: создаём проект

Положите всё в одну папку проекта: conftest.py, tokens.py и файлы тестов. Используйте виртуальное окружение. Каждая команда ниже запускает Python из этого окружения по его пути, так что активировать окружение не нужно. Запускайте pytest как python -m pytest. В такой форме текущая папка добавляется в путь импорта. Если вы перенесёте conftest.py в папку tests, оставьте tokens.py в папке проекта. Тогда pytest.exe падает с ModuleNotFoundError: No module named ‘tokens’, а python -m pytest работает.

# Windows PowerShell, in your project folder
python -m venv .venv
.venv\Scripts\python -m pip install pytest "capskip==1.3.0" requests

Шаг 2: один клиент CapSkip на весь запуск

Сохраните это как conftest.py. Он читает настройки CapSkip из окружения, потому что SDK сам их не читает, а значения по умолчанию позволяют запустить его без настройки. CAPSKIP_API_KEY нужен, только если вы включили проверку API-ключа в CapSkip. Фикстура создаёт один клиент на всю сессию. Она же выясняет, запущен ли CapSkip.

# conftest.py (next to your tests): pip install pytest capskip requests
import os
import socket

import pytest
from capskip import CapSkip

# The SDK reads no environment variables, so the fixture reads them.
HOST = os.environ.get("CAPSKIP_HOST", "127.0.0.1")
PORT = int(os.environ.get("CAPSKIP_PORT", "8080"))
API_KEY = os.environ.get("CAPSKIP_API_KEY", "capskip")


@pytest.fixture(scope="session")
def capskip_client():
    # CapSkip has no ping endpoint, so try a plain TCP connect. Use the IP,
    # not "localhost": a refused connect takes 2 s, or 4 s for localhost.
    try:
        socket.create_connection((HOST, PORT), timeout=3).close()
    except OSError as exc:
        pytest.skip(f"CapSkip is not running on {HOST}:{PORT}: {exc}")
    # One client for the whole run. A solve gives up after 90 s of polling.
    return CapSkip(apiKey=API_KEY, host=HOST, port=PORT, recaptchaTimeout=90)

У CapSkip нет ping-эндпоинта, поэтому обычное TCP-подключение выясняет, слушает ли что-нибудь порт, и не отправляет задание на решение. Если ничего не слушает, фикстура пропускает тесты, а не падает. При закрытом CapSkip отказ в подключении к 127.0.0.1 занял в Windows 2.05 секунды, а к имени localhost около 4.1 секунды. Пропуск кэшируется, поэтому вся сессия платит эту цену один раз: четыре теста с капчей в двух файлах заняли в сумме 2.05 секунды.

Используйте IP. У имени localhost есть и вторая цена, даже когда CapSkip запущен. Если CapSkip слушает только IPv4, два теста входа заняли 10.3 секунды с localhost и 3.75 секунды с 127.0.0.1. Это примерно 2 секунды один раз на проверку подключения и ещё от 1 до 2.5 секунды на каждое решение.

Шаг 3: вспомогательная функция, которая решает капчу и повторяет попытку один раз

Сохраните это как tokens.py. Некоторые решения не удаются, а новая попытка может сработать. SDK выбрасывает ApiException с сообщением ERROR_CAPTCHA_UNSOLVABLE, если вы не задали в CapSkip текст Error Replacement. В этом случае сообщение содержит этот текст, и проверка ниже никогда не совпадёт. Вспомогательная функция повторяет только этот сбой. Отправка, которую CapSkip отклоняет (например, с ERROR_GOOGLEKEY или ERROR_PAGEURL), вызывает исключение сразу. Sitekey, который отклоняет Google, тоже выглядит как UNSOLVABLE, поэтому его пробуют дважды, прежде чем завершить ошибкой. О значении ошибки ERROR_CAPTCHA_UNSOLVABLE рассказано на отдельной странице.

# tokens.py: the helper your tests (and your app) call
from capskip import ApiException


def fresh_token(solver, sitekey, url, attempts=2):
    """Solve a reCAPTCHA v2 and return the token string."""
    for attempt in range(attempts):
        try:
            return solver.recaptcha(sitekey=sitekey, url=url)["code"]
        except ApiException as exc:
            # UNSOLVABLE is how CapSkip reports a solve that failed this
            # time. A refused submit (ERROR_GOOGLEKEY, ERROR_PAGEURL) will
            # fail again, so it raises at once.
            last = attempt == attempts - 1
            if last or "ERROR_CAPTCHA_UNSOLVABLE" not in str(exc):
                raise

Шаг 4: фикстура для каждого теста и маркер

Добавьте следующий блок в конец conftest.py. Это фабрика, а не токен. Каждый её вызов запускает новое решение, и каждый тест просит своё собственное, потому что токен reCAPTCHA принимается один раз. Python допускает импорты в середине файла, поэтому строка импорта может остаться там, где её ставит блок.

from tokens import fresh_token


@pytest.fixture
def solve_recaptcha(capskip_client):
    # A factory, not a token: every call is a new solve, because a
    # reCAPTCHA token is accepted once. Function scope, so each test
    # asks for its own.
    def solve(sitekey, url):
        return fresh_token(capskip_client, sitekey, url)

    return solve

Затем добавьте в conftest.py хук маркера ниже. Он регистрирует маркер captcha, чтобы можно было запускать быстрые тесты без CapSkip, а живые тесты с ним. При включённой опции strict markers (её включает шаг 6) любой незарегистрированный маркер вызывает ошибку. Регистрация captcha сохраняет работу настоящего маркера и превращает опечатку в ошибку.

def pytest_configure(config):
    # Tell pytest the marker exists, so --strict-markers accepts it and a
    # misspelling such as "captcah" is an error instead of a silent mark.
    config.addinivalue_line(
        "markers", "captcha: needs a running CapSkip and solves a real CAPTCHA"
    )

Шаг 5: пишем тесты входа

Сохраните следующие два блока как один файл, test_login.py. Первый блок ваш, а не часть CapSkip. Он открывает страницу входа, читает CSRF-токен и sitekey и отправляет форму. requests не отправляет Referer при отправке формы. Строгий сайт вроде Django по HTTPS отклоняет отправку без него, и имитация сделала то же самое, ответив 403. Поэтому вспомогательная функция задаёт его сама. Подстройте два шаблона и имена полей под свою разметку, а адрес почты и пароль тестового аккаунта подставьте в post_form.

# test_login.py: set LOGIN_URL to your own login page
import os
import re

import pytest
import requests

LOGIN_URL = os.environ.get("LOGIN_URL", "https://example.com/login")


def open_form(session):
    html = session.get(LOGIN_URL, timeout=15).text
    csrf = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', html)
    sitekey = re.search(r'data-sitekey="([^"]+)"', html)
    return csrf.group(1), sitekey.group(1)


def post_form(session, csrf, token, password="secret"):
    # requests sends no Referer, and a Django site refuses an HTTPS post
    # that has none, so set it by hand.
    return session.post(LOGIN_URL, timeout=15, headers={"Referer": LOGIN_URL}, data={
        "csrfmiddlewaretoken": csrf,
        "email": "[email protected]",
        "password": password,
        "g-recaptcha-response": token,
    })

Тесты решают капчу в последнюю очередь и отправляют форму сразу, потому что токен истекает примерно через две минуты. Эту сторону вопроса руководство по сроку действия токена reCAPTCHA разбирает подробно. Замените Welcome, 403 и текст ошибки на то, что показывает ваш собственный сайт. Фиксированное имя пользователя конфликтует уже при втором ночном запуске, поэтому, если ваша форма создаёт аккаунты, каждый раз используйте новое.

@pytest.mark.captcha
def test_login(solve_recaptcha):
    session = requests.Session()
    csrf, sitekey = open_form(session)
    token = solve_recaptcha(sitekey, LOGIN_URL)  # solve last, post at once
    resp = post_form(session, csrf, token)
    assert resp.status_code == 200 and "Welcome" in resp.text


@pytest.mark.captcha
def test_wrong_password_is_refused(solve_recaptcha):
    session = requests.Session()
    csrf, sitekey = open_form(session)
    token = solve_recaptcha(sitekey, LOGIN_URL)  # a new solve, not test 1's
    resp = post_form(session, csrf, token, password="wrong")
    assert resp.status_code == 403
    assert "Invalid email or password" in resp.text

Шаг 6: сначала быстрые тесты, затем живые

Первая строка направляет тесты на вашу страницу входа. Вторая превращает опечатку в маркере в ошибку. Она задаёт переменную окружения, которую pytest читает для дополнительных опций, и действует до конца этой сессии PowerShell. Третья строка запускает всё, кроме тестов с капчей. Четвёртая запускает только живые. Ключ для пропусков выводит причину, по которой тест был пропущен, а ключ durations показывает самые медленные тесты.

# Windows PowerShell: your login page, then make a misspelled marker an error
$env:LOGIN_URL = "https://staging.example.com/login"
$env:PYTEST_ADDOPTS = "--strict-markers"
.venv\Scripts\python -m pytest -m "not captcha"      # fast suite, no CapSkip
.venv\Scripts\python -m pytest -m captcha -rs -v --durations=5   # live tests

Пока что быстрый запуск не находит, что запускать, и pytest завершается с кодом 5. Тесты появятся, когда вы сохраните test_unit.py из раздела про модульные тесты ниже. Тогда решатель ему не нужен: его четыре теста прошли за 0.02 секунды при закрытом CapSkip. Живой запуск закончился пропуском со строкой вроде этой, которая указывает на тест, а не на conftest.py:

  • SKIPPED [1] test_login.py:29: CapSkip is not running on 127.0.0.1:8080: [WinError 10061] No connection could be made because the target machine actively refused it

Набор тестов, в котором всё пропущено, завершается с кодом 0, а в pytest 9.1.1 нет ключа, который превращал бы пропуск в сбой. Если вашей сборке обязательно нужен CapSkip, запишите файл junit с помощью опции junitxml и завершайте сборку ошибкой, когда число пропущенных тестов в нём больше нуля. Если в тесте написать captcah вместо captcha при включённой строгой опции, сбор тестов остановится с кодом выхода 2 и сообщением ‘captcah’ not found in `markers` configuration option. Без строгой опции опечатка была бы только предупреждением, и этот тест попал бы в быстрый набор.

Решайте внутри каждого теста

Не решайте капчу один раз в фикстуре, чтобы делить токен между тестами. В тестировании фикстура с областью действия module решила капчу один раз, и два теста отправили один и тот же токен. Первый приняли. Второй упал с сообщением имитации reCAPTCHA failed: token already used. Настоящий сайт формулирует это иначе. Какой тест проиграет, зависит от порядка, поэтому сбой перемещается и выглядит нестабильным. С xdist всё хуже, как показывает раздел про параллельный запуск. Вот эта ошибка целиком, чтобы вы могли её узнать. Удалите файл после того, как увидите падение, иначе он будет заставлять последующие запуски падать или проходить случайным образом.

# test_shared_token.py: the mistake, so you can see it fail
import pytest
import requests

from test_login import LOGIN_URL, open_form, post_form


@pytest.fixture(scope="module")
def shared_token(capskip_client):
    csrf, sitekey = open_form(requests.Session())
    return capskip_client.recaptcha(sitekey=sitekey, url=LOGIN_URL)["code"]


@pytest.mark.captcha
@pytest.mark.parametrize("n", [1, 2])
def test_each_login_reuses_one_token(shared_token, n):
    session = requests.Session()
    csrf, sitekey = open_form(session)
    resp = post_form(session, csrf, shared_token)
    assert resp.status_code == 200, resp.text  # n=2 fails: used already

Одно решение на каждый тест стоит нескольких секунд. Тесту, которому сама капча не нужна, не следует просить эту фикстуру. Логику вокруг неё покрывает заглушка решателя ниже.

Как сделать так, чтобы зависшее решение падало, а не обрывало прогон

Эта часть в Windows отличается. У плагина pytest-timeout два метода. Методу signal нужен SIGALRM, которого в Windows нет, поэтому там он недоступен. Попытка его запросить закончилась INTERNALERROR и AttributeError: module ‘signal’ has no attribute ‘SIGALRM’. Метод thread используется по умолчанию, и он единственный, который работает. Вот что произошло, когда тест завис, при лимите в 10 секунд и четырёх тестах в прогоне:

  • Он вывел стек главного потока (около 100 строк) и завершил процесс с кодом выхода 1 на 12.5 секунде.
  • У зависшего теста не появилось строки PASSED или FAILED.
  • Сводка не была выведена, и файл junit не был записан.
  • Два теста после него так и не запустились.

С xdist ущерб меньше. Погибает только воркер, который выполнял этот тест. Тест помечается как FAILED с сообщением worker gw1 crashed while running, запускается новый воркер, и прогон завершается сводкой и кодом выхода 1.

Поэтому таймаут thread служит крайней мерой, а не таймаутом теста. Нужно, чтобы первым сдался SDK, с обычным исключением: тогда тест упадёт, а прогон продолжится. Python SDK 1.3.0 это затрудняет. Он вызывает requests без таймаута, поэтому CapSkip, который принял вызов и никогда не отвечает, подвешивает его. Без каких-либо изменений молчащая отправка всё ещё была заблокирована, когда мы остановили её на 45-й секунде. Его recaptchaTimeout проверяется только между опросами, а зависший опрос считается признаком того, что ответ ещё не готов.

Решение: заменить две функции запросов, которые использует SDK, функциями с таймаутом. Добавьте следующий блок в conftest.py. Он вмешивается во внутренности SDK, поэтому зафиксируйте capskip на версии 1.3.0. Строка с hasattr превращает патч в пустую операцию, если более поздняя версия перестанет использовать requests. Патч не охватывает загрузку изображения с капчей, заданного как URL, потому что SDK делает это собственным вызовом.

import functools
import types

import capskip.api
import requests

# capskip 1.3.0 sets no HTTP timeout, so a CapSkip that accepts a call
# and never answers would hang the test. Give every SDK call 5 s to
# connect and 30 s to answer. This swaps a name inside the SDK, so pin
# capskip==1.3.0 and do nothing if the SDK stops using requests.
if hasattr(capskip.api, "requests"):
    capskip.api.requests = types.SimpleNamespace(
        RequestException=requests.RequestException,
        post=functools.partial(requests.post, timeout=(5, 30)),
        get=functools.partial(requests.get, timeout=(5, 30)),
    )

С патчем CapSkip, который так и не ответил на отправку, вызвал NetworkException с сообщением Read timed out. (read timeout=30) на 30.0 секунде. Тест pytest упал на 30.8 секунде, а прогон остался целым. С молчащим опросом иначе. SDK принимает его за признак того, что ответ ещё не готов, и recaptchaTimeout по умолчанию в 300 секунд закончился TimeoutException на 333.0 секунде. Поэтому шаг 2 передаёт recaptchaTimeout=90. При значении 90 тест упал на 92.6 секунде.

Срок можно превысить примерно на 35 секунд. Он проверяется между опросами, один опрос может занять все свои 30 секунд, а перед проверкой SDK ждёт до 5 секунд. При лимите 45 сбой наступил на 60.8 секунде, а при лимите 20 на 30.3 секунде. Значит, худший случай для одной попытки при таких числах составляет около 125 секунд: 90 на опрос плюс один зависший опрос. Вспомогательная функция из шага 3 делает до двух попыток, поэтому худший случай составляет около 250 секунд. Задайте лимит pytest-timeout выше этого значения. Следующий блок устанавливает плагин и задаёт 300 секунд. Переменная окружения заменяет ту, что из шага 6, поэтому в ней повторена строгая опция. Те же две опции можно вместо этого записать в ini-файл pytest как timeout и timeout_method. Переменную просто проще вставить.

# Windows PowerShell: pytest-timeout has only the thread method here.
# 300 s is above two attempts at the slowest SDK failure (about 125 s each),
# so the SDK fails first.
.venv\Scripts\python -m pip install pytest-timeout
$env:PYTEST_ADDOPTS = "--strict-markers --timeout=300 --timeout-method=thread"

Запуск тестов параллельно с xdist

Решение капчи медленное и состоит в основном из ожидания, поэтому xdist помогает. Каждый воркер xdist является отдельным процессом. Поэтому фикстура с областью действия session выполняется один раз на воркер, и каждый воркер создаёт собственный клиент. С четырьмя воркерами и восемью тестами в прогоне оказалось четыре идентификатора процесса и четыре объекта клиента. Без xdist каждого по одному. Тест записывает файлы с workers-0.log по workers-7.log, и Get-Content workers-*.log показывает их. Один клиент на воркер подходит, потому что клиент не хранит ничего, что воркерам нужно делить между собой.

# test_workers.py: shows one client per worker process
import os
from pathlib import Path

import pytest


@pytest.mark.captcha
@pytest.mark.parametrize("n", range(8))
def test_which_client(capskip_client, worker_id, n):
    line = f"{worker_id} pid={os.getpid()} client={id(capskip_client)}"
    Path(f"workers-{n}.log").write_text(line)

Число воркеров выберите сами. Значение auto означает число ядер процессора, а не то, сколько CapSkip может решать одновременно. На тестовом ПК оно дало 24 воркера. Четыре модульных теста заняли с ним около 4 секунд, а с четырьмя воркерами около 1.1 секунды из-за времени запуска воркеров. Собственный лимит CapSkip задаёт настройка reCAPTCHA Max. Threads, по умолчанию равная 10. Эта таблица получена на 24 входах против имитации с таким лимитом и решением в 5 секунд.

ВоркерыВремя прогонаРешается одновременно
1189.6 s1
449.1 s4
1026.1 s10
1224.1 s10
2423.1 s10

Больше 10 воркеров дают небольшой выигрыш: 26.1 секунды с 10 воркерами и 23.1 с 24. Лишние задания ждут свободного потока внутри CapSkip, а задание, которое ждёт дольше, чем Wait Timeout, завершается ошибкой. В уменьшенном тесте восемь тестов шли одновременно при лимите 2, лимите ожидания 6 секунд и решении в 4 секунды. Четыре задания упали с ERROR_CAPTCHA_UNSOLVABLE. Повтор в tokens.py поставил их в очередь снова, и все восемь тестов прошли за 22.3 секунды. Это вторая причина для повтора.

Каждый тест занял около 7.9 секунды при решении в 5 секунд. SDK опрашивает на 0, 0.25, 0.75, 1.75, 3.75 и 7.75 секундах, поэтому замечает завершённое решение только при следующем опросе. Установка xdist и команда приведены ниже.

# Each xdist worker is its own process, so each builds its own client.
# Pick the number yourself: -n auto means CPU count, not solver capacity.
.venv\Scripts\python -m pip install pytest-xdist
.venv\Scripts\python -m pytest -m captcha -n 4 -v

Ещё одна ловушка. Под xdist описанная выше ошибка с общим токеном может не проявиться. Токен с областью действия module решается один раз на воркер, поэтому два теста сталкиваются, только если попадают на одного и того же воркера. Тест, который в одних запусках проходит, а в других падает, подсказывает искать общий токен.

Асинхронные тесты с AsyncCapSkip

В Python SDK есть настоящий асинхронный клиент, AsyncCapSkip. Он построен на httpx. Добавьте эту фикстуру в conftest.py. Она запрашивает capskip_client, поэтому проверка пропуска по-прежнему действует. Асинхронный клиент не хранит цикл событий, поэтому достаточно обычной фикстуры, и тесты выполняются в циклах по умолчанию с областью действия function.

from capskip import AsyncCapSkip


@pytest.fixture(scope="session")
def async_capskip_client(capskip_client):
    # Asking for capskip_client keeps the skip-when-closed check. The
    # async client holds no event loop, so a plain fixture is enough.
    return AsyncCapSkip(apiKey=API_KEY, host=HOST, port=PORT, recaptchaTimeout=90)

pytest-asyncio 1.4.0 работает в строгом режиме, и pytest 9 проваливает асинхронный тест без маркера с сообщением Failed: async def functions are not natively supported. Сохраните следующий блок как test_async.py. Замените YOUR_SITEKEY на sitekey вашей страницы.

# test_async.py: .venv\Scripts\python -m pip install pytest-asyncio
import asyncio

import pytest

from test_login import LOGIN_URL


@pytest.mark.captcha
@pytest.mark.asyncio  # needed: pytest-asyncio runs in strict mode
async def test_five_solves_at_once(async_capskip_client):
    solves = [
        async_capskip_client.recaptcha(sitekey="YOUR_SITEKEY", url=LOGIN_URL)
        for _ in range(5)
    ]
    results = await asyncio.gather(*solves)
    assert len({r["code"] for r in results}) == 5  # five different tokens

Не ждите линейного масштабирования. На имитации, которая решала за 1.5 секунды, запуск 1, 5, 10 и 25 решений через gather занял 2.6, 2.5, 4.6 и 9.8 секунды. Клиент открывает новое соединение httpx на каждый вызов, и из-за этого цикл событий каждый раз ненадолго занят. Патч из раздела про таймауты к этому клиенту не применяется. Его лимит составляют стандартные для httpx 5 секунд на запрос, а не на решение, поэтому решение в 12 секунд всё равно сработало и завершилось на 14.1 секунде. Молчащая отправка вызвала NetworkException с пустым сообщением на 5.2 секунде. Молчащий опрос закончился TimeoutException на 24.7 секунде при лимите 20. Подробнее о параллельной работе в Python читайте в руководстве по параллельному решению капч с asyncio.

Модульные тесты, которым не нужен решатель

Большинству ваших тестов CapSkip не нужен. Тесты вашего собственного кода, который вызывает решатель, могут использовать заглушку. Её результат должен точно совпадать с настоящим. SDK возвращает обычный dict с captchaId и code, поэтому заглушка делает то же самое. Последний тест показывает другой способ: создайте настоящий клиент, который не открывает соединение, пока вы не начнёте решать, и подмените один метод с помощью monkeypatch. Все четыре теста проходят при закрытом CapSkip. Сохраните блок как test_unit.py.

# test_unit.py: runs with CapSkip closed
import pytest
from capskip import ApiException, CapSkip

from tokens import fresh_token

URL = "https://example.com/login"


class FakeSolver:
    """Same call and same result shape as CapSkip.recaptcha()."""

    def __init__(self, *outcomes):
        self.outcomes = list(outcomes)
        self.calls = 0

    def recaptcha(self, sitekey, url, **kwargs):
        self.calls += 1
        outcome = self.outcomes.pop(0)
        if isinstance(outcome, Exception):
            raise outcome
        return {"captchaId": "1001", "code": outcome}


def test_returns_the_code():
    assert fresh_token(FakeSolver("tok-1"), "KEY", URL) == "tok-1"


def test_retries_once_when_unsolvable():
    fake = FakeSolver(ApiException("ERROR_CAPTCHA_UNSOLVABLE"), "tok-2")
    assert fresh_token(fake, "KEY", URL) == "tok-2"
    assert fake.calls == 2


def test_does_not_retry_a_bad_sitekey():
    fake = FakeSolver(ApiException("ERROR_GOOGLEKEY"), "never-used")
    with pytest.raises(ApiException):
        fresh_token(fake, "KEY", URL)
    assert fake.calls == 1


def test_real_client_with_monkeypatch(monkeypatch):
    solver = CapSkip()  # opens no connection until a solve is called
    fake = {"captchaId": "1", "code": "tok-3"}
    monkeypatch.setattr(solver, "recaptcha", lambda **kw: fake)
    assert fresh_token(solver, "KEY", URL) == "tok-3"

Запуск тестов против CapSkip на другой машине

Тестам и решателю не обязательно работать на одном ПК. Переключите CapSkip в режим Server в настройках подключения, и он будет слушать ваш сетевой адрес или публичный IP. Для стабильного удалённого доступа рекомендуется статический публичный IP. Затем задайте три переменные, и тот же набор тестов запустится без изменений. Решателем по-прежнему остаётся ваша собственная машина с Windows, и решения по-прежнему не тарифицируются.

# CapSkip on another Windows machine in Server mode: set these first
$env:CAPSKIP_HOST = "192.168.1.50"
$env:CAPSKIP_PORT = "8080"
$env:CAPSKIP_API_KEY = "your-key"   # only if API key validation is on
.venv\Scripts\python -m pytest -m captcha -rs

Обратите внимание на две вещи. SDK обращается к CapSkip по обычному HTTP, поэтому API-ключ передаётся открытым текстом. Держите это соединение в частной сети или VPN и включите проверку API-ключа. При запуске режима Server CapSkip открывает свой порт для всех адресов. Чтобы ограничить доступ ожидаемыми машинами, добавьте в Windows Firewall правило, блокирующее все остальные. Для этого руководства удалённая настройка не запускалась. Читались только переменные.

Весь conftest.py

Вот conftest.py из шагов 2 и 4 вместе с патчем таймаутов и асинхронной фикстурой в одном файле. Он заменяет блоки, которые вы добавляли по одному, поэтому сначала удалите их.

# conftest.py, complete: pip install pytest capskip==1.3.0 requests
import functools
import os
import socket
import types

import capskip.api
import pytest
import requests
from capskip import AsyncCapSkip, CapSkip

from tokens import fresh_token

# The SDK reads no environment variables, so this file reads them.
HOST = os.environ.get("CAPSKIP_HOST", "127.0.0.1")
PORT = int(os.environ.get("CAPSKIP_PORT", "8080"))
API_KEY = os.environ.get("CAPSKIP_API_KEY", "capskip")

# capskip 1.3.0 sets no HTTP timeout. Give every SDK call 5 s to connect
# and 30 s to answer. Pin capskip==1.3.0: this swaps a name inside it.
if hasattr(capskip.api, "requests"):
    capskip.api.requests = types.SimpleNamespace(
        RequestException=requests.RequestException,
        post=functools.partial(requests.post, timeout=(5, 30)),
        get=functools.partial(requests.get, timeout=(5, 30)),
    )


def pytest_configure(config):
    # Register the marker so --strict-markers accepts it.
    config.addinivalue_line(
        "markers", "captcha: needs a running CapSkip and solves a real CAPTCHA"
    )


@pytest.fixture(scope="session")
def capskip_client():
    # No ping endpoint, so a plain TCP connect. Use the IP, not "localhost".
    try:
        socket.create_connection((HOST, PORT), timeout=3).close()
    except OSError as exc:
        pytest.skip(f"CapSkip is not running on {HOST}:{PORT}: {exc}")
    return CapSkip(apiKey=API_KEY, host=HOST, port=PORT, recaptchaTimeout=90)


@pytest.fixture
def solve_recaptcha(capskip_client):
    # A factory: every call is a new solve, and a token works once.
    def solve(sitekey, url):
        return fresh_token(capskip_client, sitekey, url)

    return solve


@pytest.fixture(scope="session")
def async_capskip_client(capskip_client):
    return AsyncCapSkip(apiKey=API_KEY, host=HOST, port=PORT, recaptchaTimeout=90)

Частые ошибки в тестах pytest с капчей

Что вы видитеПричинаИсправить
SKIPPED: CapSkip is not running on 127.0.0.1:8080Там ничего не слушает, или в CAPSKIP_HOST указано http://Запустите CapSkip, проверьте настройки подключения, укажите хост без http://
INTERNALERROR с no attribute SIGALRMМетод таймаута signal в Windows не существуетИспользуйте метод thread
Дамп стека, нет сводки, код выхода 1Сработал таймаут thread и завершил прогонПусть первым сдаётся SDK: используйте recaptchaTimeout и патч запросов
worker gw1 crashed while runningТаймаут thread убил одного воркера xdistТо же исправление, и найдите зависший тест
‘captcah’ not found in `markers` configuration optionОпечатка в маркере при включённой строгой опцииИсправьте написание
ModuleNotFoundError: No module named ‘tokens’pytest.exe не добавляет папку проекта в путь импортаЗапускайте python -m pytest
Failed: async def functions are not natively supportedАсинхронный тест без маркера asyncioДобавьте маркер
403 при отправке формы входаrequests не отправил Referer, а сайт его проверяетПередайте заголовок Referer
Второй тест падает, первый проходитДва теста отправили один токенРешайте капчу внутри каждого теста
NetworkException: Read timed out (read timeout=30)CapSkip принял отправку и замолчалПроверьте CapSkip и запустите снова
TimeoutException примерно через 333 секундыМолчащий опрос при лимите по умолчанию в 300 секундЗадайте recaptchaTimeout равным 90
Каждое решение медленнее, а первая проверка занимает на 2 секунды большеCAPSKIP_HOST равен localhostИспользуйте 127.0.0.1
AttributeError: ‘NoneType’ object has no attribute ‘group’На странице по адресу LOGIN_URL нет CSRF-токена или sitekey, поэтому LOGIN_URL всё ещё содержит адрес из примераУкажите в LOGIN_URL вашу страницу входа

FAQ

Можно ли запускать эти тесты на хостинговом раннере CI?

Хостинговый раннер работает на чужой машине, поэтому не может обратиться к 127.0.0.1 на вашем ПК. Ему потребовался бы публичный адрес CapSkip в режиме Server. Тогда ключ передаётся открытым текстом по обычному HTTP, а блокирующее правило не может перечислить адреса раннеров, которые постоянно меняются. Лучше использовать собственный раннер в вашей сети или VPN, а API-ключ хранить в хранилище секретов системы CI. Ни один хостинговый раннер для этого руководства не тестировался. Читались только переменные.

Нужно ли запускать тесты с капчей при каждой сборке?

Нет. Каждый такой тест занимает секунды, требует запущенного CapSkip и обращается к реальному тестовому сайту (staging). Маркер позволяет обычной сборке запускать быстрый набор, а заданию по расписанию живой. Запускайте живой набор перед релизом или каждую ночь.

Работает ли это со стандартным модулем unittest?

Вызовы CapSkip одинаковы в любом фреймворке. Клиент на сессию, пропуск, маркер и параллельные настройки выше относятся к возможностям pytest, а unittest для этого руководства не запускался. Загляните в документацию вашего инструмента запуска тестов, где описаны его собственные фикстуры, пропуски и таймауты.

Нужны ли мне Selenium или Playwright?

Для этой схемы не нужны. Она отправляет форму через requests, поэтому проверяет сценарий входа, который проверяет сервер. Если капча работает только внутри настоящей сессии браузера, управляйте браузером из теста и передавайте токен странице. Вызовы решателя остаются теми же.

Коротко

Набору pytest-тестов с капчей нужен один клиент CapSkip на сессию и новое решение внутри каждого теста. Пропускайте тесты, когда CapSkip закрыт, помечайте живые тесты маркером и задавайте число воркеров явно, в пределах 10 потоков CapSkip. В Windows задавайте собственные лимиты SDK заметно ниже лимита pytest-timeout, чтобы зависшее решение проваливало один тест, а не завершало весь прогон.

Ночной набор тестов, который несколько сотен раз решает капчу, быстро накапливает много решений. Когда локальное распознавание капчи выполняется на вашей собственной машине с Windows, эти решения никогда не превращаются в счёт.