{"id":26660,"date":"2026-10-18T19:07:45","date_gmt":"2026-10-18T19:07:45","guid":{"rendered":"https:\/\/capskip.com\/?p=26660"},"modified":"2026-10-18T19:07:45","modified_gmt":"2026-10-18T19:07:45","slug":"pytest-captcha-tests","status":"publish","type":"post","link":"https:\/\/capskip.com\/zh\/pytest-captcha-tests\/","title":{"rendered":"\u5982\u4f55\u5728 pytest \u4e2d\u8bc6\u522b\u9a8c\u8bc1\u7801\uff0c\u53c8\u4e0d\u8ba9\u6574\u4e2a\u8fd0\u884c\u5361\u4f4f"},"content":{"rendered":"<p>To solve CAPTCHAs in pytest, build one CapSkip client per session in conftest.py and solve inside each test, because a token works once. Skip the CAPTCHA tests when CapSkip is not running. Then give the SDK its own timeouts. On Windows, pytest-timeout can only use its thread method, and when that fires it ends the whole run. The Python SDK sets no HTTP timeout, so a silent CapSkip would stall a test with no error. CapSkip runs on your own Windows PC, so a suite that solves hundreds of times a night has no per-solve cost. This pytest CAPTCHA setup covers each of those traps, plus parallel runs with xdist and async tests.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">What you need<\/h2>\n<ul>\n<li>CapSkip running on a Windows PC, with the reCAPTCHA solver switched on. The <a href=\"https:\/\/capskip.com\/setup-guide\/\">setup guide<\/a> walks through the first start.<\/li>\n<li>Python 3.10 or later. The capskip 1.3.0 package and pytest 9 both need it. Everything below ran on Windows 11 with Python 3.14.0, with pytest 9.1.1, pytest-timeout 2.4.0, pytest-xdist 3.8.0, pytest-asyncio 1.4.0 and requests 2.34.2. The refused-connection timings came out the same on 3.13.7.<\/li>\n<li>A PowerShell prompt. The commands use PowerShell syntax.<\/li>\n<li>A staging site that you own, with a reCAPTCHA v2 checkbox in a login form. The example logs in to a Django-style form that carries a CSRF token. Change the field names to match yours.<\/li>\n<li>An address for the solver. In Local mode CapSkip answers on 127.0.0.1 for that device only. In Server mode it listens on your network address or public IP, so tests on another machine can call it over the API. Both are set under <a href=\"https:\/\/capskip.com\/setup-guide\/#connection-settings\">connection settings<\/a>.<\/li>\n<\/ul>\n<p>The measurements below come from a mock CapSkip and a mock login page on the same PC, used with the real pytest and the real SDK. The pytest behaviour and the SDK behaviour hold for your own site too. Solve times, thread limits and the accept and reject rules of the login page are the mock&#8217;s own.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 1: create the project<\/h2>\n<p>Put everything in one project folder: conftest.py, tokens.py and the test files. Use a virtual environment. Every command below runs the virtual environment&#8217;s own Python by its path, so you do not need to activate it. Start pytest as python -m pytest. That form adds the current folder to the import path. If you move conftest.py into a tests folder, keep tokens.py in the project folder. Then pytest.exe fails with ModuleNotFoundError: No module named &#8216;tokens&#8217;, and python -m pytest works.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># Windows PowerShell, in your project folder\npython -m venv .venv\n.venv\\Scripts\\python -m pip install pytest &quot;capskip==1.3.0&quot; requests<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 2: one CapSkip client for the whole run<\/h2>\n<p>Save this as conftest.py. It reads the CapSkip settings from the environment, because the SDK does not read them on its own, and the defaults make it run with no setup. CAPSKIP_API_KEY only matters if you switched on API key validation in CapSkip. The fixture builds one client for the whole session. It also finds out whether CapSkip is running.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># conftest.py (next to your tests): pip install pytest capskip requests\nimport os\nimport socket\n\nimport pytest\nfrom capskip import CapSkip\n\n# The SDK reads no environment variables, so the fixture reads them.\nHOST = os.environ.get(&quot;CAPSKIP_HOST&quot;, &quot;127.0.0.1&quot;)\nPORT = int(os.environ.get(&quot;CAPSKIP_PORT&quot;, &quot;8080&quot;))\nAPI_KEY = os.environ.get(&quot;CAPSKIP_API_KEY&quot;, &quot;capskip&quot;)\n\n\n@pytest.fixture(scope=&quot;session&quot;)\ndef capskip_client():\n    # CapSkip has no ping endpoint, so try a plain TCP connect. Use the IP,\n    # not &quot;localhost&quot;: a refused connect takes 2 s, or 4 s for localhost.\n    try:\n        socket.create_connection((HOST, PORT), timeout=3).close()\n    except OSError as exc:\n        pytest.skip(f&quot;CapSkip is not running on {HOST}:{PORT}: {exc}&quot;)\n    # One client for the whole run. A solve gives up after 90 s of polling.\n    return CapSkip(apiKey=API_KEY, host=HOST, port=PORT, recaptchaTimeout=90)<\/pre>\n<\/div>\n<p>CapSkip has no ping endpoint, so a plain TCP connect asks whether anything is listening, and it sends no solve job. If nothing is, the fixture skips instead of failing. With CapSkip closed, a refused connect to 127.0.0.1 took 2.05 seconds on Windows, and about 4.1 seconds to the name localhost. The skip is cached, so a whole session pays that cost once: four CAPTCHA tests in two files took 2.05 seconds in total.<\/p>\n<p>Use the IP. The name localhost has a second cost even when CapSkip is running. Against an IPv4-only listener, two login tests took 10.3 seconds with localhost and 3.75 seconds with 127.0.0.1. That is about 2 seconds once for the connection check, and 1 to 2.5 seconds more for each solve.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 3: a helper that solves once, and retries once<\/h2>\n<p>Save this as tokens.py. Some solves fail, and a fresh attempt can work. The SDK raises an ApiException whose message is ERROR_CAPTCHA_UNSOLVABLE, unless you set an Error Replacement text in CapSkip. Then the message carries that text, and the check below never matches. The helper retries only that failure. A submit that CapSkip refuses, such as ERROR_GOOGLEKEY or ERROR_PAGEURL, raises at once. A sitekey that Google refuses reads as UNSOLVABLE too, so it is tried twice before it fails. See <a href=\"https:\/\/capskip.com\/error-captcha-unsolvable\/\">ERROR_CAPTCHA_UNSOLVABLE<\/a> for what the error means.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># tokens.py: the helper your tests (and your app) call\nfrom capskip import ApiException\n\n\ndef fresh_token(solver, sitekey, url, attempts=2):\n    &quot;&quot;&quot;Solve a reCAPTCHA v2 and return the token string.&quot;&quot;&quot;\n    for attempt in range(attempts):\n        try:\n            return solver.recaptcha(sitekey=sitekey, url=url)[&quot;code&quot;]\n        except ApiException as exc:\n            # UNSOLVABLE is how CapSkip reports a solve that failed this\n            # time. A refused submit (ERROR_GOOGLEKEY, ERROR_PAGEURL) will\n            # fail again, so it raises at once.\n            last = attempt == attempts - 1\n            if last or &quot;ERROR_CAPTCHA_UNSOLVABLE&quot; not in str(exc):\n                raise<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 4: a fixture per test, and a marker<\/h2>\n<p>Add the next block to the end of conftest.py. It is a factory, not a token. Every call to it is a new solve, and each test asks for its own, because a reCAPTCHA token is accepted once. Python allows imports in the middle of a file, so the import line can stay where the block puts it.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\">from tokens import fresh_token\n\n\n@pytest.fixture\ndef solve_recaptcha(capskip_client):\n    # A factory, not a token: every call is a new solve, because a\n    # reCAPTCHA token is accepted once. Function scope, so each test\n    # asks for its own.\n    def solve(sitekey, url):\n        return fresh_token(capskip_client, sitekey, url)\n\n    return solve<\/pre>\n<\/div>\n<p>Then add the marker hook below to conftest.py. It registers a marker called captcha, so that you can run the fast tests without CapSkip and the live ones with it. With the strict markers option on, which Step 6 switches on, any marker you did not register is an error. Registering captcha keeps the real one working and turns a misspelling into an error.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\">def pytest_configure(config):\n    # Tell pytest the marker exists, so --strict-markers accepts it and a\n    # misspelling such as &quot;captcah&quot; is an error instead of a silent mark.\n    config.addinivalue_line(\n        &quot;markers&quot;, &quot;captcha: needs a running CapSkip and solves a real CAPTCHA&quot;\n    )<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 5: write the login tests<\/h2>\n<p>Save the next two blocks as one file, test_login.py. This first block is yours, not CapSkip&#8217;s. It opens the login page, reads the CSRF token and the sitekey, and posts the form. requests sends no Referer on a form post. A strict site such as Django over HTTPS rejects a post without one, and the mock did the same with a 403. So the helper sets it. Adjust the two patterns and the field names to your markup, and put the email and password of a test account into post_form.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># test_login.py: set LOGIN_URL to your own login page\nimport os\nimport re\n\nimport pytest\nimport requests\n\nLOGIN_URL = os.environ.get(&quot;LOGIN_URL&quot;, &quot;https:\/\/example.com\/login&quot;)\n\n\ndef open_form(session):\n    html = session.get(LOGIN_URL, timeout=15).text\n    csrf = re.search(r'name=&quot;csrfmiddlewaretoken&quot; value=&quot;([^&quot;]+)&quot;', html)\n    sitekey = re.search(r'data-sitekey=&quot;([^&quot;]+)&quot;', html)\n    return csrf.group(1), sitekey.group(1)\n\n\ndef post_form(session, csrf, token, password=&quot;secret&quot;):\n    # requests sends no Referer, and a Django site refuses an HTTPS post\n    # that has none, so set it by hand.\n    return session.post(LOGIN_URL, timeout=15, headers={&quot;Referer&quot;: LOGIN_URL}, data={\n        &quot;csrfmiddlewaretoken&quot;: csrf,\n        &quot;email&quot;: &quot;user@example.com&quot;,\n        &quot;password&quot;: password,\n        &quot;g-recaptcha-response&quot;: token,\n    })<\/pre>\n<\/div>\n<p>The tests solve last and post at once, because the token expires after about two minutes. The <a href=\"https:\/\/capskip.com\/recaptcha-token-expiration\/\">guide to reCAPTCHA token expiration<\/a> explains that side of it. Change Welcome, the 403 and the error text to what your own site shows. A fixed username collides on the second nightly run, so use a new one each time if your form creates accounts.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\">@pytest.mark.captcha\ndef test_login(solve_recaptcha):\n    session = requests.Session()\n    csrf, sitekey = open_form(session)\n    token = solve_recaptcha(sitekey, LOGIN_URL)  # solve last, post at once\n    resp = post_form(session, csrf, token)\n    assert resp.status_code == 200 and &quot;Welcome&quot; in resp.text\n\n\n@pytest.mark.captcha\ndef test_wrong_password_is_refused(solve_recaptcha):\n    session = requests.Session()\n    csrf, sitekey = open_form(session)\n    token = solve_recaptcha(sitekey, LOGIN_URL)  # a new solve, not test 1's\n    resp = post_form(session, csrf, token, password=&quot;wrong&quot;)\n    assert resp.status_code == 403\n    assert &quot;Invalid email or password&quot; in resp.text<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 6: run the fast tests, then the live ones<\/h2>\n<p>The first line points the tests at your login page. The second makes a misspelled marker an error. It sets an environment variable that pytest reads for extra options, and it lasts for the rest of that PowerShell session. The third line runs everything except the CAPTCHA tests. The fourth runs only the live ones. The skip switch prints why a test was skipped, and the durations switch lists the slowest tests.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># Windows PowerShell: your login page, then make a misspelled marker an error\n$env:LOGIN_URL = &quot;https:\/\/staging.example.com\/login&quot;\n$env:PYTEST_ADDOPTS = &quot;--strict-markers&quot;\n.venv\\Scripts\\python -m pytest -m &quot;not captcha&quot;      # fast suite, no CapSkip\n.venv\\Scripts\\python -m pytest -m captcha -rs -v --durations=5   # live tests<\/pre>\n<\/div>\n<p>At this point the fast run finds nothing to run, and pytest exits with code 5. It has tests once you save test_unit.py from the unit tests section further down. Then it needs nothing from the solver: its four tests passed in 0.02 seconds with CapSkip closed. The live run skipped with a line like this, pointing at the test and not at conftest.py:<\/p>\n<ul>\n<li>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<\/li>\n<\/ul>\n<p>A skipped suite exits with code 0, and pytest 9.1.1 has no switch that turns a skip into a failure. If your build must have CapSkip, write a junit file with the junitxml option and fail the build when its skipped count is above zero. Typing captcah instead of captcha in a test, with the strict option on, stops collection with exit code 2 and the message &#8216;captcah&#8217; not found in `markers` configuration option. Without the strict option the typo would only be a warning, and that test would run in the fast suite.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Solve inside every test<\/h2>\n<p>Do not solve once in a fixture and share the token. In testing, a module-scoped fixture solved once and two tests posted the same token. The first was accepted. The second failed with the mock&#8217;s reCAPTCHA failed: token already used. A real site words it differently. Which test loses depends on the order, so the failure moves around and looks flaky. It is worse under xdist, as the parallel section shows. Here is the mistake in full, so you can recognise it. Delete the file after you have seen it fail, or it will make later runs fail or pass at random.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># test_shared_token.py: the mistake, so you can see it fail\nimport pytest\nimport requests\n\nfrom test_login import LOGIN_URL, open_form, post_form\n\n\n@pytest.fixture(scope=&quot;module&quot;)\ndef shared_token(capskip_client):\n    csrf, sitekey = open_form(requests.Session())\n    return capskip_client.recaptcha(sitekey=sitekey, url=LOGIN_URL)[&quot;code&quot;]\n\n\n@pytest.mark.captcha\n@pytest.mark.parametrize(&quot;n&quot;, [1, 2])\ndef test_each_login_reuses_one_token(shared_token, n):\n    session = requests.Session()\n    csrf, sitekey = open_form(session)\n    resp = post_form(session, csrf, shared_token)\n    assert resp.status_code == 200, resp.text  # n=2 fails: used already<\/pre>\n<\/div>\n<p>One solve per test costs a few seconds each. A test that does not need the CAPTCHA itself should not ask for the fixture. The fake solver further down covers the logic around it.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Make a hung solve fail, not abort the run<\/h2>\n<p>This is the part that is different on Windows. The pytest-timeout plugin has two methods. The signal method needs SIGALRM, which Windows does not have, so it is not available there. Asking for it ended with INTERNALERROR and AttributeError: module &#8216;signal&#8217; has no attribute &#8216;SIGALRM&#8217;. The thread method is the default, and it is the only one that works. Here is what it did when a test hung, with a limit of 10 seconds and four tests in the run:<\/p>\n<ul>\n<li>It printed the main thread&#8217;s stack, about 100 lines, and ended the process with exit code 1 at 12.5 seconds.<\/li>\n<li>The hung test got no PASSED or FAILED line.<\/li>\n<li>No summary was printed and no junit file was written.<\/li>\n<li>The two tests after it never ran.<\/li>\n<\/ul>\n<p>With xdist the damage is smaller. Only the worker running that test dies. The test is reported as FAILED with worker gw1 crashed while running it, a replacement worker starts, and the run finishes with a summary and exit code 1.<\/p>\n<p>So the thread timeout is a last resort, not a test timeout. You want the SDK to fail first, with an ordinary exception, so the test fails and the run goes on. The Python SDK 1.3.0 makes that hard. It calls requests with no timeout, so a CapSkip that accepts a call and never answers hangs it. Without any change, a silent submit was still blocked when we stopped it at 45 seconds. Its recaptchaTimeout is only checked between polls, and a hung poll is treated as not ready.<\/p>\n<p>The fix is to swap the two request functions the SDK uses for ones with a timeout. Add the next block to conftest.py. It reaches into the SDK, so pin capskip to version 1.3.0. The hasattr line makes it do nothing if a later version stops using requests. It does not cover the download of an image CAPTCHA given as a URL, because the SDK does that with its own call.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\">import functools\nimport types\n\nimport capskip.api\nimport requests\n\n# capskip 1.3.0 sets no HTTP timeout, so a CapSkip that accepts a call\n# and never answers would hang the test. Give every SDK call 5 s to\n# connect and 30 s to answer. This swaps a name inside the SDK, so pin\n# capskip==1.3.0 and do nothing if the SDK stops using requests.\nif hasattr(capskip.api, &quot;requests&quot;):\n    capskip.api.requests = types.SimpleNamespace(\n        RequestException=requests.RequestException,\n        post=functools.partial(requests.post, timeout=(5, 30)),\n        get=functools.partial(requests.get, timeout=(5, 30)),\n    )<\/pre>\n<\/div>\n<p>With the patch, a CapSkip that never answered the submit raised NetworkException with Read timed out. (read timeout=30) at 30.0 seconds. The pytest test failed at 30.8 seconds, and the run stayed intact. A silent poll is different. The SDK swallows it as not ready, and the default recaptchaTimeout of 300 seconds ended with TimeoutException at 333.0 seconds. That is why Step 2 passes recaptchaTimeout=90. With 90, the test failed at 92.6 seconds.<\/p>\n<p>The deadline can be overshot by up to about 35 seconds. It is checked between polls, a poll can take its full 30 seconds, and the SDK waits up to 5 seconds before it checks. With a limit of 45 the failure came at 60.8 seconds, and with 20 at 30.3 seconds. So the worst case for one attempt with these numbers is about 125 seconds: 90 for the polling, plus one stuck poll. The helper from Step 3 makes up to two attempts, so the worst case is about 250 seconds. Set the pytest-timeout limit above that. The next block installs the plugin and sets 300 seconds. The environment variable replaces the one from Step 6, so it repeats the strict option. You can put the same two options in a pytest ini file as timeout and timeout_method instead. The variable is just easier to paste.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># Windows PowerShell: pytest-timeout has only the thread method here.\n# 300 s is above two attempts at the slowest SDK failure (about 125 s each),\n# so the SDK fails first.\n.venv\\Scripts\\python -m pip install pytest-timeout\n$env:PYTEST_ADDOPTS = &quot;--strict-markers --timeout=300 --timeout-method=thread&quot;<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Run the tests in parallel with xdist<\/h2>\n<p>Solving is slow and mostly waiting, so xdist helps. Each xdist worker is a separate process. A session fixture therefore runs once per worker, and every worker builds its own client. With four workers and eight tests, the run showed four process IDs and four client objects. Without xdist there is one of each. The test writes workers-0.log to workers-7.log, and Get-Content workers-*.log shows them. One client per worker is fine, because the client keeps nothing that workers need to share.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># test_workers.py: shows one client per worker process\nimport os\nfrom pathlib import Path\n\nimport pytest\n\n\n@pytest.mark.captcha\n@pytest.mark.parametrize(&quot;n&quot;, range(8))\ndef test_which_client(capskip_client, worker_id, n):\n    line = f&quot;{worker_id} pid={os.getpid()} client={id(capskip_client)}&quot;\n    Path(f&quot;workers-{n}.log&quot;).write_text(line)<\/pre>\n<\/div>\n<p>Pick the number of workers yourself. The auto setting means the CPU count, not what CapSkip can solve at once. It gave 24 workers on the test PC. Four unit tests took about 4 seconds with it and about 1.1 seconds with four workers, because of the worker start-up cost. CapSkip&#8217;s own limit is the reCAPTCHA Max. Threads setting, which defaults to 10. This table is from 24 logins against a mock with that limit and a 5 second solve.<\/p>\n<table>\n<thead>\n<tr>\n<th>Workers<\/th>\n<th>Run time<\/th>\n<th>Solving at once<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>1<\/td>\n<td>189.6 s<\/td>\n<td>1<\/td>\n<\/tr>\n<tr>\n<td>4<\/td>\n<td>49.1 s<\/td>\n<td>4<\/td>\n<\/tr>\n<tr>\n<td>10<\/td>\n<td>26.1 s<\/td>\n<td>10<\/td>\n<\/tr>\n<tr>\n<td>12<\/td>\n<td>24.1 s<\/td>\n<td>10<\/td>\n<\/tr>\n<tr>\n<td>24<\/td>\n<td>23.1 s<\/td>\n<td>10<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>Past 10 workers the gain is small: 26.1 seconds with 10 workers and 23.1 with 24. The extra jobs wait for a free thread inside CapSkip, and a job that waits past the Wait Timeout fails. In a scaled-down test, eight tests ran at once against a limit of 2, a wait limit of 6 seconds and a 4 second solve. Four jobs failed with ERROR_CAPTCHA_UNSOLVABLE. The retry in tokens.py queued them again, and all eight tests passed in 22.3 seconds. That is the second reason for the retry.<\/p>\n<p>Each test took about 7.9 seconds for a 5 second solve. The SDK polls at 0, 0.25, 0.75, 1.75, 3.75 and 7.75 seconds, so it notices a finished solve at the next poll. The xdist install and the command are below.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># Each xdist worker is its own process, so each builds its own client.\n# Pick the number yourself: -n auto means CPU count, not solver capacity.\n.venv\\Scripts\\python -m pip install pytest-xdist\n.venv\\Scripts\\python -m pytest -m captcha -n 4 -v<\/pre>\n<\/div>\n<p>One more trap. Under xdist, the shared-token mistake from earlier can pass. The module-scoped token is solved once per worker, so two tests collide only when they land on the same worker. A test that passes in some runs and fails in others is a clue to look for a shared token.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Async tests with AsyncCapSkip<\/h2>\n<p>The Python SDK has a real async client, AsyncCapSkip. It is built on httpx. Add this fixture to conftest.py. It asks for capskip_client, so the skip check still applies. The async client holds no event loop, so a plain fixture is enough and the tests run on the default function-scoped loops.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\">from capskip import AsyncCapSkip\n\n\n@pytest.fixture(scope=&quot;session&quot;)\ndef async_capskip_client(capskip_client):\n    # Asking for capskip_client keeps the skip-when-closed check. The\n    # async client holds no event loop, so a plain fixture is enough.\n    return AsyncCapSkip(apiKey=API_KEY, host=HOST, port=PORT, recaptchaTimeout=90)<\/pre>\n<\/div>\n<p>pytest-asyncio 1.4.0 runs in strict mode, and pytest 9 fails an async test that lacks the marker with Failed: async def functions are not natively supported. Save the next block as test_async.py. Change YOUR_SITEKEY to the sitekey of the page.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># test_async.py: .venv\\Scripts\\python -m pip install pytest-asyncio\nimport asyncio\n\nimport pytest\n\nfrom test_login import LOGIN_URL\n\n\n@pytest.mark.captcha\n@pytest.mark.asyncio  # needed: pytest-asyncio runs in strict mode\nasync def test_five_solves_at_once(async_capskip_client):\n    solves = [\n        async_capskip_client.recaptcha(sitekey=&quot;YOUR_SITEKEY&quot;, url=LOGIN_URL)\n        for _ in range(5)\n    ]\n    results = await asyncio.gather(*solves)\n    assert len({r[&quot;code&quot;] for r in results}) == 5  # five different tokens<\/pre>\n<\/div>\n<p>Do not expect it to scale in a straight line. Against a mock that solved in 1.5 seconds, gathering 1, 5, 10 and 25 solves took 2.6, 2.5, 4.6 and 9.8 seconds. The client opens a new httpx connection for every call, which keeps the event loop busy for a moment each time. The patch from the timeout section does not apply to this client. Its limit is httpx&#8217;s default of 5 seconds per request, not per solve, so a 12 second solve still worked and finished at 14.1 seconds. A silent submit raised NetworkException with an empty message at 5.2 seconds. A silent poll ended with TimeoutException at 24.7 seconds with a limit of 20. For more on concurrency in Python, see <a href=\"https:\/\/capskip.com\/solve-captchas-parallel-python\/\">solving CAPTCHAs in parallel with asyncio<\/a>.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Unit tests that need no solver<\/h2>\n<p>Most of your tests should not need CapSkip. Tests of your own code that calls the solver can use a fake. Its result has to match the real one exactly. The SDK returns a plain dict with captchaId and code, so the fake does too. The last test shows the other way: build the real client, which opens no connection until you solve, and replace one method with monkeypatch. All four pass with CapSkip closed. Save the block as test_unit.py.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># test_unit.py: runs with CapSkip closed\nimport pytest\nfrom capskip import ApiException, CapSkip\n\nfrom tokens import fresh_token\n\nURL = &quot;https:\/\/example.com\/login&quot;\n\n\nclass FakeSolver:\n    &quot;&quot;&quot;Same call and same result shape as CapSkip.recaptcha().&quot;&quot;&quot;\n\n    def __init__(self, *outcomes):\n        self.outcomes = list(outcomes)\n        self.calls = 0\n\n    def recaptcha(self, sitekey, url, **kwargs):\n        self.calls += 1\n        outcome = self.outcomes.pop(0)\n        if isinstance(outcome, Exception):\n            raise outcome\n        return {&quot;captchaId&quot;: &quot;1001&quot;, &quot;code&quot;: outcome}\n\n\ndef test_returns_the_code():\n    assert fresh_token(FakeSolver(&quot;tok-1&quot;), &quot;KEY&quot;, URL) == &quot;tok-1&quot;\n\n\ndef test_retries_once_when_unsolvable():\n    fake = FakeSolver(ApiException(&quot;ERROR_CAPTCHA_UNSOLVABLE&quot;), &quot;tok-2&quot;)\n    assert fresh_token(fake, &quot;KEY&quot;, URL) == &quot;tok-2&quot;\n    assert fake.calls == 2\n\n\ndef test_does_not_retry_a_bad_sitekey():\n    fake = FakeSolver(ApiException(&quot;ERROR_GOOGLEKEY&quot;), &quot;never-used&quot;)\n    with pytest.raises(ApiException):\n        fresh_token(fake, &quot;KEY&quot;, URL)\n    assert fake.calls == 1\n\n\ndef test_real_client_with_monkeypatch(monkeypatch):\n    solver = CapSkip()  # opens no connection until a solve is called\n    fake = {&quot;captchaId&quot;: &quot;1&quot;, &quot;code&quot;: &quot;tok-3&quot;}\n    monkeypatch.setattr(solver, &quot;recaptcha&quot;, lambda **kw: fake)\n    assert fresh_token(solver, &quot;KEY&quot;, URL) == &quot;tok-3&quot;<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Run the tests against CapSkip on another machine<\/h2>\n<p>The tests and the solver do not have to share a PC. Switch CapSkip to Server mode under connection settings, and it listens on your network address or public IP. A static public IP is recommended for stable remote access. Then set the three variables and the same suite runs unchanged. The solver is still your own Windows machine, and solves are still unmetered.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># CapSkip on another Windows machine in Server mode: set these first\n$env:CAPSKIP_HOST = &quot;192.168.1.50&quot;\n$env:CAPSKIP_PORT = &quot;8080&quot;\n$env:CAPSKIP_API_KEY = &quot;your-key&quot;   # only if API key validation is on\n.venv\\Scripts\\python -m pytest -m captcha -rs<\/pre>\n<\/div>\n<p>Mind two things. The SDK talks to CapSkip over plain HTTP, so the API key travels in clear text. Keep that link on a private network or a VPN, and turn on API key validation. And CapSkip opens its port to every address when Server mode starts. To limit access to the machines you expect, add a Windows Firewall block rule for the others. The remote setup was not run for this guide. Only the variables were read.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">The whole conftest.py<\/h2>\n<p>Here is the conftest.py from Steps 2 and 4 plus the timeout patch and the async fixture, in one file. It replaces the blocks you added one by one, so delete those first.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># conftest.py, complete: pip install pytest capskip==1.3.0 requests\nimport functools\nimport os\nimport socket\nimport types\n\nimport capskip.api\nimport pytest\nimport requests\nfrom capskip import AsyncCapSkip, CapSkip\n\nfrom tokens import fresh_token\n\n# The SDK reads no environment variables, so this file reads them.\nHOST = os.environ.get(&quot;CAPSKIP_HOST&quot;, &quot;127.0.0.1&quot;)\nPORT = int(os.environ.get(&quot;CAPSKIP_PORT&quot;, &quot;8080&quot;))\nAPI_KEY = os.environ.get(&quot;CAPSKIP_API_KEY&quot;, &quot;capskip&quot;)\n\n# capskip 1.3.0 sets no HTTP timeout. Give every SDK call 5 s to connect\n# and 30 s to answer. Pin capskip==1.3.0: this swaps a name inside it.\nif hasattr(capskip.api, &quot;requests&quot;):\n    capskip.api.requests = types.SimpleNamespace(\n        RequestException=requests.RequestException,\n        post=functools.partial(requests.post, timeout=(5, 30)),\n        get=functools.partial(requests.get, timeout=(5, 30)),\n    )\n\n\ndef pytest_configure(config):\n    # Register the marker so --strict-markers accepts it.\n    config.addinivalue_line(\n        &quot;markers&quot;, &quot;captcha: needs a running CapSkip and solves a real CAPTCHA&quot;\n    )\n\n\n@pytest.fixture(scope=&quot;session&quot;)\ndef capskip_client():\n    # No ping endpoint, so a plain TCP connect. Use the IP, not &quot;localhost&quot;.\n    try:\n        socket.create_connection((HOST, PORT), timeout=3).close()\n    except OSError as exc:\n        pytest.skip(f&quot;CapSkip is not running on {HOST}:{PORT}: {exc}&quot;)\n    return CapSkip(apiKey=API_KEY, host=HOST, port=PORT, recaptchaTimeout=90)\n\n\n@pytest.fixture\ndef solve_recaptcha(capskip_client):\n    # A factory: every call is a new solve, and a token works once.\n    def solve(sitekey, url):\n        return fresh_token(capskip_client, sitekey, url)\n\n    return solve\n\n\n@pytest.fixture(scope=&quot;session&quot;)\ndef async_capskip_client(capskip_client):\n    return AsyncCapSkip(apiKey=API_KEY, host=HOST, port=PORT, recaptchaTimeout=90)<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Common errors with pytest CAPTCHA tests<\/h2>\n<table>\n<thead>\n<tr>\n<th>What you see<\/th>\n<th>Cause<\/th>\n<th>Fix<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>SKIPPED: CapSkip is not running on 127.0.0.1:8080<\/td>\n<td>Nothing listens there, or CAPSKIP_HOST includes http:\/\/<\/td>\n<td>Start CapSkip, check connection settings, give a host with no http:\/\/<\/td>\n<\/tr>\n<tr>\n<td>INTERNALERROR with no attribute SIGALRM<\/td>\n<td>The signal timeout method does not exist on Windows<\/td>\n<td>Use the thread method<\/td>\n<\/tr>\n<tr>\n<td>A stack dump, no summary, exit code 1<\/td>\n<td>The thread timeout fired and ended the run<\/td>\n<td>Make the SDK fail first, with recaptchaTimeout and the request patch<\/td>\n<\/tr>\n<tr>\n<td>worker gw1 crashed while running<\/td>\n<td>The thread timeout killed one xdist worker<\/td>\n<td>Same fix, and look for the hung test<\/td>\n<\/tr>\n<tr>\n<td>&#8216;captcah&#8217; not found in `markers` configuration option<\/td>\n<td>A misspelled marker with the strict option on<\/td>\n<td>Fix the spelling<\/td>\n<\/tr>\n<tr>\n<td>ModuleNotFoundError: No module named &#8216;tokens&#8217;<\/td>\n<td>pytest.exe does not put the project folder on the path<\/td>\n<td>Run python -m pytest<\/td>\n<\/tr>\n<tr>\n<td>Failed: async def functions are not natively supported<\/td>\n<td>An async test with no asyncio marker<\/td>\n<td>Add the marker<\/td>\n<\/tr>\n<tr>\n<td>403 on the login post<\/td>\n<td>requests sent no Referer, and the site checks it<\/td>\n<td>Pass the Referer header<\/td>\n<\/tr>\n<tr>\n<td>The second test fails, the first passes<\/td>\n<td>Two tests posted one token<\/td>\n<td>Solve inside each test<\/td>\n<\/tr>\n<tr>\n<td>NetworkException: Read timed out (read timeout=30)<\/td>\n<td>CapSkip took the submit and went silent<\/td>\n<td>Check CapSkip, then rerun<\/td>\n<\/tr>\n<tr>\n<td>TimeoutException after about 333 seconds<\/td>\n<td>A silent poll, with the default 300 second limit<\/td>\n<td>Set recaptchaTimeout to 90<\/td>\n<\/tr>\n<tr>\n<td>Every solve is slower, and the first check takes 2 seconds more<\/td>\n<td>CAPSKIP_HOST is localhost<\/td>\n<td>Use 127.0.0.1<\/td>\n<\/tr>\n<tr>\n<td>AttributeError: &#8216;NoneType&#8217; object has no attribute &#8216;group&#8217;<\/td>\n<td>The page at LOGIN_URL has no CSRF token or sitekey, so LOGIN_URL still holds the example address<\/td>\n<td>Set LOGIN_URL to your own login page<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">FAQ<\/h2>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Can a hosted CI runner run these tests?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">A hosted runner is someone else&#8217;s machine, so it cannot reach 127.0.0.1 on your PC. It would need a public address for CapSkip in Server mode. The key then travels in clear text over plain HTTP, and a block rule cannot list runner addresses that keep changing. Prefer a self-hosted runner on your own network, or a VPN, and keep the API key in the CI system&#8217;s secret store. No hosted runner was tested for this guide. Only the variables were read.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Should every build run the CAPTCHA tests?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">No. Each one takes seconds, needs a running CapSkip and touches a real staging site. The marker lets an ordinary build run the fast suite and a scheduled job run the live one. Run the live one before a release, or nightly.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Does this work with the standard unittest module?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">The CapSkip calls are the same in any framework. The session client, the skip, the marker and the parallel settings above are pytest features, and unittest was not run for this guide. Check your runner&#8217;s documentation for its own fixtures, skips and timeouts.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Do I need Selenium or Playwright?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Not for this setup. It posts the form with requests, so it tests a login flow that the server checks. If the CAPTCHA only works inside a real browser session, drive a browser from the test and hand the token to the page. The solver calls stay the same.<\/p>\n<\/details>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">The short version<\/h2>\n<p>A pytest CAPTCHA suite needs one CapSkip client per session and a fresh solve inside each test. Skip when CapSkip is closed, mark the live tests, and keep an explicit worker count under CapSkip&#8217;s 10 threads. On Windows, set the SDK&#8217;s own limits well below the pytest-timeout limit, so a hung solve fails one test instead of ending the run.<\/p>\n<ul>\n<li>Every CAPTCHA type the Python package handles: <a href=\"https:\/\/capskip.com\/python-captcha-solver\/\">the Python CAPTCHA solver page<\/a>.<\/li>\n<li>The checkbox reCAPTCHA in a plain Python script: <a href=\"https:\/\/capskip.com\/recaptcha-v2-python\/\">solving reCAPTCHA v2 in Python<\/a>.<\/li>\n<li>How the checkbox solver works: <a href=\"https:\/\/capskip.com\/recaptcha-v2-solver\/\">the reCAPTCHA v2 solver page<\/a>.<\/li>\n<\/ul>\n<p>A nightly suite that solves a few hundred times adds up quickly. Running it through a <a href=\"https:\/\/capskip.com\/\">captcha solver<\/a> on your own Windows machine means those solves never turn into a bill.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>\u9a8c\u8bc1\u7801\u4ee4\u724c\u53ea\u80fd\u4f7f\u7528\u4e00\u6b21\uff0cpytest-timeout \u5728 Windows \u4e0a\u53ea\u80fd\u4e2d\u6b62\u6574\u4e2a\u6d4b\u8bd5\u8fd0\u884c\uff0c\u800c\u4e14 Python SDK \u4e0d\u8bbe\u7f6e HTTP \u8d85\u65f6\u3002\u672c\u6587\u7ed9\u51fa\u4e00\u4e2a pytest \u65b9\u6848\uff1a\u5b83\u7528 CapSkip \u8bc6\u522b reCAPTCHA\uff0c\u5728\u8bc6\u522b\u670d\u52a1\u5173\u95ed\u65f6\u8df3\u8fc7\u6d4b\u8bd5\uff0c\u5e76\u8ba9\u5361\u4f4f\u7684\u8bc6\u522b\u5e72\u51c0\u5730\u5931\u8d25\u3002<\/p>","protected":false},"author":1,"featured_media":26659,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"pytest CAPTCHA Tests on Windows: Skips, Timeouts | CapSkip","rank_math_description":"Set up a pytest captcha test with CapSkip: one client, a fresh solve per test, a skip when the solver is closed and timeouts that fail cleanly.","rank_math_focus_keyword":"pytest captcha","footnotes":""},"categories":[70],"tags":[],"class_list":["post-26660","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-captcha"],"_links":{"self":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/26660","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/comments?post=26660"}],"version-history":[{"count":3,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/26660\/revisions"}],"predecessor-version":[{"id":26668,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/26660\/revisions\/26668"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media\/26659"}],"wp:attachment":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media?parent=26660"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/categories?post=26660"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/tags?post=26660"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}