So lösen Sie Captchas in xUnit-Tests mit CapSkip (C#)

Um Captchas in xUnit-Tests zu lösen, bauen Sie einen einzigen CapSkip-Client in einer Assembly-Fixture auf und lösen Sie in jedem Test selbst, denn ein Token funktioniert nur einmal. Übergeben Sie den Abbruch-Token des Tests an das SDK, damit ein Timeout den Lösevorgang stoppt. Halten Sie die Zahl der gleichzeitig laufenden Lösevorgänge unter dem Thread-Limit von CapSkip. CapSkip läuft auf Ihrem eigenen Windows-PC, daher fallen bei einer Suite, die nachts Hunderte Male löst, keine Kosten pro Lösung an. Dieses Captcha-Setup für xUnit hat unter xUnit v3 und .NET 10 eigene Fallstricke. Der Befehl dotnet test braucht eine Datei, die das neue Verhalten erst einschaltet. Testklassen laufen standardmäßig parallel. Und ein Timeout wirkt nur, wenn Ihr Code kooperiert, also den Abbruch-Token beachtet. Dieser Leitfaden behandelt jeden dieser Punkte.
Was Sie brauchen
- CapSkip läuft auf einem Windows-PC, mit eingeschaltetem reCAPTCHA-Löser. Die Einrichtungsanleitung führt durch den ersten Start.
- Das .NET 10 SDK. Testprojekte für xUnit v3 verwenden net8.0 oder neuer oder .NET Framework 4.7.2 als Zielframework. Alles unten lief unter Windows 11 mit dem SDK 10.0.100, auf net10.0 und noch einmal auf net8.0, mit xunit.v3.mtp-v2 4.0.2 und CapSkip 1.3.0 von NuGet. SDK 8 und 9 wurden nicht getestet.
- Eine Staging-Website (eine Testkopie Ihrer Website vor dem Release), die Ihnen gehört, mit einer reCAPTCHA-v2-Checkbox in einem Formular. Das Beispiel sendet ein Registrierungsformular ab, das einen CSRF-Token trägt. Passen Sie die Feldnamen an Ihre Website an.
- Eine Adresse für den Solver. Im Local-Modus antwortet CapSkip auf 127.0.0.1 nur für dieses Gerät. Im Server-Modus lauscht es auf Ihrer Netzwerkadresse oder öffentlichen IP, damit Tests auf einem anderen Rechner es über die API aufrufen können. Eingestellt werden beide Modi unter Verbindungseinstellungen.
Die Messungen unten stammen von einem Mock-CapSkip und einer Mock-Registrierungsseite (nachgebaute Testattrappen) auf demselben PC, verwendet mit dem echten xUnit und dem echten SDK. Das Verhalten von xUnit und des SDK gilt auch für Ihre eigene Website. Die Lösezeiten und die Regeln der Registrierungsseite zum Annehmen und Ablehnen sind dagegen die des Mocks.
Schritt 1: Das Testprojekt erstellen
Das .NET SDK hat keine xUnit-v3-Vorlage, bis Sie das Vorlagenpaket installieren. Der erste Befehl unten erledigt das. Die Vorlage schreibt dann das Projekt, eine Datei xunit.runner.json, eine Datei global.json und einen Beispieltest namens UnitTest1.cs. Der Block löscht den Beispieltest, weil er jedem Lauf einen zweiten Test hinzufügen würde.
# 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
Behalten Sie die global.json. Unter SDK 10 verweigert dotnet test ein xUnit-v3-Projekt ohne sie und bricht ab mit: Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. Die Datei schaltet dotnet test auf den neuen Runner um, Microsoft.Testing.Platform, den die Kommentare in den Blöcken unten MTP nennen. Die Vorlage legt die Datei in den Projektordner oder fügt den Abschnitt test zu einer global.json hinzu, die bereits darüber liegt. Führen Sie dotnet test im Projektordner aus. Wer es aus einem Ordner ohne die Datei auf das Projekt richtet, etwa mit dotnet test CapSkipTests, bekommt denselben Fehler.
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}Schritt 2: Ein einziger CapSkip-Client für den gesamten Lauf
Jeder C#-Dateiblock ab hier beginnt mit dem Namen, unter dem Sie ihn speichern. Der erste ist die Fixture. Sie liest die CapSkip-Einstellungen aus der Umgebung, weil das SDK sie nicht von selbst liest, und erzeugt einen einzigen Client. Mit den Standardwerten läuft sie ohne jede Einrichtung. CAPSKIP_API_KEY spielt nur eine Rolle, wenn Sie in CapSkip die Validierung des API-Schlüssels eingeschaltet haben.
// 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;
}Die Fixture stellt außerdem fest, ob CapSkip läuft. Ein einfacher TCP-Connect fragt, ob irgendetwas lauscht, und sendet keinen Lösungsauftrag.
// 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;
}
}
}Registrieren Sie die Fixture für die Assembly. xUnit erzeugt dann eine einzige Instanz für den gesamten Lauf. Es übergibt diese Instanz über den Konstruktor an jede Testklasse, wie es die xUnit-Docs zu Shared Context beschreiben.
// 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))]
Die Assembly-Fixture ist den beiden anderen Varianten überlegen. Bei sechs Testklassen wurde eine Class-Fixture sechsmal erzeugt, einmal pro Klasse. Eine Collection-Fixture wurde einmal erzeugt, zwingt diese Klassen aber, nacheinander zu laufen. Sechs Klassen, die jeweils einmal lösten, brauchten damit mehr als 23 Sekunden und mit der Assembly-Fixture unter 5 Sekunden. Der Client selbst lässt sich gefahrlos teilen. Er hält keinen veränderlichen Zustand, jeder Client verwendet denselben statischen HttpClient im SDK, und vierzehn gleichzeitige Lösevorgänge über einen Client funktionierten.
Schritt 3: Ein kleiner Helfer für Ihre Staging-Website
Dieser Teil gehört Ihnen, nicht CapSkip. Der Helfer öffnet die Registrierungsseite und liest daraus den sitekey und den CSRF-Token aus. Passen Sie die beiden Muster an Ihr Markup an.
// 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();
}Danach sendet er das Formular mit dem Token im Feld g-recaptcha-response ab. HttpClient sendet bei einem Formular-POST keinen Referer, und eine strenge Website wie Django über HTTPS lehnt einen POST ohne Referer ab, daher setzt der Helfer ihn.
// 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));
}
}Jeder Test erzeugt seinen eigenen Helfer und damit seinen eigenen HttpClient. Das ist Absicht. Mit einem gemeinsamen Client, der Cookies behält, und sechs parallelen Tests akzeptierte eine Website, die bei jedem Besuch eine neue Session startet, einen POST und lehnte fünf mit einer CSRF-Abweichung ab. Die Tests überschrieben gegenseitig ihre Cookies.
Schritt 4: Den Test schreiben
Der xUnit-Captcha-Test überspringt sich selbst, wenn CapSkip nicht läuft. Er löst sein eigenes Captcha, sendet das Formular ab und prüft die Antwort. Behalten Sie fünf Dinge im Blick:
- Das Timeout am Fact ist in Millisekunden angegeben. Es lässt den Test scheitern, wenn die Zeit abgelaufen ist, und löst außerdem den Abbruch-Token des Tests aus.
- Der Token geht als benanntes Argument cancellationToken an RecaptchaAsync, damit das Abbrechen des Tests den Lösevorgang stoppt.
- Die Assertion auf den Body steht vor der auf den Status. Lehnt die Website das Formular ab, zeigt xUnit dann den Anfang der Antwort der Website statt nur Forbidden. Es kürzt lange Zeichenfolgen bei etwa 50 Zeichen, schreiben Sie für eine lange HTML-Antwort also eine eigene Assertion-Meldung.
- Wählen Sie ein Timeout oberhalb Ihres langsamsten normalen Lösevorgangs und unterhalb des eigenen Limits des SDK von 300 Sekunden.
- Ändern Sie welcome in einen Text, den Ihre Erfolgsseite zeigt, und passen Sie den Pfad /signup und den Benutzernamen im Helfer an Ihr Formular an. Ein fester Benutzername kollidiert beim zweiten nächtlichen Lauf, verwenden Sie also jedes Mal einen neuen.
// 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);
}
}Schritt 5: Ausführen
Setzen Sie die Variablen für die Sitzung und führen Sie dotnet test im Projektordner aus. Variablen, die Sie weglassen, fallen auf die Standardwerte zurück. Ein erfolgreicher xUnit-Captcha-Lauf endet mit einer Zusammenfassung, die Passed! lautet, mit total 1, failed 0 und 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
In jedem Test lösen
Lösen Sie nicht einmal in einer Fixture und teilen Sie dann den Token. Ein reCAPTCHA-Token lässt sich nur einmal verwenden. Im Test löste eine Fixture einmal, und zwei Tests sendeten denselben Token: Der erste wurde akzeptiert, der zweite von der Website abgelehnt. Welcher Test verliert, hängt von der Reihenfolge des Laufs ab, daher wandert der Fehler umher und wirkt instabil. Außerdem läuft ein Token nach etwa zwei Minuten ab, ein später Test scheitert also selbst dann, wenn er allein läuft. Der Leitfaden zum Ablaufen von reCAPTCHA-Tokens geht auf diesen Aspekt näher ein.
Ein Lösevorgang pro Test kostet jeweils ein paar Sekunden. Braucht ein Test das Captcha selbst nicht, geben Sie ihm einen anderen Weg, etwa den Fake-Client weiter unten.
Parallele Lösevorgänge unter dem Limit von CapSkip halten
xUnit führt Testklassen gleichzeitig aus. Standardmäßig gilt ein Thread pro logischem Kern, und der Test-PC hat 24. Tests innerhalb einer Klasse laufen nacheinander. Das bedeutet, dass eine Suite mit vielen Klassen, die lösen, viele Lösevorgänge auf einmal startet. Die Einstellung Max. Threads für reCAPTCHA in CapSkip steht standardmäßig auf 10. Überzählige Jobs warten auf einen freien Thread, und ein Job, der länger als das Wait Timeout wartet, scheitert. Hier sehen Sie, was die Thread-Einstellungen bei sechs Klassen bewirkten, die jeweils einmal lösten, wobei der Mock jeden Lösevorgang drei Sekunden lang festhielt.
| Einstellung | Laufzeit | Gleichzeitige Lösevorgänge |
|---|---|---|
| Standard | 4,4 s | 6 |
| maxParallelThreads 3 | 8,1 s | 3 |
| maxParallelThreads 2 | 11,9 s | 2 |
| Parallelization aus | 23,3 s | 1 |
Bei vierzehn Klassen gegen das Limit des Mocks von 10 lösten zehn, und vier warteten jeweils drei Sekunden im Mock. Die einfachste Obergrenze ist eine Zahl in der xunit.runner.json. Die Vorlage hat diese Datei bereits angelegt.
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"maxParallelThreads": 8
}Die andere Möglichkeit ist ein Gate in der Fixture, ein Zähler, der dafür sorgt, dass unabhängig von der Thread-Zahl nur wenige Lösevorgänge gleichzeitig laufen. Speichern Sie den Block unten neben CapSkipFixture.cs. Lassen Sie den Test dann das Gate aufrufen. Ersetzen Sie den Aufruf von RecaptchaAsync durch: var code = await capskip.SolveAsync(page.SiteKey, page.Url, ct); und senden Sie code statt solved.Code ab. Das Gate muss in der Assembly-Fixture liegen, denn ein Gate in einer Class-Fixture begrenzt nichts: Jede Klasse bekommt ein eigenes. Mit dem Gate auf 8 erreichten vierzehn Lösevorgänge höchstens 8 gleichzeitig.
// 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(); }
}
}Bei vierzehn Klassen brauchten eine Obergrenze des Runners von 8, ein Gate von 8 und gar keine Obergrenze etwa gleich lang: zwischen 8 und 9 Sekunden. Der Unterschied liegt darin, wo das Warten stattfindet. Mit einer Obergrenze geschieht es in Ihrem Testprozess. Ohne eine geschieht es in CapSkip, wo es auf das Wait Timeout angerechnet wird. Die xUnit-Docs zur Parallelität beschreiben den Rest.
Damit nur die Tests, die lösen, nacheinander laufen, legen Sie ihre Klassen in eine benannte Collection. Setzen Sie das Attribut unten über die Klasse in SignupTests.cs und lassen Sie den Klassenrumpf unverändert. Klassen in derselben Collection laufen nacheinander, und andere Collections laufen weiterhin neben ihnen. Im Test brauchten sechs Klassen, die lösen, plus sechs Klassen mit drei Sekunden anderer Arbeit 23,3 Sekunden, und die sechs anderen liefen währenddessen mit.
// 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 ...
}Eine Falle aus älteren xUnit-Gewohnheiten. Die Attributeinstellungen DisableTestParallelization und MaxParallelThreads an CollectionBehavior sind in 4.0.2 jetzt Fehler. Sie scheitern mit CS0619 und verweisen auf das Attribut Parallelization. Verwenden Sie stattdessen dieses Attribut aus Xunit.v3, wie in [assembly: Parallelization(MaxThreads = 3)], oder setzen Sie die Obergrenze in der xunit.runner.json.
Ein Timeout den Lösevorgang stoppen lassen
Ein Fact mit einem Timeout von 5000 gegen einen Lösevorgang, der 20 Sekunden dauert, scheiterte nach 5,0 Sekunden mit: Test execution timed out after 5000 milliseconds. Der gesamte Lauf endete etwa eine halbe Sekunde später. Weil das SDK den Token des Tests bekommen hatte, stoppte der abgebrochene Token den Aufruf. Während das SDK auf die Antwort pollt, ist die Exception eine TaskCanceledException. Während es die Aufgabe übermittelt, ist die Exception eine NetworkException von CapSkip mit der Meldung The operation was canceled.
Manchmal meldet xUnit dann einen zweiten Fehlschlag für denselben Test, einen Fehler beim Aufräumen, der die Abbruch-Exception trägt. Eine Absicherung um den Aufruf zum Lösen beseitigt ihn. Fügen Sie die Zeile using CapSkip; oben in der Testdatei ein. Lassen Sie den Token und den Abruf der Seite oberhalb des try stehen, und setzen Sie das Lösen, das Absenden und beide Assertions hinein. Die Absicherung schluckt die Exception nur, wenn der Token des Tests abgebrochen wurde, denn das Timeout hat den Test bereits mit seiner eigenen Meldung scheitern lassen.
// 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.
}Übergeben Sie den Token nicht, scheitert der Test trotzdem nach 5,0 Sekunden, aber der Solver pollt im Hintergrund weiter. Ein Test, der seinen Token nie anfasst, kann das Formular sogar noch absenden, nachdem er als gescheitert gemeldet wurde. Genau davor warnt der Analyzer xUnit1051. Er schlägt bei einem Aufruf von RecaptchaAsync ohne Token an, ebenso bei HttpClient-Aufrufen ohne einen.
Der Timeout-Schalter von dotnet test für den ganzen Lauf ist genauso kooperativ. Er beendete keinen Test, der seinen Token ignoriert. Für einen Lauf, der hängt, verwenden Sie einen Hang-Dump. Der erste Block unten gilt für den neuen Runner, den dieser Leitfaden verwendet. Der zweite gilt für den VSTest-Weg.
# 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
Mit einem Hang-Timeout von 15 Sekunden beendete der neue Runner den Lauf nach 16,2 Sekunden. Er schrieb einen Mini-Dump von 2,7 MB und ließ keinen Prozess zurück. Auf dem VSTest-Weg beendet der Dump-Typ none den Lauf, lässt den hängenden Testprozess aber am Leben.
Das SDK hat ein eigenes Limit, recaptchaTimeout, das 300 Sekunden beträgt. Es wird nur zwischen zwei Abfragen geprüft. Ein Limit von 8 Sekunden löste im Test nach 12,9 Sekunden aus, und der Standardwert löste nach 5 Minuten und 3 Sekunden aus. Halten Sie Ihr Timeout deutlich darunter.
Den Test überspringen, wenn CapSkip nicht läuft
Der Test aus Schritt 4 ruft Assert.SkipUnless mit dem Flag IsUp der Fixture auf. Ist CapSkip geschlossen, meldet er einen übersprungenen Test mit der Begründung, und der Lauf endet mit 0. Werden alle Tests übersprungen, gibt der Runner Zero tests ran aus. Fügen Sie in einem CI-Job, der CapSkip haben muss, den Schalter fail-skips mit dem Wert on hinzu. Ein übersprungener Test lässt den Lauf dann mit Exit-Code 2 scheitern.
Ohne diese Absicherung lässt ein geschlossenes CapSkip den Test mit einer NetworkException von CapSkip scheitern: No connection could be made because the target machine actively refused it. Das dauerte unter Windows etwa 2,1 Sekunden. Eine Attributoption, SkipExceptions mit dem Typ NetworkException, macht aus dieser Exception ein Überspringen. Sie verbirgt aber auch ein CapSkip, das mitten in einem Lauf stoppt, daher ist die Port-Prüfung sicherer.
Die Port-Prüfung kann auch einen Tippfehler verbergen. CAPSKIP_HOST ist ein reiner Hostname oder eine Adresse ohne vorangestelltes http://. Ein Wert wie http://127.0.0.1 lässt die Prüfung fehlschlagen, daher werden die Tests übersprungen statt als gescheitert gemeldet. Ohne die Absicherung meldet das SDK: No such host is known. (http:80), und es kann mehrere Sekunden dauern, bis es das tut.
Wiederholen, wenn ein Lösevorgang scheitert
xUnit hat kein Attribut für Wiederholungen. Manche Lösevorgänge scheitern, und ein neuer Versuch kann gelingen. Das SDK löst eine ApiException mit der Meldung ERROR_CAPTCHA_UNSOLVABLE aus, es sei denn, Sie legen in CapSkip einen Error-Replacement-Text fest. Dann trägt die Exception diesen Text, und der Filter unten trifft nie zu. Dieser Helfer wiederholt nur diesen Fehlschlag, und jeder Versuch nimmt seinen eigenen Platz am Gate ein. Speichern Sie ihn neben dem Gate-Block, denn er ruft das SolveAsync des Gates auf.
// 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")) { }
}
}
}Mit einem Mock, der seine ersten beiden Jobs scheitern ließ, bestand ein Helfer, der drei Versuche erlaubte, nach 5,8 Sekunden. Ein zweiter Lauf, der zwei Versuche erlaubte, scheiterte nach 4,0 Sekunden mit CapSkip.ApiException: ERROR_CAPTCHA_UNSOLVABLE. Siehe ERROR_CAPTCHA_UNSOLVABLE zur Bedeutung des Fehlers.
Den Namenskonflikt bei TimeoutException beheben
Ein Testprojekt hat implizite usings eingeschaltet, daher ist System immer im Geltungsbereich. Mit using CapSkip zusätzlich lässt sich eine catch-Klausel für TimeoutException nicht kompilieren: error CS0104: TimeoutException is an ambiguous reference between CapSkip.TimeoutException and System.TimeoutException. Jede dieser Lösungen funktioniert, und der Alias funktioniert auch innerhalb von 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 kollidiert nur, wenn Sie using System.ComponentModel.DataAnnotations hinzufügen, was ein einfaches Testprojekt nicht hat.
Unit-Tests, die keinen Solver brauchen
Tests Ihres eigenen Codes, der den Solver aufruft, brauchen CapSkip nicht. Der ApiClient des SDK ist public, seine beiden Anfragemethoden sind virtual, und die Eigenschaft ApiClient des Clients lässt sich setzen. Dieser Test besteht auch bei geschlossenem 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);
}
}Schalter, die man kennen sollte
Das sind die Schalter von dotnet test, die beim Testen einer Solver-Suite auf dem neuen Runner eine Rolle spielten. Bestandene Tests zeigen standardmäßig keine Ausgabe, und der Diagnose-Schalter schreibt seine Meldungen in eine Datei statt in die Konsole.
# 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
Die Tests gegen CapSkip auf einem anderen Rechner ausführen
Die Tests und der Solver müssen sich keinen PC teilen. Schalten Sie CapSkip in den Verbindungseinstellungen in den Server-Modus, dann lauscht es auf Ihrer Netzwerkadresse oder öffentlichen IP. Setzen Sie danach CAPSKIP_HOST und CAPSKIP_PORT, und dieselben xUnit-Captcha-Tests laufen unverändert. Im Test bestand die Suite mit CAPSKIP_HOST auf der LAN-Adresse des Test-PCs, während der Mock auf allen Schnittstellen lauschte.
Beachten Sie zwei Dinge. Das SDK spricht mit CapSkip über einfaches HTTP, ohne TLS-Option, daher wird der API-Schlüssel im Klartext übertragen. Halten Sie diese Verbindung in einem privaten Netzwerk oder einem VPN, und aktivieren Sie die Validierung des API-Schlüssels. Und CapSkip öffnet seinen Port beim Start des Server-Modus für jede Adresse. Um den Zugriff auf die Rechner zu beschränken, die Sie erwarten, fügen Sie in der Windows Firewall eine Blockierregel für alle anderen hinzu. Der Solver bleibt Ihr eigener Windows-Rechner, und abgerechnet wird pro Lösung weiterhin nichts.
Die vollständige Testdatei
Hier sind die Fixture, die Port-Prüfung, der Helfer und der Test in einer Datei, für einen schnellen Einstieg. Haben Sie die Schritte 2 bis 4 übersprungen, ersetzt diese eine Datei sie. Haben Sie diese Dateien bereits gespeichert, löschen Sie sie zuerst. Die Fixture-Klasse ist partial, daher passen die Blöcke für Gate und Wiederholung weiterhin. Speichern Sie die Datei als 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);
}
}Häufige Fehler bei xUnit-Captcha-Tests
| Was Sie sehen | Ursache | Beheben |
|---|---|---|
| Testing with VSTest target is no longer supported | Ein xUnit-v3-Projekt ohne global.json unter SDK 10 | Fügen Sie die global.json hinzu und führen Sie den Befehl in deren Ordner aus |
| CS0104 bei TimeoutException | System und CapSkip definieren beide eine TimeoutException | Verwenden Sie den Alias, oder fangen Sie CapSkipError ab |
| CS0619 bei DisableTestParallelization | Die Option ist in xUnit v3 veraltet | Verwenden Sie das Attribut Parallelization oder xunit.runner.json |
| Zero tests ran, mit einer Program.cs im Projekt | Der eigene Einstiegspunkt des Projekts hat den von xUnit ersetzt | Löschen Sie die Program.cs |
| Test execution timed out after 5000 milliseconds | Der Lösevorgang dauerte länger als das Timeout | Erhöhen Sie das Timeout und übergeben Sie den Token an das SDK |
| Skipped: CapSkip is not running | Auf Host und Port lauscht nichts | Starten Sie CapSkip, prüfen Sie die Verbindungseinstellungen, verwenden Sie einen Host ohne http:// |
| Nur einer von mehreren Tests besteht | Tests teilen sich einen Token oder einen HttpClient, der Cookies behält | Lösen Sie und öffnen Sie die Seite in jedem Test |
| Der zweite Test scheitert mit einer CSRF-Abweichung | Parallele Tests haben gegenseitig ihre Cookies überschrieben | Ein HttpClient pro Test |
| Build-Fehler xUnit1049 | Ein async-void-Test | Geben Sie Task oder ValueTask zurück |
| Warnung xUnit1051 | Ein Aufruf, der einen Token annimmt, hat keinen erhalten | Übergeben Sie TestContext.Current.CancellationToken |
FAQ
Kann ein gehosteter CI-Agent diese Tests ausführen?
Ein gehosteter Agent läuft auf einem fremden Rechner und kann daher 127.0.0.1 auf Ihrem PC nicht erreichen. Betreiben Sie CapSkip im Server-Modus auf einem Windows-Rechner, den Sie kontrollieren, richten Sie CAPSKIP_HOST darauf aus und bewahren Sie den API-Schlüssel im Secret-Speicher des CI-Systems auf. GitHub-gehostete und Azure-DevOps-gehostete Agents wurden hier nicht getestet. Getestet wurde nur der LAN-Fall. Der Agent muss außerdem diese Adresse und diesen Port erreichen dürfen.
Funktioniert das mit xUnit v2, NUnit oder MSTest?
Für diesen Leitfaden wurde nur xUnit v3 4.0.2 ausgeführt. Mehrere Dinge oben sind Funktionen von v3, etwa Assert.SkipUnless, TestContext.Current und das Attribut für die Assembly-Fixture. Die Aufrufe von CapSkip sind in jedem Framework gleich. Was sich ändert, sind die Einstellungen für Fixture, Timeout und Parallelität, prüfen Sie daher für jede davon die Dokumentation Ihres Frameworks.
Kann ich den alten VSTest-Runner behalten?
Ja. Verwenden Sie xunit.v3.mtp-off 4.0.2, xunit.runner.visualstudio 4.0.1 und Microsoft.NET.Test.Sdk 18.10.1 statt des Pakets mtp-v2 und lassen Sie die global.json weg. dotnet test funktioniert dann wie zuvor. Gründe für übersprungene Tests gibt der Standard-Konsolen-Logger auf diesem Weg nicht aus, und der Schalter für hängende Läufe heißt blame-hang-timeout.
Sollte jeder Build die Solver-Tests ausführen?
Wahrscheinlich nicht. Jeder davon dauert Sekunden, braucht ein laufendes CapSkip und greift auf eine echte Staging-Website zu. Legen Sie sie in ein eigenes Testprojekt, damit ein gewöhnliches dotnet test Ihrer Unit-Tests nie auf den Solver wartet. Führen Sie dieses Projekt nach einem Zeitplan oder vor einem Release aus.
Die Kurzfassung
Eine xUnit-Captcha-Suite braucht einen einzigen CapSkip-Client in einer Assembly-Fixture und einen frischen Lösevorgang in jedem Test. Übergeben Sie den Token des Tests an das SDK, damit das Timeout des Fact einen Lösevorgang stoppen kann. Begrenzen Sie parallele Lösevorgänge mit einer Runner-Einstellung oder einem Gate auf weniger als die 10 Threads von CapSkip. Verwenden Sie unter SDK 10 eine global.json. Überspringen Sie, statt zu scheitern, wenn CapSkip geschlossen ist, und nutzen Sie in CI fail-skips.
- Alle Captcha-Typen, die das .NET-Paket verarbeitet, finden Sie auf der C#-Captcha-Solver-Seite.
- Das Checkbox-reCAPTCHA in einem reinen C#-Programm: reCAPTCHA v2 in C# lösen.
- Wie der Checkbox-Solver funktioniert: die reCAPTCHA-v2-Solver-Seite.
Eine nächtliche Suite, die einige hundert Mal löst, summiert sich schnell. Wenn Sie sie über eine lokale Captcha-Umgehung auf Ihrem eigenen Windows-Rechner laufen lassen, werden diese Lösungen nie zu einer Rechnung.
