Captchas in pytest lösen, ohne dass der Testlauf hängen bleibt

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

Um Captchas in pytest zu lösen, bauen Sie in conftest.py einen CapSkip-Client pro Session und lösen innerhalb jedes Tests, denn ein Token funktioniert nur einmal. Überspringen Sie die Captcha-Tests, wenn CapSkip nicht läuft. Geben Sie dem SDK dann eigene Timeouts. Unter Windows kann pytest-timeout nur seine Thread-Methode verwenden, und wenn diese auslöst, beendet sie den gesamten Lauf. Das Python-SDK setzt kein HTTP-Timeout, sodass ein stummes CapSkip einen Test ohne Fehlermeldung blockieren würde. CapSkip läuft auf Ihrem eigenen Windows-PC, daher kostet eine Suite, die jede Nacht hunderte Male löst, nichts pro Lösung. Dieses pytest-Captcha-Setup behandelt jede dieser Fallen, dazu parallele Läufe mit xdist und asynchrone Tests.

Was Sie brauchen

  • CapSkip läuft auf einem Windows-PC, mit eingeschaltetem reCAPTCHA-Löser. Die Einrichtungsanleitung führt durch den ersten Start.
  • Python 3.10 oder neuer. Das Paket capskip 1.3.0 und pytest 9 setzen es beide voraus. Alles unten lief unter Windows 11 mit Python 3.14.0, pytest 9.1.1, pytest-timeout 2.4.0, pytest-xdist 3.8.0, pytest-asyncio 1.4.0 und requests 2.34.2. Die Zeiten bei verweigerter Verbindung fielen unter 3.13.7 genauso aus.
  • Eine PowerShell-Eingabeaufforderung. Die Befehle verwenden PowerShell-Syntax.
  • Eine Staging-Website, die Ihnen gehört, mit einer reCAPTCHA-v2-Checkbox in einem Login-Formular. Das Beispiel meldet sich an einem Formular im Django-Stil an, das einen CSRF-Token trägt. Passen Sie die Feldnamen an Ihre Website an.
  • Eine Adresse für den Solver. Im Local-Modus antwortet CapSkip auf 127.0.0.1 nur für dieses Gerät. Im Server-Modus lauscht es auf Ihrer Netzwerkadresse oder öffentlichen IP, damit Tests auf einem anderen Rechner es über die API aufrufen können. Eingestellt werden beide Modi unter Verbindungseinstellungen.

Die Messungen unten stammen von einem Mock-CapSkip und einer Mock-Login-Seite (nachgebaute Testattrappen) auf demselben PC, verwendet mit dem echten pytest und dem echten SDK. Das Verhalten von pytest und das Verhalten des SDK gelten auch für Ihre eigene Website. Die Lösezeiten, die Thread-Limits und die Regeln der Login-Seite zum Annehmen und Ablehnen sind dagegen die des Mocks.

Schritt 1: Das Projekt anlegen

Legen Sie alles in einen Projektordner: conftest.py, tokens.py und die Testdateien. Verwenden Sie eine virtuelle Umgebung. Jeder Befehl unten ruft das Python der virtuellen Umgebung über dessen Pfad auf, Sie müssen sie also nicht aktivieren. Starten Sie pytest als python -m pytest. Diese Form fügt den aktuellen Ordner zum Importpfad hinzu. Wenn Sie conftest.py in einen Ordner tests verschieben, lassen Sie tokens.py im Projektordner. Dann scheitert pytest.exe mit ModuleNotFoundError: No module named ‘tokens’, und python -m pytest funktioniert.

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

Schritt 2: Ein einziger CapSkip-Client für den gesamten Lauf

Speichern Sie dies als conftest.py. Die Datei liest die CapSkip-Einstellungen aus der Umgebung, weil das SDK sie nicht von selbst liest, und die Standardwerte sorgen dafür, dass sie ohne jede Einrichtung läuft. CAPSKIP_API_KEY spielt nur eine Rolle, wenn Sie in CapSkip die Validierung des API-Schlüssels eingeschaltet haben. Die Fixture baut einen Client für die gesamte Session. Außerdem stellt sie fest, ob CapSkip läuft.

# 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 hat keinen Ping-Endpunkt, daher fragt ein einfacher TCP-Verbindungsaufbau, ob etwas lauscht, und sendet keinen Lösevorgang. Wenn nichts lauscht, überspringt die Fixture, statt zu scheitern. Bei geschlossenem CapSkip dauerte eine verweigerte Verbindung zu 127.0.0.1 unter Windows 2,05 Sekunden und zum Namen localhost etwa 4,1 Sekunden. Das Überspringen wird zwischengespeichert, daher zahlt eine ganze Session diese Kosten nur einmal: Vier Captcha-Tests in zwei Dateien brauchten insgesamt 2,05 Sekunden.

Verwenden Sie die IP. Der Name localhost hat selbst bei laufendem CapSkip noch einen zweiten Preis. Bei einem Listener, der nur IPv4 bedient, brauchten zwei Login-Tests mit localhost 10,3 Sekunden und mit 127.0.0.1 3,75 Sekunden. Das sind etwa 2 Sekunden einmalig für die Verbindungsprüfung und 1 bis 2,5 Sekunden mehr für jeden Lösevorgang.

Schritt 3: Ein Helfer, der einmal löst und einmal wiederholt

Speichern Sie dies als tokens.py. Manche Lösevorgänge scheitern, und ein neuer Versuch kann klappen. Das SDK löst eine ApiException aus, deren Meldung ERROR_CAPTCHA_UNSOLVABLE lautet, es sei denn, Sie haben in CapSkip einen Text unter Error Replacement festgelegt. Dann trägt die Meldung diesen Text, und die Prüfung unten greift nie. Der Helfer wiederholt nur diesen einen Fehlschlag. Eine Übermittlung, die CapSkip ablehnt, etwa mit ERROR_GOOGLEKEY oder ERROR_PAGEURL, löst sofort eine Exception aus. Ein sitekey, den Google ablehnt, wird ebenfalls als UNSOLVABLE gemeldet, wird also zweimal versucht, bevor er scheitert. Siehe ERROR_CAPTCHA_UNSOLVABLE zur Bedeutung des Fehlers.

# 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

Schritt 4: Eine Fixture pro Test und ein Marker

Fügen Sie den nächsten Block am Ende von conftest.py hinzu. Er ist eine Factory, kein Token. Jeder Aufruf löst neu, und jeder Test fordert seinen eigenen an, weil ein reCAPTCHA-Token nur einmal akzeptiert wird. Python erlaubt Importe mitten in einer Datei, daher kann die Importzeile dort bleiben, wo der Block sie hinsetzt.

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

Fügen Sie dann den Marker-Hook unten zu conftest.py hinzu. Er registriert einen Marker namens captcha, damit Sie die schnellen Tests ohne CapSkip und die Live-Tests mit CapSkip ausführen können. Ist die Option für strikte Marker eingeschaltet, was Schritt 6 tut, ist jeder Marker, den Sie nicht registriert haben, ein Fehler. Die Registrierung von captcha hält den echten Marker funktionsfähig und macht aus einem Tippfehler einen Fehler.

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"
    )

Schritt 5: Die Login-Tests schreiben

Speichern Sie die nächsten zwei Blöcke als eine Datei, test_login.py. Dieser erste Block ist Ihr eigener, nicht der von CapSkip. Er öffnet die Login-Seite, liest den CSRF-Token und den sitekey aus und sendet das Formular ab. requests sendet bei einem Formular-POST keinen Referer. Eine strenge Website wie Django über HTTPS lehnt einen POST ohne ihn ab, und der Mock tat dasselbe mit einer 403. Daher setzt der Helfer ihn. Passen Sie die beiden Muster und die Feldnamen an Ihr Markup an, und tragen Sie E-Mail-Adresse und Passwort eines Testkontos in post_form ein.

# 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,
    })

Die Tests lösen als Letztes und senden sofort ab, weil der Token nach etwa zwei Minuten abläuft. Der Leitfaden zum Ablaufen von reCAPTCHA-Tokens erklärt diesen Aspekt genauer. Ändern Sie Welcome, die 403 und den Fehlertext auf das, was Ihre eigene Website anzeigt. Ein fester Benutzername kollidiert beim zweiten nächtlichen Lauf, verwenden Sie also jedes Mal einen neuen, wenn Ihr Formular Konten anlegt.

@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

Schritt 6: Erst die schnellen Tests ausführen, dann die Live-Tests

Die erste Zeile richtet die Tests auf Ihre Login-Seite aus. Die zweite macht einen falsch geschriebenen Marker zum Fehler. Sie setzt eine Umgebungsvariable, die pytest für zusätzliche Optionen liest, und sie gilt für den Rest dieser PowerShell-Sitzung. Die dritte Zeile führt alles außer den Captcha-Tests aus. Die vierte führt nur die Live-Tests aus. Der Schalter für Skips gibt aus, warum ein Test übersprungen wurde, und der Schalter für Laufzeiten listet die langsamsten Tests auf.

# 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

An dieser Stelle findet der schnelle Lauf nichts zum Ausführen, und pytest endet mit Exit-Code 5. Tests hat er, sobald Sie test_unit.py aus dem Abschnitt zu den Unit-Tests weiter unten speichern. Dann braucht er nichts vom Solver: Seine vier Tests liefen bei geschlossenem CapSkip in 0,02 Sekunden durch. Der Live-Lauf wurde mit einer Zeile wie dieser übersprungen, die auf den Test zeigt und nicht auf 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

Eine übersprungene Suite endet mit Exit-Code 0, und pytest 9.1.1 hat keinen Schalter, der einen Skip in einen Fehlschlag verwandelt. Muss Ihr Build CapSkip zwingend haben, schreiben Sie mit der Option junitxml eine junit-Datei und lassen Sie den Build scheitern, wenn die Zahl der übersprungenen Tests über null liegt. Schreiben Sie in einem Test captcah statt captcha, stoppt das bei eingeschalteter strikter Option die Sammlung mit Exit-Code 2 und der Meldung ‘captcah’ not found in `markers` configuration option. Ohne die strikte Option wäre der Tippfehler nur eine Warnung, und dieser Test liefe in der schnellen Suite.

In jedem Test lösen

Lösen Sie nicht einmal in einer Fixture und teilen Sie den Token. Im Test löste eine Fixture mit Modul-Scope einmal, und zwei Tests sendeten denselben Token. Der erste wurde akzeptiert. Der zweite scheiterte mit reCAPTCHA failed: token already used, der Meldung des Mocks. Eine echte Website formuliert das anders. Welcher Test verliert, hängt von der Reihenfolge ab, daher wandert der Fehlschlag umher und wirkt wie ein instabiler Test. Unter xdist ist es schlimmer, wie der Abschnitt zur parallelen Ausführung zeigt. Hier ist der Fehler vollständig, damit Sie ihn erkennen. Löschen Sie die Datei, nachdem Sie ihn scheitern gesehen haben, sonst scheitern oder bestehen spätere Läufe zufällig.

# 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

Ein Lösevorgang pro Test kostet jeweils ein paar Sekunden. Ein Test, der das Captcha selbst nicht braucht, sollte die Fixture nicht anfordern. Der Fake-Solver weiter unten deckt die Logik drumherum ab.

Einen hängenden Lösevorgang scheitern lassen, statt den Lauf abzubrechen

Dieser Teil ist unter Windows anders. Das Plugin pytest-timeout kennt zwei Methoden. Die Methode signal braucht SIGALRM, das es unter Windows nicht gibt, daher ist sie dort nicht verfügbar. Sie anzufordern endete mit INTERNALERROR und AttributeError: module ‘signal’ has no attribute ‘SIGALRM’. Die Methode thread ist der Standard und die einzige, die funktioniert. Das tat sie, als ein Test hing, bei einem Limit von 10 Sekunden und vier Tests im Lauf:

  • Sie gab den Stack des Haupt-Threads aus, etwa 100 Zeilen, und beendete den Prozess nach 12,5 Sekunden mit Exit-Code 1.
  • Der hängende Test bekam keine Zeile PASSED oder FAILED.
  • Es wurde keine Zusammenfassung ausgegeben und keine junit-Datei geschrieben.
  • Die zwei Tests danach liefen nie.

Mit xdist ist der Schaden kleiner. Nur der Worker, der diesen Test ausführt, stirbt. Der Test wird als FAILED gemeldet, mit der Meldung worker gw1 crashed while running, ein Ersatz-Worker startet, und der Lauf endet mit einer Zusammenfassung und Exit-Code 1.

Das Thread-Timeout ist also ein letzter Ausweg und kein Test-Timeout. Sie wollen, dass das SDK zuerst mit einer gewöhnlichen Exception scheitert, damit der Test fehlschlägt und der Lauf weitergeht. Das Python-SDK 1.3.0 macht das schwer. Es ruft requests ohne Timeout auf, daher lässt ein CapSkip, das einen Aufruf annimmt und nie antwortet, es hängen. Ohne jede Änderung war ein stummes Übermitteln noch blockiert, als wir es nach 45 Sekunden gestoppt haben. Das recaptchaTimeout wird nur zwischen zwei Abfragen geprüft, und eine hängende Abfrage gilt als noch nicht bereit.

Die Lösung besteht darin, die beiden Anfragefunktionen, die das SDK verwendet, durch solche mit Timeout zu ersetzen. Fügen Sie den nächsten Block zu conftest.py hinzu. Er greift in das SDK ein, pinnen Sie capskip daher auf Version 1.3.0. Die Zeile mit hasattr sorgt dafür, dass er nichts tut, falls eine spätere Version requests nicht mehr verwendet. Er deckt den Download eines Bild-Captchas, das als URL übergeben wird, nicht ab, weil das SDK dafür einen eigenen Aufruf verwendet.

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)),
    )

Mit dem Patch löste ein CapSkip, das das Übermitteln nie beantwortete, nach 30,0 Sekunden eine NetworkException mit Read timed out. (read timeout=30) aus. Der pytest-Test scheiterte nach 30,8 Sekunden, und der Lauf blieb intakt. Eine stumme Abfrage ist anders. Das SDK schluckt sie als noch nicht bereit, und das Standard-recaptchaTimeout von 300 Sekunden endete nach 333,0 Sekunden mit einer TimeoutException. Deshalb übergibt Schritt 2 recaptchaTimeout=90. Mit 90 scheiterte der Test nach 92,6 Sekunden.

Die Frist kann um bis zu etwa 35 Sekunden überschritten werden. Sie wird zwischen zwei Abfragen geprüft, eine Abfrage kann ihre vollen 30 Sekunden dauern, und das SDK wartet bis zu 5 Sekunden, bevor es prüft. Bei einem Limit von 45 kam der Fehlschlag nach 60,8 Sekunden, bei 20 nach 30,3 Sekunden. Der schlimmste Fall für einen Versuch liegt mit diesen Zahlen also bei etwa 125 Sekunden: 90 für das Polling plus eine hängende Abfrage. Der Helfer aus Schritt 3 macht bis zu zwei Versuche, der schlimmste Fall liegt also bei etwa 250 Sekunden. Setzen Sie das Limit von pytest-timeout darüber. Der nächste Block installiert das Plugin und setzt 300 Sekunden. Die Umgebungsvariable ersetzt die aus Schritt 6, wiederholt also die strikte Option. Dieselben zwei Optionen können Sie stattdessen als timeout und timeout_method in eine pytest-ini-Datei schreiben. Die Variable lässt sich nur leichter einfügen.

# 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"

Die Tests parallel mit xdist ausführen

Lösen ist langsam und besteht meist aus Warten, daher hilft xdist. Jeder xdist-Worker ist ein eigener Prozess. Eine Session-Fixture läuft deshalb einmal pro Worker, und jeder Worker baut seinen eigenen Client. Mit vier Workern und acht Tests zeigte der Lauf vier Prozess-IDs und vier Client-Objekte. Ohne xdist gibt es von beidem je eines. Der Test schreibt workers-0.log bis workers-7.log, und Get-Content workers-*.log zeigt sie an. Ein Client pro Worker ist in Ordnung, weil der Client nichts hält, was Worker teilen müssten.

# 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)

Wählen Sie die Zahl der Worker selbst. Die Einstellung auto bedeutet die CPU-Anzahl, nicht das, was CapSkip gleichzeitig lösen kann. Auf dem Test-PC ergab sie 24 Worker. Vier Unit-Tests brauchten damit etwa 4 Sekunden und mit vier Workern etwa 1,1 Sekunden, wegen des Startaufwands der Worker. Das eigene Limit von CapSkip ist die reCAPTCHA-Einstellung Max. Threads, die standardmäßig 10 beträgt. Diese Tabelle stammt von 24 Logins gegen einen Mock mit diesem Limit und einem Lösevorgang von 5 Sekunden.

WorkerLaufzeitGleichzeitige Lösevorgänge
1189.6 s1
449.1 s4
1026.1 s10
1224.1 s10
2423.1 s10

Über 10 Worker hinaus ist der Gewinn klein: 26,1 Sekunden mit 10 Workern und 23,1 mit 24. Die zusätzlichen Aufgaben warten in CapSkip auf einen freien Thread, und eine Aufgabe, die länger als das Wait Timeout wartet, scheitert. In einem verkleinerten Test liefen acht Tests gleichzeitig gegen ein Limit von 2, ein Warte-Limit von 6 Sekunden und einen Lösevorgang von 4 Sekunden. Vier Aufgaben scheiterten mit ERROR_CAPTCHA_UNSOLVABLE. Die Wiederholung in tokens.py stellte sie erneut in die Warteschlange, und alle acht Tests bestanden in 22,3 Sekunden. Das ist der zweite Grund für die Wiederholung.

Jeder Test dauerte bei einem Lösevorgang von 5 Sekunden etwa 7,9 Sekunden. Das SDK pollt bei 0; 0,25; 0,75; 1,75; 3,75 und 7,75 Sekunden, bemerkt einen fertigen Lösevorgang also erst bei der nächsten Abfrage. Die Installation von xdist und der Befehl stehen unten.

# 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

Noch eine Falle. Unter xdist kann der Fehler mit dem geteilten Token von vorhin unbemerkt durchgehen. Der Token mit Modul-Scope wird einmal pro Worker gelöst, daher kollidieren zwei Tests nur, wenn sie auf demselben Worker landen. Ein Test, der in manchen Läufen besteht und in anderen scheitert, ist ein Hinweis, nach einem geteilten Token zu suchen.

Asynchrone Tests mit AsyncCapSkip

Das Python-SDK hat einen echten asynchronen Client, AsyncCapSkip. Er baut auf httpx auf. Fügen Sie diese Fixture zu conftest.py hinzu. Sie fordert capskip_client an, daher gilt die Skip-Prüfung weiterhin. Der asynchrone Client hält keine Event-Loop, deshalb genügt eine einfache Fixture, und die Tests laufen auf den Standard-Loops mit Function-Scope.

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 läuft im strict-Modus, und pytest 9 lässt einen asynchronen Test ohne den Marker mit Failed: async def functions are not natively supported scheitern. Speichern Sie den nächsten Block als test_async.py. Ändern Sie YOUR_SITEKEY auf den sitekey der Seite.

# 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

Erwarten Sie keine lineare Skalierung. Gegen einen Mock, der in 1,5 Sekunden löste, brauchte gather bei 1, 5, 10 und 25 Lösevorgängen 2,6, 2,5, 4,6 und 9,8 Sekunden. Der Client öffnet für jeden Aufruf eine neue httpx-Verbindung, was die Event-Loop jedes Mal kurz beschäftigt. Der Patch aus dem Abschnitt zum Timeout gilt für diesen Client nicht. Sein Limit ist der Standardwert von httpx, 5 Sekunden pro Anfrage, nicht pro Lösevorgang, daher klappte ein Lösevorgang von 12 Sekunden trotzdem und war nach 14,1 Sekunden fertig. Ein stummes Übermitteln löste nach 5,2 Sekunden eine NetworkException mit leerer Meldung aus. Eine stumme Abfrage endete bei einem Limit von 20 nach 24,7 Sekunden mit einer TimeoutException. Mehr zur Nebenläufigkeit in Python finden Sie unter Captchas parallel mit asyncio lösen.

Unit-Tests, die keinen Solver brauchen

Die meisten Ihrer Tests sollten CapSkip nicht brauchen. Tests Ihres eigenen Codes, der den Solver aufruft, können einen Fake verwenden. Sein Ergebnis muss genau dem echten entsprechen. Das SDK gibt ein einfaches dict mit captchaId und code zurück, also tut der Fake es auch. Der letzte Test zeigt den anderen Weg: Bauen Sie den echten Client, der keine Verbindung öffnet, bis Sie lösen, und ersetzen Sie eine Methode mit monkeypatch. Alle vier bestehen bei geschlossenem CapSkip. Speichern Sie den Block als 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"

Die Tests gegen CapSkip auf einem anderen Rechner ausführen

Die Tests und der Solver müssen sich keinen PC teilen. Schalten Sie CapSkip in den Verbindungseinstellungen in den Server-Modus, dann lauscht es auf Ihrer Netzwerkadresse oder öffentlichen IP. Für stabilen Fernzugriff wird eine statische öffentliche IP empfohlen. Setzen Sie dann die drei Variablen, und dieselbe Suite läuft unverändert. Der Solver bleibt Ihr eigener Windows-Rechner, und abgerechnet wird pro Lösung weiterhin nichts.

# 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

Beachten Sie zwei Dinge. Das SDK spricht mit CapSkip über reines HTTP, daher wandert der API-Schlüssel im Klartext. Halten Sie diese Verbindung in einem privaten Netzwerk oder einem VPN, und aktivieren Sie die Validierung des API-Schlüssels. Und CapSkip öffnet seinen Port beim Start des Server-Modus für jede Adresse. Um den Zugriff auf die Rechner zu beschränken, die Sie erwarten, fügen Sie in der Windows Firewall eine Blockierregel für alle anderen hinzu. Das Remote-Setup wurde für diesen Leitfaden nicht ausgeführt. Es wurden nur die Variablen eingelesen.

Die vollständige conftest.py

Hier ist die conftest.py aus den Schritten 2 und 4 plus Timeout-Patch und asynchroner Fixture in einer Datei. Sie ersetzt die Blöcke, die Sie einzeln hinzugefügt haben, löschen Sie diese daher zuerst.

# 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)

Häufige Fehler bei pytest-Captcha-Tests

Was Sie sehenUrsacheBeheben
SKIPPED: CapSkip is not running on 127.0.0.1:8080Dort lauscht nichts, oder CAPSKIP_HOST enthält http://Starten Sie CapSkip, prüfen Sie die Verbindungseinstellungen und geben Sie einen Host ohne http:// an
INTERNALERROR mit no attribute SIGALRMDie Timeout-Methode signal gibt es unter Windows nichtVerwenden Sie die Methode thread
Ein Stack-Dump, keine Zusammenfassung, Exit-Code 1Das Thread-Timeout hat ausgelöst und den Lauf beendetLassen Sie das SDK zuerst scheitern, mit recaptchaTimeout und dem Request-Patch
worker gw1 crashed while runningDas Thread-Timeout hat einen xdist-Worker beendetDieselbe Lösung, und suchen Sie den hängenden Test
‘captcah’ not found in `markers` configuration optionEin falsch geschriebener Marker bei eingeschalteter strikter OptionKorrigieren Sie die Schreibweise
ModuleNotFoundError: No module named ‘tokens’pytest.exe legt den Projektordner nicht in den PfadFühren Sie python -m pytest aus
Failed: async def functions are not natively supportedEin asynchroner Test ohne den Marker asyncioFügen Sie den Marker hinzu
403 beim Login-POSTrequests hat keinen Referer gesendet, und die Website prüft ihnÜbergeben Sie den Header Referer
Der zweite Test scheitert, der erste bestehtZwei Tests haben einen Token gesendetLösen Sie innerhalb jedes Tests
NetworkException: Read timed out (read timeout=30)CapSkip hat die Übermittlung angenommen und ist verstummtPrüfen Sie CapSkip und führen Sie den Lauf erneut aus
TimeoutException nach etwa 333 SekundenEine stumme Abfrage, mit dem Standardlimit von 300 SekundenSetzen Sie recaptchaTimeout auf 90
Jeder Lösevorgang ist langsamer, und die erste Prüfung dauert 2 Sekunden längerCAPSKIP_HOST ist localhostVerwenden Sie 127.0.0.1
AttributeError: ‘NoneType’ object has no attribute ‘group’Die Seite unter LOGIN_URL hat weder CSRF-Token noch sitekey, LOGIN_URL enthält also noch die BeispieladresseSetzen Sie LOGIN_URL auf Ihre eigene Login-Seite

FAQ

Kann ein gehosteter CI-Runner diese Tests ausführen?

Ein gehosteter Runner ist der Rechner eines Fremden, kann also 127.0.0.1 auf Ihrem PC nicht erreichen. Er bräuchte für CapSkip im Server-Modus eine öffentliche Adresse. Der Schlüssel wandert dann im Klartext über reines HTTP, und eine Blockierregel kann die Adressen von Runnern, die ständig wechseln, nicht aufführen. Bevorzugen Sie einen selbst gehosteten Runner in Ihrem eigenen Netzwerk oder ein VPN, und halten Sie den API-Schlüssel im Secret Store des CI-Systems. Für diesen Leitfaden wurde kein gehosteter Runner getestet. Es wurden nur die Variablen eingelesen.

Sollte jeder Build die Captcha-Tests ausführen?

Nein. Jeder einzelne dauert Sekunden, braucht ein laufendes CapSkip und greift auf eine echte Staging-Website zu. Mit dem Marker kann ein normaler Build die schnelle Suite ausführen und ein geplanter Job die Live-Suite. Führen Sie die Live-Suite vor einem Release oder nächtlich aus.

Funktioniert das mit dem Standardmodul unittest?

Die CapSkip-Aufrufe sind in jedem Framework dieselben. Der Session-Client, der Skip, der Marker und die Parallel-Einstellungen oben sind Funktionen von pytest, und unittest wurde für diesen Leitfaden nicht ausgeführt. Schauen Sie in der Dokumentation Ihres Runners nach dessen eigenen Fixtures, Skips und Timeouts.

Brauche ich Selenium oder Playwright?

Für dieses Setup nicht. Es sendet das Formular mit requests ab und testet damit einen Login-Ablauf, den der Server prüft. Funktioniert das Captcha nur in einer echten Browser-Sitzung, steuern Sie aus dem Test einen Browser an und geben den Token an die Seite weiter. Die Aufrufe an den Solver bleiben gleich.

Die Kurzfassung

Eine pytest-Captcha-Suite braucht einen CapSkip-Client pro Session und einen frischen Lösevorgang in jedem Test. Überspringen Sie, wenn CapSkip geschlossen ist, markieren Sie die Live-Tests und halten Sie eine explizite Worker-Zahl unter den 10 Threads von CapSkip. Setzen Sie unter Windows die eigenen Limits des SDK deutlich unter das Limit von pytest-timeout, damit ein hängender Lösevorgang einen Test scheitern lässt, statt den Lauf zu beenden.

Eine nächtliche Suite, die einige hundert Mal löst, summiert sich schnell. Wenn Sie sie über einen Captcha-Löser auf Ihrem eigenen Windows-Rechner laufen lassen, werden diese Lösungen nie zu einer Rechnung.