如何用 CapSkip 在 xUnit 测试中识别验证码(C#)

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

要在 xUnit 测试中识别验证码,就在程序集 fixture 中构建一个 CapSkip 客户端,并在每个测试内部各自识别,因为一个 token 只能用一次。把测试的取消令牌传给 SDK,这样超时就能停止识别。让同时进行的识别数量保持在 CapSkip 的线程上限之内。CapSkip 运行在你自己的 Windows PC 上,所以一个每晚识别几百次的测试套件没有按次识别的费用。这套 xUnit 验证码方案在 xUnit v3 和 .NET 10 上有它自己的陷阱。dotnet test 命令需要你添加一个文件来启用新行为。测试类默认并行运行。而且超时只有在你的代码配合时才会生效。本指南会逐一讲解这些问题。

你需要什么

  • 在 Windows PC 上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。 设置指南 会带你完成首次启动的每一步。
  • 已安装 .NET 10 SDK。xUnit v3 的测试项目以 net8.0 或更高版本为目标,也可以以 .NET Framework 4.7.2 为目标。下面的所有内容都是在 Windows 11 上用 SDK 10.0.100 运行的,先在 net10.0 上,之后又在 net8.0 上,使用 NuGet 上的 xunit.v3.mtp-v2 4.0.2 和 CapSkip 1.3.0。SDK 8 和 9 没有测试过。
  • 一个你自己拥有的预发布站点,表单里带有 reCAPTCHA v2 复选框。示例提交的是一个带有 CSRF token 的注册表单。请把字段名改成与你的一致。
  • 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的测试就能通过 API 调用它。两种模式的设置位置都是 连接设置.

下面的测量数据来自同一台 PC 上的一个模拟 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# 文件代码块的第一行都是它应保存成的文件名。第一个是 fixture。它从环境变量中读取 CapSkip 的设置(因为 SDK 自己不会读取),并构建一个客户端。有了这些默认值,它不需要任何配置就能运行。只有当你在 CapSkip 中开启了 API key validation 时,CAPSKIP_API_KEY 才有意义。

// 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;
}

这个 fixture 还会查明 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;
        }
    }
}

在程序集上注册这个 fixture。这样 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))]

程序集 fixture 比另外两种方式更好。有六个测试类时,类 fixture 被构建了六次,每个类一次。测试集合 fixture 只构建了一次,但它会强制这些类一个接一个地运行。六个类各识别一次,用测试集合 fixture 花了 23 秒以上,用程序集 fixture 则不到 5 秒。客户端本身可以放心共享。它不保存可变状态,每个客户端都使用 SDK 内部同一个静态 HttpClient,而且通过同一个客户端同时进行十四次识别也没有问题。

第 3 步:为你的预发布站点写一个小型辅助类

这部分代码是你自己的,不是 CapSkip 的。这个辅助类会打开注册页面,并从中读取 sitekey 和 CSRF token。请按你的标记调整这两个正则表达式。

// 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();
}

然后它把表单连同 token 一起提交,token 放在 g-recaptcha-response 字段里。HttpClient 在提交表单时不发送 Referer,而 Django 这类严格的站点会拒绝没有 Referer 的 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。这是有意为之的。如果共用一个保存 cookie 的客户端,并行跑六个测试,一个每次访问都会开启新会话的站点只接受了一次提交,其余五次都因 CSRF 不匹配而被拒绝。这些测试互相覆盖了对方的 cookie。

第 4 步:编写测试

这个 xUnit 验证码测试在 CapSkip 没有运行时会自行跳过。它识别自己的验证码,提交表单并检查返回结果。请记住五件事:

  • Fact 上的 Timeout 以毫秒为单位。时间一到,测试就会失败,同时测试的取消令牌也会被取消。
  • 取消令牌作为具名参数 cancellationToken 传给 RecaptchaAsync,这样取消测试就能停止识别。
  • 对响应正文的断言要放在对状态码的断言之前。这样当站点拒绝表单时,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

在每个测试内部识别

不要在 fixture 里识别一次,然后共享这个 token。reCAPTCHA token 只能使用一次。测试中,一个 fixture 识别了一次,两个测试提交了同一个 token:第一个被接受,第二个被站点拒绝。哪个测试会输,取决于运行顺序,所以失败的位置会变来变去,看起来时好时坏。token 大约两分钟后还会过期,所以较晚执行的测试即使单独运行也会失败。 reCAPTCHA token 过期指南 讲解了这方面的内容。

每个测试识别一次,各要花几秒。如果某个测试本身并不需要验证码,就给它走另一条路径,比如后面的模拟客户端。

让并行识别的数量保持在 CapSkip 的上限之内

xUnit 会同时运行多个测试类。默认是每个逻辑核心一个线程,而测试用的 PC 有 24 个核心。同一个类里的测试则一个接一个地运行。这意味着,一个包含许多识别类的测试套件会同时启动许多识别。CapSkip 的 reCAPTCHA Max. Threads 设置默认是 10。多出来的任务会等待空闲线程,等待时间超过 Wait Timeout 的任务会失败。下面是各种线程设置对六个类产生的影响:每个类各识别一次,模拟程序让每次识别都停留三秒。

设置运行时间同时识别数
默认值4.4 秒6
maxParallelThreads 38.1 秒3
maxParallelThreads 211.9 秒2
Parallelization 关闭23.3 秒1

当有十四个类,而模拟程序的上限是 10 时,十个类直接开始识别,另外四个在模拟程序内部各等了三秒。最简单的上限设置方式,就是在 xunit.runner.json 里写一个数字。模板已经创建了这个文件。

{
  "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
  "maxParallelThreads": 8
}

另一种方式是在 fixture 里设一个闸门(gate),这样不管线程数是多少,同时运行的识别都只有几个。把下面的代码块保存在 CapSkipFixture.cs 旁边。然后让测试调用这个闸门:把 RecaptchaAsync 调用替换为 var code = await capskip.SolveAsync(page.SiteKey, page.Url, ct); 然后提交 code,而不是 solved.Code。闸门必须放在程序集 fixture 里,因为放在类 fixture 里的闸门起不到任何限制作用:每个类都会有自己的一个。把闸门设为 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 习惯时会遇到的一个陷阱。CollectionBehavior 上的特性设置 DisableTestParallelization 和 MaxParallelThreads 在 4.0.2 中已经变成错误。它们会以 CS0619 失败,并指向 Parallelization 特性。请改用 Xunit.v3 中的那个特性,写法如 [assembly: Parallelization(MaxThreads = 3)],或者在 xunit.runner.json 里设置上限。

让超时真正停止识别

一个 Timeout 为 5000 的 Fact,面对需要 20 秒的识别,在 5.0 秒时失败,消息是:Test execution timed out after 5000 milliseconds。整个运行大约半秒之后结束。因为 SDK 拿到了测试的取消令牌,被取消的令牌就停止了这次调用。SDK 在轮询答案期间,异常是 TaskCanceledException。在它提交任务期间,异常则是消息为 The operation was canceled 的 CapSkip NetworkException。

有时 xUnit 随后会为同一个测试再报告一个失败,那是一个携带取消异常的清理阶段失败。在识别调用外面加一道保护就能消除它。把 using CapSkip; 这一行加到测试文件的顶部。取消令牌和获取页面的那一行留在 try 的上方,识别、提交以及两条断言都放进 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 针对整次运行的超时开关同样需要代码配合。它没能结束一个无视取消令牌的测试。对于挂起的运行,请使用挂起转储(hang dump)。下面第一个代码块适用于新运行器,也就是本指南所用的那个。第二个适用于 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 MB 的 mini 转储文件,也没有留下任何残余进程。在 VSTest 路线上,转储类型 none 会结束这次运行,但会让挂起的测试进程继续存活。

SDK 有它自己的上限 recaptchaTimeout,是 300 秒。它只在两次轮询之间检查。测试中,设为 8 秒的上限在 12.9 秒后才触发,而默认值在 5 分 3 秒之后才触发。请把你的 Timeout 设得远低于它。

CapSkip 没有运行时跳过测试

第 4 步里的测试会用 fixture 的 IsUp 标志调用 Assert.SkipUnless。CapSkip 关闭时,它会报告一个带有原因的已跳过测试,而且运行以退出码 0 结束。当所有测试都被跳过时,运行器会打印 Zero tests ran。在必须有 CapSkip 的 CI 任务中,请添加 fail-skips 开关并把值设为 on。这样,被跳过的测试就会让运行以退出码 2 失败。

没有这个跳过检查时,关闭的 CapSkip 会让测试以 CapSkip NetworkException 失败: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 会抛出消息为 ERROR_CAPTCHA_UNSOLVABLE 的 ApiException,除非你在 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 秒内通过。另一次只允许两次尝试的运行,则在 4.0 秒后以 CapSkip.ApiException: ERROR_CAPTCHA_UNSOLVABLE 失败。请参阅 ERROR_CAPTCHA_UNSOLVABLE 了解这个错误的含义。

解决 TimeoutException 的名称冲突

测试项目开启了隐式 using,所以 System 始终在作用域内。再加上 using CapSkip,针对 TimeoutException 的 catch 子句就无法编译: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
    }
}

只有当你加上 using System.ComponentModel.DataAnnotations 时,ValidationException 才会发生冲突,而普通的测试项目里并没有这一行。

不需要识别工具的单元测试

测试你自己那些调用识别工具的代码,并不需要 CapSkip。SDK 的 ApiClient 是公开的,它的两个请求方法是虚方法,而且客户端的 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

测试和识别工具不必共用一台 PC。在连接设置里把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP。然后设置 CAPSKIP_HOST 和 CAPSKIP_PORT,同样的 xUnit 验证码测试无需改动就能运行。测试中,把 CAPSKIP_HOST 设为测试 PC 的局域网地址、并让模拟程序监听所有网络接口时,整个套件都通过了。

有两点需要注意。SDK 与 CapSkip 之间使用明文 HTTP 通信,没有 TLS 选项,所以 API 密钥是以明文传输的。请把这条链路放在专用网络或 VPN 里,并开启 API key validation。另外,Server 模式启动时,CapSkip 会向所有地址开放端口。要想只让你预期的机器访问,就为其他地址添加一条 Windows Firewall 阻止规则。识别工具仍然是你自己的 Windows 机器,识别也仍然不计量。

完整的测试文件

下面把 fixture、探测、辅助类和测试放在一个文件里,方便快速上手。如果你跳过了第 2 步到第 4 步,这一个文件就能代替它们。如果你已经保存过那些文件,请先把它们删掉。fixture 类是 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在 SDK 10 上,xUnit v3 项目没有 global.json添加 global.json,并在它所在的文件夹里运行
TimeoutException 上出现 CS0104System 和 CapSkip 都定义了它使用别名,或者捕获 CapSkipError
DisableTestParallelization 上出现 CS0619这个选项在 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:// 的主机
几个测试里只有一个通过测试共用了同一个 token,或同一个保存 cookie 的 HttpClient在每个测试内部识别并打开页面
第二个测试因 CSRF 不匹配而失败并行的测试互相覆盖了对方的 cookie每个测试使用一个 HttpClient
构建错误 xUnit1049一个 async void 测试返回 Task 或 ValueTask
警告 xUnit1051本可接受取消令牌的调用没有传入令牌传入 TestContext.Current.CancellationToken

常见问题

托管的 CI agent 能运行这些测试吗?

托管的 agent 运行在别人的机器上,所以它访问不到你 PC 上的 127.0.0.1。请在一台你能控制的 Windows 机器上以 Server 模式运行 CapSkip,让 CAPSKIP_HOST 指向它,并把 API 密钥保存在 CI 系统的密钥存储中。这里没有测试 GitHub 托管的 agent 和 Azure DevOps 托管的 agent,只测试了局域网的情形。此外还需要允许 agent 访问那个地址和端口。

这套做法适用于 xUnit v2、NUnit 或 MSTest 吗?

本指南只运行了 xUnit v3 4.0.2。上面有几处是 v3 的功能,比如 Assert.SkipUnless、TestContext.Current 和程序集 fixture 特性。在任何框架里,CapSkip 的调用都是一样的。会变的是 fixture、超时和并行的设置,所以请对照你所用框架的文档逐项确认。

可以继续使用旧的 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,而且会访问真实的预发布站点。把它们放进单独的测试项目,这样对你的单元测试运行普通的 dotnet test 时,就永远不用等识别工具。按计划或在发布之前运行那个项目。

简短版结论

xUnit 验证码测试套件需要在程序集 fixture 里放一个 CapSkip 客户端,并在每个测试内部各做一次全新的识别。把测试的取消令牌传给 SDK,这样 Fact 的 Timeout 才能停止识别。用运行器设置或闸门,把并行识别的数量限制在 CapSkip 的 10 个线程之下。在 SDK 10 上使用 global.json。CapSkip 关闭时要跳过,而不是失败,并且在 CI 中使用 fail-skips。

一个每晚运行、要识别几百次的测试套件,次数很快就会累积起来。用在你自己的 Windows 机器上运行的本地 验证码绕过 方案来完成这些识别,它们就永远不会变成账单。