Как решать капчу в тестах xUnit с помощью CapSkip (C#)

Чтобы решать капчу в тестах xUnit, создайте один клиент CapSkip в фикстуре сборки и решайте внутри каждого теста, потому что токен годится только один раз. Передайте SDK токен отмены теста, чтобы таймаут останавливал решение. Держите число одновременных решений ниже лимита потоков CapSkip. CapSkip работает на вашем собственном ПК с Windows, поэтому набор тестов, который решает капчу сотни раз за ночь, не требует платы за каждое решение. У такой настройки есть собственные ловушки, связанные с xUnit v3 и .NET 10. Команде dotnet test нужен отдельный файл, который включает новое поведение. Тестовые классы по умолчанию запускаются параллельно. А таймаут работает, только если ваш код с ним сотрудничает: сам проверяет токен отмены и останавливается. В этом руководстве разобрано каждое из этих мест.
Что понадобится
- CapSkip, запущенный на ПК с Windows, с включённым решателем reCAPTCHA. Пошаговое руководство по настройке проведёт вас через первый запуск.
- SDK .NET 10. Тестовые проекты xUnit v3 нацелены на net8.0 или новее либо на .NET Framework 4.7.2. Всё ниже запускалось на Windows 11 с SDK 10.0.100, на net10.0 и ещё раз на net8.0, с xunit.v3.mtp-v2 4.0.2 и CapSkip 1.3.0 из NuGet. SDK 8 и 9 не тестировались.
- Ваш собственный staging-сайт с чекбоксом reCAPTCHA v2 в форме. Пример отправляет форму регистрации, в которой есть CSRF-токен. Поменяйте имена полей под свою форму.
- Адрес решателя. В режиме Local CapSkip отвечает на 127.0.0.1 и только для этого устройства. В режиме Server он слушает ваш сетевой адрес или публичный IP, чтобы тесты на другой машине могли обращаться к нему через API. Оба режима задаются в разделе Настройки подключения.
Замеры ниже получены на тестовой имитации CapSkip и тестовой странице регистрации на том же ПК, с настоящим xUnit и настоящим SDK. Поведение xUnit и поведение SDK верны и для вашего собственного сайта. Время решения, а также правила приёма и отклонения на странице регистрации относятся только к этой тестовой имитации.
Шаг 1: создаём тестовый проект
В .NET SDK нет шаблона xUnit v3, пока вы не установите пакет шаблонов. Это делает первая команда ниже. Затем шаблон создаёт проект, файл xunit.runner.json, файл global.json и пример теста под названием UnitTest1.cs. Блок удаляет этот пример, потому что он добавил бы ещё один тест в каждый запуск.
# 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
Оставьте global.json. В SDK 10 команда dotnet test отказывается работать с проектом xUnit v3 без него и останавливается с сообщением: Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. Файл переключает dotnet test на новый раннер, Microsoft.Testing.Platform, который комментарии в блоках ниже называют MTP. Шаблон кладёт этот файл в папку проекта или добавляет раздел test в уже существующий global.json выше по дереву папок. Запускайте dotnet test из папки проекта. Если указать ему проект из папки без этого файла, например dotnet test CapSkipTests, будет та же ошибка.
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}Шаг 2: один клиент CapSkip на весь запуск
Каждый блок с файлом C# дальше начинается с имени, под которым его нужно сохранить. Первый блок содержит фикстуру. Она читает настройки CapSkip из окружения, потому что SDK не читает их сам, и создаёт один клиент. Значения по умолчанию позволяют запустить её без настройки. CAPSKIP_API_KEY нужен, только если вы включили проверку API-ключа в 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;
}Фикстура также выясняет, запущен ли CapSkip. Обычное TCP-подключение спрашивает, слушает ли что-нибудь порт, и не отправляет задание на решение.
// 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;
}
}
}Зарегистрируйте фикстуру на уровне сборки. Тогда xUnit создаёт один экземпляр на весь запуск и передаёт его каждому тестовому классу через конструктор, как описано в документации xUnit по общему контексту.
// 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))]
Фикстура сборки лучше двух других вариантов. При шести тестовых классах фикстура класса создавалась шесть раз, по разу на класс. Фикстура коллекции создавалась один раз, но заставляет эти классы выполняться один за другим. Шесть классов, каждый из которых решал капчу по одному разу, с ней заняли больше 23 секунд, а с фикстурой сборки меньше 5 секунд. Сам клиент можно безопасно разделять между тестами. Он не хранит изменяемого состояния, все клиенты используют один и тот же статический HttpClient внутри SDK, и четырнадцать одновременных решений через один клиент отработали.
Шаг 3: небольшой вспомогательный класс для вашего staging-сайта
Эта часть ваша, а не часть CapSkip. Вспомогательный класс открывает страницу регистрации и читает из неё sitekey и CSRF-токен. Подстройте два шаблона под свою разметку.
// 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();
}Затем он отправляет форму с токеном в поле g-recaptcha-response. HttpClient не отправляет Referer при отправке формы, а строгий сайт вроде Django по HTTPS отклоняет такую отправку без него, поэтому вспомогательный класс задаёт его сам.
// 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));
}
}Каждый тест создаёт свой экземпляр вспомогательного класса, а значит, и собственный HttpClient. Это сделано намеренно. При одном общем клиенте, хранящем cookies, и шести параллельных тестах сайт, который начинает новую сессию при каждом визите, принял одну отправку и отклонил пять из-за несовпадения CSRF. Тесты перезаписывали cookies друг друга.
Шаг 4: пишем тест
Тест капчи в xUnit сам пропускается, если CapSkip не запущен. Он решает собственную капчу, отправляет форму и проверяет ответ. Учтите пять моментов:
- Timeout у Fact задаётся в миллисекундах. Когда время выходит, тест падает, и одновременно отменяется токен отмены этого теста.
- Токен передаётся в RecaptchaAsync как именованный аргумент cancellationToken, чтобы отмена теста останавливала решение.
- Проверка тела ответа стоит раньше проверки статуса. Тогда, когда сайт отклоняет форму, xUnit показывает начало ответа сайта, а не только Forbidden. Длинные строки он обрезает примерно на 50 символах, поэтому для длинного HTML-ответа напишите собственное сообщение для проверки.
- Выберите Timeout выше самого медленного обычного решения и ниже собственного лимита SDK в 300 секунд.
- Замените welcome на текст, который показывает ваша страница успеха, а путь /signup и имя пользователя во вспомогательном классе поменяйте под свою форму. Фиксированное имя пользователя конфликтует уже при втором ночном запуске, поэтому каждый раз используйте новое.
// 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);
}
}Шаг 5: запускаем
Задайте переменные для текущего сеанса и запустите dotnet test из папки проекта. Переменные, которые вы не задали, принимают значения по умолчанию. Успешный запуск тестов капчи в xUnit заканчивается итоговой строкой Passed! с total 1, failed 0 и 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
Решайте внутри каждого теста
Не решайте капчу один раз в фикстуре и не делите токен между тестами. Токен reCAPTCHA можно использовать один раз. В тесте фикстура решила капчу один раз, а два теста отправили один и тот же токен: первый приняли, а второй сайт отклонил. Какой тест проиграет, зависит от порядка запуска, поэтому сбой кочует между тестами и выглядит плавающим. Кроме того, токен истекает примерно через две минуты, поэтому запоздавший тест падает, даже если запущен один. Подробное руководство по сроку действия токена reCAPTCHA объясняет эту сторону вопроса.
Одно решение на тест занимает по нескольку секунд. Если самой капчи тесту не нужно, дайте ему другой путь, например поддельный клиент, показанный ниже.
Держите параллельные решения в пределах лимита CapSkip
xUnit запускает тестовые классы одновременно. По умолчанию используется один поток на логическое ядро, а на тестовом ПК их 24. Тесты внутри одного класса выполняются один за другим. Это значит, что набор со множеством классов, которые решают капчу, запускает множество решений одновременно. Настройка Max. Threads для reCAPTCHA в CapSkip по умолчанию равна 10. Лишние задания ждут свободного потока, а задание, которое ждёт дольше Wait Timeout, завершается ошибкой. Вот как настройки потоков повлияли на шесть классов, каждый из которых решал по одному разу, при том что тестовая имитация удерживала каждое решение три секунды.
| Настройка | Время прогона | Решений одновременно |
|---|---|---|
| По умолчанию | 4.4 с | 6 |
| maxParallelThreads 3 | 8.1 с | 3 |
| maxParallelThreads 2 | 11.9 с | 2 |
| Parallelization: выкл. | 23.3 с | 1 |
При четырнадцати классах и лимите тестовой имитации в 10 десять решений шли сразу, а четыре ждали внутри имитации по три секунды. Проще всего ограничить параллелизм числом в xunit.runner.json. Шаблон уже создал этот файл.
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"maxParallelThreads": 8
}Другой способ: шлюз в фикстуре, чтобы одновременно выполнялось лишь несколько решений при любом числе потоков. Сохраните блок ниже рядом с CapSkipFixture.cs. Затем сделайте так, чтобы тест вызывал шлюз. Замените вызов RecaptchaAsync на: var code = await capskip.SolveAsync(page.SiteKey, page.Url, ct); и отправляйте code вместо solved.Code. Шлюз должен находиться в фикстуре сборки, потому что шлюз в фикстуре класса ничего не ограничивает: у каждого класса был бы свой. При шлюзе на 8 четырнадцать решений достигли пика в 8 одновременных.
// 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(); }
}
}При четырнадцати классах ограничение раннера на 8, шлюз на 8 и полное отсутствие ограничения заняли примерно одно и то же время: от 8 до 9 секунд. Разница в том, где происходит ожидание. С ограничением оно происходит в вашем тестовом процессе. Без него оно происходит внутри CapSkip, где засчитывается в Wait Timeout. Остальное описано в документации xUnit по параллельному запуску тестов.
Чтобы выполнять последовательно только тесты с решением, поместите их классы в одну именованную коллекцию. Добавьте атрибут ниже над классом в SignupTests.cs, а тело класса оставьте как есть. Классы из одной коллекции выполняются один за другим, а остальные коллекции по-прежнему выполняются рядом с ними. В тесте шесть классов с решением и ещё шесть классов, выполнявших три секунды другой работы, заняли 23.3 секунды, причём остальные шесть выполнялись одновременно.
// 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 ...
}Ещё одна ловушка для тех, кто привык к старому xUnit. Параметры атрибута DisableTestParallelization и MaxParallelThreads у CollectionBehavior теперь считаются ошибками в версии 4.0.2. Они завершаются ошибкой CS0619 и указывают на атрибут Parallelization. Используйте вместо них этот атрибут из Xunit.v3, как в [assembly: Parallelization(MaxThreads = 3)], или задайте ограничение в xunit.runner.json.
Как сделать, чтобы таймаут останавливал решение
Fact с Timeout 5000 при решении, которое занимает 20 секунд, упал на 5.0 секунды с сообщением: Test execution timed out after 5000 milliseconds. Весь запуск завершился примерно через полсекунды после этого. Поскольку SDK получил токен теста, отменённый токен остановил вызов. Пока SDK опрашивает результат, исключением будет TaskCanceledException. Пока он отправляет задание, исключением будет NetworkException из CapSkip с сообщением The operation was canceled.
Иногда xUnit после этого сообщает о втором сбое для того же теста, сбое очистки, который несёт исключение отмены. Защитный блок вокруг вызова решения убирает его. Добавьте строку using CapSkip; в начало файла с тестом. Токен и получение страницы оставьте выше try, а решение, отправку и обе проверки поместите внутрь. Защитный блок проглатывает исключение, только когда токен теста был отменён, потому что таймаут уже провалил тест с собственным сообщением.
// 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.
}Если не передавать токен, тест всё равно упадёт на 5.0 секунды, но решатель продолжит опрос в фоне. Тест, который вообще не трогает свой токен, может даже отправить форму уже после того, как его объявили упавшим. Именно об этом предупреждает анализатор xUnit1051. Он срабатывает на вызове RecaptchaAsync без токена, а также на вызовах HttpClient без него.
Параметр dotnet test с таймаутом для всего запуска тоже кооперативный: он не прервал тест, который игнорирует свой токен. Для запуска, который зависает, используйте дамп зависания. Первый блок ниже предназначен для нового раннера, который и использует это руководство. Второй относится к варианту с VSTest.
# 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
При таймауте зависания в 15 секунд новый раннер завершил запуск на 16.2 секунды. Он записал мини-дамп размером 2.7 МБ и не оставил после себя ни одного процесса. В варианте с VSTest тип дампа none завершает запуск, но оставляет зависший тестовый процесс живым.
У SDK есть собственный лимит recaptchaTimeout, по умолчанию 300 секунд. Его проверяют только между опросами. Лимит в 8 секунд в тесте сработал через 12.9 секунды, а значение по умолчанию сработало через 5 минут 3 секунды. Держите свой Timeout заметно ниже него.
Пропускайте тест, когда CapSkip не запущен
Тест из шага 4 вызывает Assert.SkipUnless с флагом IsUp из фикстуры. Если CapSkip закрыт, он сообщает о пропущенном тесте с указанием причины, а запуск завершается с кодом 0. Когда пропущены все тесты, раннер выводит Zero tests ran. В задании CI, где CapSkip обязателен, добавьте параметр fail-skips со значением on. Тогда пропущенный тест роняет запуск с кодом выхода 2.
Без проверки на пропуск закрытый CapSkip роняет тест с NetworkException из CapSkip: No connection could be made because the target machine actively refused it. В Windows это заняло около 2.1 секунды. Параметр атрибута SkipExceptions с типом NetworkException превращает это исключение в пропуск. Но он же скрывает и случай, когда CapSkip останавливается посреди запуска, поэтому проверочное подключение надёжнее.
Проверочное подключение может скрыть и опечатку. В CAPSKIP_HOST нужно указывать только имя хоста или адрес, без http:// впереди. Значение вроде http://127.0.0.1 приводит к тому, что проверочное подключение не проходит, поэтому тесты пропускаются, а не падают. Без проверки на пропуск SDK сообщает: No such host is known. (http:80), и на это может уйти несколько секунд.
Повтор при неудачном решении
В xUnit нет атрибута повтора. Некоторые решения не удаются, а новая попытка может сработать. SDK выбрасывает ApiException с сообщением ERROR_CAPTCHA_UNSOLVABLE, если только вы не задали в CapSkip текст Error Replacement. Тогда исключение несёт этот текст, и фильтр ниже никогда не срабатывает. Этот вспомогательный метод повторяет только такой сбой, и каждая попытка занимает собственное место у шлюза. Сохраните его рядом с блоком шлюза, потому что он вызывает метод 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")) { }
}
}
}В случае тестовой имитации, у которой не удались первые два задания, тест, где вспомогательный метод допускал три попытки, прошёл за 5.8 секунды. Во втором запуске, где допускались две попытки, тест завершился ошибкой CapSkip.ApiException: ERROR_CAPTCHA_UNSOLVABLE через 4.0 секунды. О значении ошибки ERROR_CAPTCHA_UNSOLVABLE рассказано на отдельной странице.
Как устранить конфликт имён TimeoutException
В тестовом проекте включены неявные using, поэтому System всегда доступен. Если добавить ещё и using CapSkip, блок catch для TimeoutException не компилируется: error CS0104: TimeoutException is an ambiguous reference between CapSkip.TimeoutException and System.TimeoutException. Подойдёт любое из этих исправлений, а псевдоним работает и внутри 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 конфликтует, только если добавить using System.ComponentModel.DataAnnotations, а в обычном тестовом проекте его нет.
Модульные тесты, которым не нужен решатель
Тестам вашего собственного кода, который вызывает решатель, CapSkip не нужен. ApiClient из SDK публичный, два его метода запросов виртуальные, а свойство ApiClient у клиента можно задать. Этот тест проходит при закрытом CapSkip.
// 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);
}
}Полезные параметры командной строки
Вот параметры dotnet test, которые имели значение при тестировании набора тестов с решателем на новом раннере. Успешные тесты по умолчанию ничего не выводят, а параметр диагностики записывает свои сообщения в файл, а не в консоль.
# 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
Запуск тестов с CapSkip на другой машине
Тесты и решатель не обязаны работать на одном ПК. Переключите CapSkip в режим Server в настройках подключения, и он будет слушать ваш сетевой адрес или публичный IP. Затем задайте CAPSKIP_HOST и CAPSKIP_PORT, и те же тесты капчи в xUnit запустятся без изменений. В тесте набор прошёл при CAPSKIP_HOST, равном адресу тестового ПК в локальной сети, и тестовой имитации, слушающей все интерфейсы.
Учтите два момента. SDK обращается к CapSkip по обычному HTTP, без опции TLS, поэтому API-ключ передаётся открытым текстом. Держите это соединение в частной сети или внутри VPN и включите проверку API-ключа. И при запуске режима Server CapSkip открывает свой порт для всех адресов. Чтобы доступ был только у ожидаемых машин, добавьте в Windows Firewall правило, блокирующее все остальные. Решателем по-прежнему остаётся ваша собственная машина с Windows, и решения по-прежнему не тарифицируются.
Весь тестовый файл
Вот фикстура, проверочное подключение, вспомогательный класс и тест в одном файле, для быстрого старта. Если вы пропустили шаги со 2 по 4, этот один файл заменяет их. Если вы уже сохранили те файлы, сначала удалите их. Класс фикстуры объявлен как partial, поэтому блоки шлюза и повтора по-прежнему подходят. Сохраните его как 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);
}
}Частые ошибки при тестах капчи в xUnit
| Что вы видите | Причина | Исправить |
|---|---|---|
| Testing with VSTest target is no longer supported | Проект xUnit v3 без global.json на SDK 10 | Добавьте global.json и запускайте из его папки |
| CS0104 на TimeoutException | Его определяют и System, и CapSkip | Используйте псевдоним или перехватывайте CapSkipError |
| CS0619 на DisableTestParallelization | Этот параметр устарел в xUnit v3 | Используйте атрибут Parallelization или xunit.runner.json |
| Zero tests ran при наличии Program.cs в проекте | Собственная точка входа проекта заменила точку входа xUnit | Удалите Program.cs |
| Test execution timed out after 5000 milliseconds | Решение заняло больше времени, чем Timeout | Увеличьте Timeout и передайте токен в SDK |
| Skipped: CapSkip is not running | По этому хосту и порту ничего не слушает | Запустите CapSkip, проверьте настройки подключения, указывайте хост без http:// |
| Проходит только один из нескольких тестов | Тесты делят один токен или один HttpClient, хранящий cookies | Решайте и открывайте страницу внутри каждого теста |
| Второй тест падает с несовпадением CSRF | Параллельные тесты перезаписали cookies друг друга | Один HttpClient на каждый тест |
| Ошибка сборки xUnit1049 | Тест async void | Возвращайте Task или ValueTask |
| Предупреждение xUnit1051 | Вызов, который принимает токен, остался без него | Передайте TestContext.Current.CancellationToken |
FAQ
Может ли хостинговый CI-агент запускать эти тесты?
Хостинговый агент работает на чужой машине, поэтому не может обратиться к 127.0.0.1 на вашем ПК. Запустите CapSkip в режиме Server на машине с Windows, которой вы управляете, направьте на неё CAPSKIP_HOST и храните API-ключ в хранилище секретов системы CI. Агенты, размещённые в GitHub и в Azure DevOps, здесь не тестировались. Проверялся только случай локальной сети. Агенту также должно быть разрешено подключаться к этому адресу и порту.
Работает ли это с xUnit v2, NUnit или MSTest?
Для этого руководства запускался только xUnit v3 4.0.2. Несколько вещей выше относятся к возможностям v3, например Assert.SkipUnless, TestContext.Current и атрибут фикстуры сборки. Вызовы CapSkip одинаковы в любом фреймворке. Меняются настройки фикстур, таймаута и параллелизма, поэтому для каждой из них сверяйтесь с документацией вашего фреймворка.
Можно ли оставить старый раннер VSTest?
Да. Используйте xunit.v3.mtp-off 4.0.2, xunit.runner.visualstudio 4.0.1 и Microsoft.NET.Test.Sdk 18.10.1 вместо пакета mtp-v2 и не добавляйте global.json. Тогда dotnet test работает, как раньше. В этом варианте стандартный консольный логгер не выводит причины пропуска, а параметр для зависания называется blame-hang-timeout.
Нужно ли запускать тесты с решателем в каждой сборке?
Скорее всего, нет. Каждый такой тест занимает секунды, требует запущенного CapSkip и обращается к настоящему staging-сайту. Вынесите их в отдельный тестовый проект, чтобы обычный dotnet test ваших модульных тестов никогда не ждал решатель. Запускайте этот проект по расписанию или перед выпуском.
Коротко
Набору тестов капчи в xUnit нужен один клиент CapSkip в фикстуре сборки и новое решение внутри каждого теста. Передавайте SDK токен теста, чтобы Timeout у Fact мог остановить решение. Ограничьте параллельные решения числом ниже 10 потоков CapSkip настройкой раннера или шлюзом. На SDK 10 используйте global.json. Когда CapSkip закрыт, пропускайте тесты, а не роняйте, и применяйте fail-skips в CI.
- Все типы капчи, которые поддерживает пакет для .NET, перечислены на странице сервиса распознавания капчи для C#.
- reCAPTCHA с чекбоксом в обычной программе на C# описана в руководстве по решению reCAPTCHA v2 на C#.
- Как работает решатель reCAPTCHA с чекбоксом, описано на странице сервиса распознавания reCAPTCHA v2.
Ночной набор тестов, который несколько сотен раз решает капчу, быстро накапливает много решений. Когда локальный обход капчи выполняется на вашей собственной машине с Windows, эти решения никогда не превращаются в счёт.
