How to Solve CAPTCHAs in xUnit Tests With CapSkip (C#)

To solve CAPTCHAs in xUnit tests, build one CapSkip client in an assembly fixture and solve inside each test, because a token works once. Pass the test’s cancellation token to the SDK, so a timeout stops the solve. Keep the number of solves in flight under CapSkip’s thread limit. CapSkip runs on your own Windows PC, so a suite that solves hundreds of times a night has no per-solve cost. This xUnit CAPTCHA setup has traps of its own on xUnit v3 and .NET 10. The dotnet test command needs an opt-in file. Test classes run in parallel by default. And a timeout only works if your code cooperates. This guide covers each of them.
What you need
- CapSkip running on a Windows PC, with the reCAPTCHA solver switched on. The setup guide walks through the first start.
- The .NET 10 SDK. Test projects for xUnit v3 target net8.0 or later, or .NET Framework 4.7.2. Everything below ran on Windows 11 with SDK 10.0.100, on net10.0 and again on net8.0, with xunit.v3.mtp-v2 4.0.2 and CapSkip 1.3.0 from NuGet. SDK 8 and 9 were not tested.
- A staging site that you own, with a reCAPTCHA v2 checkbox in a form. The example posts a sign-up form that carries a CSRF token. Change the field names to match yours.
- 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 connection settings.
The measurements below come from a mock CapSkip and a mock sign-up page on the same PC, used with the real xUnit and the real SDK. The xUnit behaviour and the SDK behaviour hold for your own site too. Solve times and the accept and reject rules of the sign-up page are the mock’s own.
Step 1: create the test project
The .NET SDK has no xUnit v3 template until you install the template pack. The first command below does that. The template then writes the project, an xunit.runner.json file, a global.json file and a sample test called UnitTest1.cs. The block deletes the sample test, because it would add a second test to every run.
# The SDK has no xUnit v3 template until you install the template pack. dotnet new install xunit.v3.templates dotnet new xunit3 -n CapSkipTests -f net10.0 cd CapSkipTests rm UnitTest1.cs # the template's sample test; delete it dotnet add package CapSkip
Keep the global.json. On SDK 10, dotnet test refuses an xUnit v3 project without it and stops with: Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. The file switches dotnet test to the new runner, Microsoft.Testing.Platform, which the block comments below call MTP. The template puts the file in the project folder, or adds the test section to a global.json that already sits above it. Run dotnet test from the project folder. Pointing it at the project from a folder without the file, for example dotnet test CapSkipTests, fails with the same error.
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}Step 2: one CapSkip client for the whole run
Each C# file block from here on starts with the name to save it as. The first one is the fixture. It reads the CapSkip settings from the environment, because the SDK does not read them on its own, and it builds one client. The defaults make it run with no setup. CAPSKIP_API_KEY only matters if you switched on API key validation in CapSkip.
// CapSkipFixture.cs
using CapSkip;
public sealed partial class CapSkipFixture : IAsyncLifetime
{
// The SDK never reads CAPSKIP_* itself, so read them here.
private static string Env(string name, string fallback) =>
Environment.GetEnvironmentVariable(name) ?? fallback;
private readonly string _host = Env("CAPSKIP_HOST", "127.0.0.1");
private readonly int _port = int.Parse(Env("CAPSKIP_PORT", "8080"));
public CapSkipClient Solver { get; }
public bool IsUp { get; private set; }
public string SiteUrl { get; } = Env("STAGING_URL", "https://staging.example.com");
public CapSkipFixture() => Solver = new CapSkipClient(
apiKey: Env("CAPSKIP_API_KEY", "capskip"), host: _host, port: _port);
public async ValueTask InitializeAsync() =>
IsUp = await CapSkipProbe.IsListeningAsync(_host, _port);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}The fixture also finds out whether CapSkip is running. A plain TCP connect asks whether anything is listening, and it sends no solve job.
// CapSkipProbe.cs
using System.Net.Sockets;
public static class CapSkipProbe
{
// A TCP connect asks "is anything listening?" and sends no solve job.
public static async Task<bool> IsListeningAsync(string host, int port)
{
using var tcp = new TcpClient();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1));
try
{
await tcp.ConnectAsync(host, port, timeout.Token);
return true;
}
catch (Exception e) when (e is SocketException or OperationCanceledException)
{
return false;
}
}
}Register the fixture on the assembly. xUnit then builds one instance for the whole run. It gives that instance to every test class through its constructor, as the xUnit shared context docs describe.
// AssemblyInfo.cs // One CapSkipFixture for the whole run, handed to every test class // through its constructor. No IClassFixture or [Collection] needed. [assembly: AssemblyFixture(typeof(CapSkipFixture))]
The assembly fixture beats the other two styles. With six test classes, a class fixture was built six times, once per class. A collection fixture was built once, but it forces those classes to run one after another. Six classes that each solved once took more than 23 seconds with it, and under 5 seconds with the assembly fixture. The client itself is safe to share. It keeps no mutable state, every client uses the same static HttpClient inside the SDK, and fourteen solves at once through one client worked.
Step 3: a small helper for your staging site
This part is yours, not CapSkip’s. The helper opens the sign-up page and reads the sitekey and the CSRF token from it. Adjust the two patterns to your markup.
// SignupSite.cs
using System.Text.RegularExpressions;
public sealed record SignupPage(string Url, string SiteKey, string Csrf);
public sealed partial class SignupSite(string baseUrl) : IDisposable
{
// One HttpClient per test: it keeps this test's cookies and no
// other test's.
private readonly HttpClient _http = new();
public async Task<SignupPage> OpenAsync(CancellationToken ct)
{
var url = baseUrl + "/signup";
var html = await _http.GetStringAsync(url, ct);
return new SignupPage(url,
Regex.Match(html, "data-sitekey=\"([^\"]+)\"").Groups[1].Value,
Regex.Match(html, "name=\"csrfmiddlewaretoken\" value=\"([^\"]+)\"").Groups[1].Value);
}
public void Dispose() => _http.Dispose();
}Then it posts the form with the token in the g-recaptcha-response field. HttpClient sends no Referer on a form post, and a strict site such as Django over HTTPS rejects a post without one, so the helper sets it.
// SignupSite.Submit.cs
using System.Net;
public sealed partial class SignupSite
{
public async Task<(HttpStatusCode Status, string Body)> SubmitAsync(
SignupPage page, string token, CancellationToken ct)
{
using var request = new HttpRequestMessage(HttpMethod.Post, page.Url)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["csrfmiddlewaretoken"] = page.Csrf,
["username"] = "ci-user",
["g-recaptcha-response"] = token,
}),
};
request.Headers.Referrer = new Uri(page.Url); // HttpClient sends none
using var response = await _http.SendAsync(request, ct);
return (response.StatusCode, await response.Content.ReadAsStringAsync(ct));
}
}Each test creates its own helper and so its own HttpClient. That is on purpose. With one shared cookie-keeping client and six parallel tests, a site that starts a new session on every visit accepted one post and rejected five with a CSRF mismatch. The tests overwrote each other’s cookies.
Step 4: write the test
The xUnit CAPTCHA test skips itself when CapSkip is not running. It solves its own CAPTCHA, posts the form and checks the answer. Keep five things in mind:
- The Timeout on the Fact is in milliseconds. It fails the test when the time is up, and it also cancels the test’s cancellation token.
- The token goes to RecaptchaAsync as the named cancellationToken argument, so that cancelling the test stops the solve.
- The assertion on the body comes before the one on the status. When the site rejects the form, xUnit then shows the start of the site’s reply instead of only Forbidden. It cuts long strings at about 50 characters, so for a long HTML reply, write an assertion message of your own.
- Pick a Timeout above your slowest normal solve and below the SDK’s own limit of 300 seconds.
- Change welcome to text your success page shows, and change the /signup path and the username in the helper to match your form. A fixed username collides on the second nightly run, so use a new one each time.
// SignupTests.cs
using System.Net;
public class SignupTests(CapSkipFixture capskip)
{
[Fact(Timeout = 120_000)] // milliseconds; also cancels the test's token
public async Task Signup_with_a_solved_captcha_is_accepted()
{
Assert.SkipUnless(capskip.IsUp, "CapSkip is not running");
var ct = TestContext.Current.CancellationToken;
using var site = new SignupSite(capskip.SiteUrl);
var page = await site.OpenAsync(ct);
var solved = await capskip.Solver.RecaptchaAsync(
page.SiteKey, page.Url, cancellationToken: ct);
var (status, body) = await site.SubmitAsync(page, solved.Code, ct);
Assert.Contains("welcome", body); // on failure xUnit prints the site's reason
Assert.Equal(HttpStatusCode.OK, status);
}
}Step 5: run it
Set the variables for the session and run dotnet test from the project folder. Variables you leave out fall back to the defaults. A passing xUnit CAPTCHA run ends with a summary that reads Passed!, with total 1, failed 0 and succeeded 1.
# PowerShell. The fixture reads these; unset ones fall back to the defaults. $env:CAPSKIP_HOST = "127.0.0.1" # a Server mode machine: its LAN IP $env:CAPSKIP_PORT = "8080" $env:CAPSKIP_API_KEY = "capskip" $env:STAGING_URL = "https://staging.example.com" dotnet test
Solve inside every test
Do not solve once in a fixture and share the token. A reCAPTCHA token can be used once. In testing, a fixture solved once and two tests posted the same token: the first was accepted and the second was rejected by the site. Which test loses depends on the run order, so the failure moves around and looks flaky. A token also expires after about two minutes, so a late test fails even if it runs alone. The guide to reCAPTCHA token expiration explains that side of it.
One solve per test costs a few seconds each. If a test does not need the CAPTCHA itself, give it a different path, such as the fake client further down.
Keep parallel solves under CapSkip’s limit
xUnit runs test classes side by side. The default is one thread per logical core, and the test PC has 24. Tests inside one class run one after another. That means a suite with many solving classes starts many solves at once. CapSkip’s reCAPTCHA Max. Threads setting defaults to 10. Extra jobs wait for a free thread, and a job that waits past the Wait Timeout fails. Here is what the thread settings did to six classes that each solved once, with the mock holding every solve for three seconds.
| Setting | Run time | Solves at once |
|---|---|---|
| Default | 4.4 s | 6 |
| maxParallelThreads 3 | 8.1 s | 3 |
| maxParallelThreads 2 | 11.9 s | 2 |
| Parallelization off | 23.3 s | 1 |
With fourteen classes against the mock’s limit of 10, ten solved and four waited inside the mock for three seconds each. The simplest cap is a number in xunit.runner.json. The template already created that file.
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"maxParallelThreads": 8
}The other way is a gate in the fixture, so that only a few solves run at once whatever the thread count. Save the block below next to CapSkipFixture.cs. Then make the test call the gate. Replace the RecaptchaAsync call with: var code = await capskip.SolveAsync(page.SiteKey, page.Url, ct); and post code instead of solved.Code. The gate has to live in the assembly fixture, because a gate in a class fixture limits nothing: every class gets its own. With the gate set to 8, fourteen solves peaked at 8 in flight.
// CapSkipFixture.Gate.cs
using CapSkip;
public sealed partial class CapSkipFixture
{
// One gate for the whole run (that is why it lives in the assembly
// fixture): at most 8 solves in flight, under CapSkip's Max. Threads.
private readonly SemaphoreSlim _gate = new(8);
public async Task<string> SolveAsync(string sitekey, string url, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try { return (await Solver.RecaptchaAsync(sitekey, url, cancellationToken: ct)).Code; }
finally { _gate.Release(); }
}
}With fourteen classes, a runner cap of 8, a gate of 8 and no cap at all took about the same time: between 8 and 9 seconds. The difference is where the waiting happens. With a cap, it happens in your test process. Without one, it happens inside CapSkip, where it counts against the Wait Timeout. The xUnit parallelism docs describe the rest.
To serialise only the solving tests, put their classes in one named collection. Add the attribute below above the class in SignupTests.cs and leave the class body as it is. Classes in the same collection run one after another, and other collections still run alongside them. In testing, six solving classes plus six classes doing three seconds of other work took 23.3 seconds, and the six others overlapped.
// SignupTests.cs (only the first line changes)
// Classes in the same collection run one after another; other
// collections still run in parallel with them.
[Collection("solver")]
public class SignupTests(CapSkipFixture capskip)
{
// ... the same tests ...
}One trap from older xUnit habits. The attribute settings DisableTestParallelization and MaxParallelThreads on CollectionBehavior are now errors in 4.0.2. They fail with CS0619 and point to the Parallelization attribute. Use that attribute from Xunit.v3 instead, as in [assembly: Parallelization(MaxThreads = 3)], or set the cap in xunit.runner.json.
Make a timeout stop the solve
A Fact with a Timeout of 5000 against a solve that takes 20 seconds failed at 5.0 seconds with: Test execution timed out after 5000 milliseconds. The whole run ended about half a second later. Because the SDK got the test’s token, the cancelled token stopped the call. While the SDK is polling for the answer, the exception is a TaskCanceledException. While it is submitting, the exception is a CapSkip NetworkException with the message The operation was canceled.
Sometimes xUnit then reports a second failure for the same test, a cleanup failure that carries the cancellation exception. A guard around the solve call removes it. Add the line using CapSkip; to the top of the test file. Keep the token and the page lookup above the try, and put the solve, the submit and both assertions inside it. The guard swallows the exception only when the test’s token was cancelled, because the timeout has already failed the test with its own message.
// SignupTests.cs (snippet: wrap the body of the test)
// When Timeout fires, the cancelled token makes the SDK throw: a
// TaskCanceledException while polling, CapSkip.NetworkException while
// submitting. Without this guard xUnit may report it as a second failure.
try
{
var solved = await capskip.Solver.RecaptchaAsync(
page.SiteKey, page.Url, cancellationToken: ct);
// ... submit and assert ...
}
catch (Exception e) when (ct.IsCancellationRequested
&& e is OperationCanceledException or CapSkipError)
{
// The Timeout already failed this test with its own message.
}If you do not pass the token, the test still fails at 5.0 seconds, but the solver keeps polling in the background. A test that never touches its token can even post the form after it was reported as failed. The xUnit1051 analyzer warns about exactly this. It fires on a RecaptchaAsync call with no token, as well as on HttpClient calls without one.
The run-wide timeout switch of dotnet test is just as cooperative. It did not end a test that ignores its token. For a run that hangs, use a hang dump. The first block below is for the new runner, which is what this guide uses. The second is for the VSTest route.
# A hung run. MTP: add the extension once, then ask for a mini dump. dotnet add package Microsoft.Testing.Extensions.HangDump dotnet test --hangdump --hangdump-timeout 10m --hangdump-type Mini # VSTest route: built in. The default dump type is Full (about 280 MB here). # Do not use "none": the run ends but the hung test process stays alive. dotnet test --blame-hang-timeout 10m --blame-hang-dump-type mini
With a hang timeout of 15 seconds, the new runner ended the run at 16.2 seconds. It wrote a 2.7 MB mini dump and left no process behind. On the VSTest route, the dump type none ends the run but leaves the hung test process alive.
The SDK has its own limit, recaptchaTimeout, which is 300 seconds. It is only checked between polls. A limit of 8 seconds fired after 12.9 seconds in testing, and the default fired after 5 minutes and 3 seconds. Keep your Timeout well below it.
Skip the test when CapSkip is not running
The test in Step 4 calls Assert.SkipUnless with the fixture’s IsUp flag. With CapSkip closed, it reports a skipped test with the reason and the run exits 0. When every test is skipped, the runner prints Zero tests ran. In a CI job that must have CapSkip, add the fail-skips switch with the value on. A skipped test then fails the run with exit code 2.
Without a guard, a closed CapSkip fails the test with a CapSkip NetworkException: No connection could be made because the target machine actively refused it. That took about 2.1 seconds on Windows. An attribute option, SkipExceptions with the NetworkException type, turns that exception into a skip. It also hides a CapSkip that stops halfway through a run, so the probe is safer.
The probe can hide a typo too. CAPSKIP_HOST is a bare host name or address. A value such as http://127.0.0.1 makes the probe fail, so the tests are skipped rather than failed. Without the guard, the SDK reports: No such host is known. (http:80), and it can take several seconds to say so.
Retry when a solve fails
xUnit has no retry attribute. 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 exception carries that text, and the filter below never matches. This helper retries only that failure, and each attempt takes its own place at the gate. Save it next to the gate block, because it calls the gate’s SolveAsync.
// CapSkipFixture.Retry.cs
using CapSkip;
public sealed partial class CapSkipFixture
{
// xUnit has no retry attribute. Retry only "could not solve" and give
// up after `attempts`; every attempt takes its own place at the gate.
public async Task<string> SolveWithRetryAsync(
string sitekey, string url, CancellationToken ct, int attempts = 3)
{
for (var attempt = 1; ; attempt++)
{
try { return await SolveAsync(sitekey, url, ct); }
catch (ApiException e) when (attempt < attempts
&& e.Message.Contains("UNSOLVABLE")) { }
}
}
}With a mock that failed its first two jobs, a helper that allowed three attempts passed in 5.8 seconds. A second run that allowed two attempts failed with CapSkip.ApiException: ERROR_CAPTCHA_UNSOLVABLE after 4.0 seconds. See ERROR_CAPTCHA_UNSOLVABLE for what the error means.
Fix the TimeoutException name clash
A test project has implicit usings on, so System is always in scope. With using CapSkip as well, a catch clause for TimeoutException fails to compile: error CS0104: TimeoutException is an ambiguous reference between CapSkip.TimeoutException and System.TimeoutException. Any of these fixes works, and the alias also works inside Assert.ThrowsAsync.
// Unambiguous.cs
using CapSkip;
using TimeoutException = CapSkip.TimeoutException; // the alias picks CapSkip's
public class Unambiguous
{
public async Task Solve(CapSkipClient solver, string sitekey, string url)
{
try { await solver.RecaptchaAsync(sitekey, url); }
catch (TimeoutException) { } // CapSkip.TimeoutException
catch (System.TimeoutException) { } // or the BCL one, spelled out
catch (CapSkipError) { } // or the base of all four
}
}ValidationException clashes only if you add using System.ComponentModel.DataAnnotations, which a plain test project does not have.
Unit tests that need no solver
Tests of your own code that calls the solver do not need CapSkip. The SDK’s ApiClient is public, its two request methods are virtual, and the client’s ApiClient property can be set. This test passes with CapSkip closed.
// FakeApiTests.cs
using CapSkip;
// ApiClient is public and InAsync/ResAsync are virtual: swap it out and
// the solver never touches the network.
public sealed class FakeApi() : ApiClient("fake", 0)
{
public override Task<string> InAsync(
IDictionary<string, object?> options, CancellationToken cancellationToken = default) =>
Task.FromResult("OK|1");
public override Task<string> ResAsync(
IDictionary<string, object?> query, CancellationToken cancellationToken = default) =>
Task.FromResult("OK|fake-token");
}
public class FakeApiTests
{
[Fact]
public async Task Code_comes_from_the_fake()
{
var solver = new CapSkipClient { ApiClient = new FakeApi() };
var solved = await solver.RecaptchaAsync("key", "https://example.test/",
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("fake-token", solved.Code);
}
}Switches worth knowing
These are the dotnet test switches that mattered while testing a solver suite on the new runner. Passing tests show no output by default, and the diagnostics switch writes its messages to a file instead of the console.
# Command-line switches that matter for a solver suite (MTP mode). dotnet test --max-threads 4 # parallel test collections dotnet test --parallel none # one test at a time dotnet test --fail-skips on # CI: a skipped test fails dotnet test --filter-method "*Signup_with*" # one test, wildcard at the ends dotnet test --output Detailed # show passing tests' output dotnet test --timeout 20m # whole run; only cooperative dotnet test --xunit-diagnostics on --diagnostic # diagnostics to a .diag file dotnet test --report-xunit-trx # a .trx file for your CI
Running the tests against CapSkip on another machine
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. Then set CAPSKIP_HOST and CAPSKIP_PORT, and the same xUnit CAPTCHA tests run unchanged. In testing, the suite passed with CAPSKIP_HOST set to the test PC’s LAN address and the mock listening on all interfaces.
Mind two things. The SDK talks plain HTTP to CapSkip, with no TLS option, 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 solver is still your own Windows machine, and solves are still unmetered.
The whole test file
Here is the fixture, the probe, the helper and the test in one file, for a quick start. If you skipped Steps 2 to 4, this one file replaces them. If you already saved those files, delete them first. The fixture class is partial, so the gate and retry blocks still fit. Save it as SignupTests.cs.
// SignupTests.cs: Steps 2 to 4 in one file
using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using CapSkip;
// One CapSkipFixture for the whole run, injected into every test class.
[assembly: AssemblyFixture(typeof(CapSkipFixture))]
public sealed partial class CapSkipFixture : IAsyncLifetime
{
// The SDK never reads CAPSKIP_* itself, so read them here.
private static string Env(string name, string fallback) =>
Environment.GetEnvironmentVariable(name) ?? fallback;
private readonly string _host = Env("CAPSKIP_HOST", "127.0.0.1");
private readonly int _port = int.Parse(Env("CAPSKIP_PORT", "8080"));
public CapSkipClient Solver { get; }
public bool IsUp { get; private set; }
public string SiteUrl { get; } = Env("STAGING_URL", "https://staging.example.com");
public CapSkipFixture() => Solver = new CapSkipClient(
apiKey: Env("CAPSKIP_API_KEY", "capskip"), host: _host, port: _port);
// A TCP connect asks "is anything listening?" and sends no solve job.
public async ValueTask InitializeAsync()
{
using var tcp = new TcpClient();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1));
try
{
await tcp.ConnectAsync(_host, _port, timeout.Token);
IsUp = true;
}
catch (Exception e) when (e is SocketException or OperationCanceledException) { }
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
public sealed record SignupPage(string Url, string SiteKey, string Csrf);
public sealed class SignupSite(string baseUrl) : IDisposable
{
// One HttpClient per test: it keeps this test's cookies and no
// other test's.
private readonly HttpClient _http = new();
public async Task<SignupPage> OpenAsync(CancellationToken ct)
{
var url = baseUrl + "/signup";
var html = await _http.GetStringAsync(url, ct);
return new SignupPage(url,
Regex.Match(html, "data-sitekey=\"([^\"]+)\"").Groups[1].Value,
Regex.Match(html, "name=\"csrfmiddlewaretoken\" value=\"([^\"]+)\"").Groups[1].Value);
}
public async Task<(HttpStatusCode Status, string Body)> SubmitAsync(
SignupPage page, string token, CancellationToken ct)
{
using var request = new HttpRequestMessage(HttpMethod.Post, page.Url)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["csrfmiddlewaretoken"] = page.Csrf,
["username"] = "ci-user",
["g-recaptcha-response"] = token,
}),
};
request.Headers.Referrer = new Uri(page.Url); // HttpClient sends none
using var response = await _http.SendAsync(request, ct);
return (response.StatusCode, await response.Content.ReadAsStringAsync(ct));
}
public void Dispose() => _http.Dispose();
}
public class SignupTests(CapSkipFixture capskip)
{
[Fact(Timeout = 120_000)] // milliseconds; also cancels the test's token
public async Task Signup_with_a_solved_captcha_is_accepted()
{
Assert.SkipUnless(capskip.IsUp, "CapSkip is not running");
var ct = TestContext.Current.CancellationToken;
using var site = new SignupSite(capskip.SiteUrl);
var page = await site.OpenAsync(ct);
var solved = await capskip.Solver.RecaptchaAsync(
page.SiteKey, page.Url, cancellationToken: ct);
var (status, body) = await site.SubmitAsync(page, solved.Code, ct);
Assert.Contains("welcome", body); // on failure xUnit prints the site's reason
Assert.Equal(HttpStatusCode.OK, status);
}
}Common errors with xUnit CAPTCHA tests
| What you see | Cause | Fix |
|---|---|---|
| Testing with VSTest target is no longer supported | An xUnit v3 project with no global.json, on SDK 10 | Add the global.json and run from its folder |
| CS0104 on TimeoutException | System and CapSkip both define it | Use the alias, or catch CapSkipError |
| CS0619 on DisableTestParallelization | The option is obsolete in xUnit v3 | Use the Parallelization attribute or xunit.runner.json |
| Zero tests ran, with a Program.cs in the project | The project’s own entry point replaced xUnit’s | Delete the Program.cs |
| Test execution timed out after 5000 milliseconds | The solve took longer than the Timeout | Raise the Timeout, and pass the token to the SDK |
| Skipped: CapSkip is not running | Nothing listens on the host and port | Start CapSkip, check connection settings, use a bare host |
| Only one of several tests passes | Tests share one token or one cookie-keeping HttpClient | Solve and open the page inside each test |
| The second test fails with a CSRF mismatch | Parallel tests overwrote each other’s cookies | One HttpClient per test |
| Build error xUnit1049 | An async void test | Return Task or ValueTask |
| Warning xUnit1051 | A call that takes a token got none | Pass TestContext.Current.CancellationToken |
FAQ
Can a hosted CI agent run these tests?
A hosted agent runs on someone else’s machine, so it cannot reach 127.0.0.1 on your PC. Run CapSkip in Server mode on a Windows machine you control, point CAPSKIP_HOST at it, and keep the API key in the CI system’s secret store. GitHub-hosted and Azure DevOps hosted agents were not tested here. Only the LAN case was. The agent also needs to be allowed to reach that address and port.
Does this work with xUnit v2, NUnit or MSTest?
Only xUnit v3 4.0.2 was run for this guide. Several things above are v3 features, such as Assert.SkipUnless, TestContext.Current and the assembly fixture attribute. The CapSkip calls are the same in any framework. The fixture, timeout and parallelism settings are what change, so check your framework’s documentation for each of them.
Can I keep the old VSTest runner?
Yes. Use xunit.v3.mtp-off 4.0.2, xunit.runner.visualstudio 4.0.1 and Microsoft.NET.Test.Sdk 18.10.1 instead of the mtp-v2 package, and leave out the global.json. dotnet test then works as before. Skip reasons are not printed by the default console logger on that route, and the hang switch is named blame-hang-timeout.
Should every build run the solver tests?
Probably not. Each one takes seconds, needs a running CapSkip and touches a real staging site. Put them in their own test project, so that an ordinary dotnet test of your unit tests never waits for the solver. Run that project on a schedule or before a release.
The short version
An xUnit CAPTCHA suite needs one CapSkip client in an assembly fixture and a fresh solve inside each test. Pass the test’s token to the SDK, so the Fact Timeout can stop a solve. Cap parallel solves below CapSkip’s 10 threads with a runner setting or a gate. Use a global.json on SDK 10. Skip, do not fail, when CapSkip is closed, and use fail-skips in CI.
- Every CAPTCHA type the .NET package handles: the C# CAPTCHA solver page.
- The checkbox reCAPTCHA in a plain C# program: solving reCAPTCHA v2 in C#.
- How the checkbox solver works: the reCAPTCHA v2 solver page.
A nightly suite that solves a few hundred times adds up quickly. Running it through a local captcha bypass on your own Windows machine means those solves never turn into a bill.
