API-Integrationstests mit xUnit

Sechs xUnit-Tests gegen eine Live-Todos-API, die Regeln für Tests gegen eine geteilte API und ein GitHub-Actions-Workflow, um sie auszuführen.

Beispiele zuletzt am gegen die API ausgeführt

Unit-Tests mit einem gemockten HttpClient beweisen, dass dein Code die API so aufruft, wie du denkst. Integrationstests beweisen, dass die API so antwortet, wie du denkst: echte Statuscodes, echtes JSON, echte Validierung. Dieser Leitfaden baut eine Integrationstest-Suite mit xUnit gegen eine Live-REST-API, die kostenlose Todos-API unter https://api.testingapis.com, die keinen Schlüssel braucht und Schreibzugriffe speichert. Er behandelt Anlegen, Lesen, Ändern und Löschen, Validierungsfehler und 404, wie Tests auf einer geteilten API unabhängig bleiben, und wie du sie in GitHub Actions ausführst. Jeder Test hier bestand mit xUnit 2.9 auf .NET 10.

Die getestete API

RequestErfolgFehler
GET /api/Todos?limit=2200, ein JSON-Array; X-Total-Count enthält die Gesamtzahl
GET /api/Todos/{id}200, das Todo404 für eine unbekannte ID
POST /api/Todos201, das neue Todo in data400 ohne title
PUT /api/Todos/{id}200, das ersetzte Todo in data404
DELETE /api/Todos/{id}200404
Terminal
$ curl -i -X POST https://api.testingapis.com/api/Todos \
    -H "Content-Type: application/json" \
    -d '{"userId":101,"title":"Write integration tests","completed":false}'
HTTP/1.1 201 Created

{"message":"Todo created successfully","data":{"id":31,"userId":101,"title":"Write integration tests","completed":false,"priority":"medium","dueDate":""}}
GET/api/Todos?limit=2

Schreibzugriffe liegen im Speicher eines geteilten Servers: Jeder kann sie lesen und ändern, und beim Neustart des Servers kehrt alles zu den Startdaten zurück. Das bestimmt, wie die Tests geschrieben werden.

Das Testprojekt einrichten

Terminal
dotnet new xunit -n TodoApi.IntegrationTests
cd TodoApi.IntegrationTests
dotnet test   # the template's empty test passes

Füge eine Fixture hinzu, die einen HttpClient besitzt. xUnit erzeugt sie einmal für alle Tests einer Klasse, die sie mit IClassFixture anfordert. Die Basis-URL kommt aus einer Umgebungsvariablen, damit dieselben Tests später gegen deine eigene API laufen können.

ApiFixture.cs
using Xunit;

/// One HttpClient for every test in a class (creating one per test wastes sockets).
public sealed class ApiFixture : IDisposable
{
    public HttpClient Http { get; } = new()
    {
        // Point the same tests at another server, e.g. your own API in a staging environment.
        BaseAddress = new Uri(Environment.GetEnvironmentVariable("API_BASE_URL") ?? "https://api.testingapis.com"),
        Timeout = TimeSpan.FromSeconds(30),
    };

    public void Dispose() => Http.Dispose();
}

public record Todo(int Id, int UserId, string Title, bool Completed);

/// Creates and updates answer { "message": "...", "data": { ...the todo... } }.
public record Envelope<T>(string Message, T Data);

Die Tests

Sechs Tests: eine Liste, dann Anlegen, Lesen, Ersetzen und Löschen, dann die beiden Fehler. Jeder Test, der schreibt, legt sein eigenes Todo mit eindeutigem Titel an und löscht es in finally, die Tests können also in beliebiger Reihenfolge, parallel und neben allen anderen Nutzern der API laufen.

TodosApiTests.cs
using System.Net;
using System.Net.Http.Json;
using Xunit;

[Trait("Category", "Integration")]
public class TodosApiTests(ApiFixture api) : IClassFixture<ApiFixture>
{
    private readonly HttpClient http = api.Http;

    // Other people use the same API: give every record this run creates a name nobody else has.
    private static string UniqueTitle() => $"xUnit test {Guid.NewGuid():N}";

    [Fact]
    public async Task Lists_todos_with_a_total_count()
    {
        using var response = await http.GetAsync("/api/Todos?limit=2");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var todos = await response.Content.ReadFromJsonAsync<List<Todo>>();
        Assert.Equal(2, todos!.Count);
        Assert.True(int.Parse(response.Headers.GetValues("X-Total-Count").Single()) >= 2);
    }

    [Fact]
    public async Task A_created_todo_can_be_read_back()
    {
        var title = UniqueTitle();
        using var created = await http.PostAsJsonAsync("/api/Todos", new { userId = 101, title });
        Assert.Equal(HttpStatusCode.Created, created.StatusCode);
        var todo = (await created.Content.ReadFromJsonAsync<Envelope<Todo>>())!.Data;

        try
        {
            var read = await http.GetFromJsonAsync<Todo>($"/api/Todos/{todo.Id}");
            Assert.Equal(title, read!.Title);
            Assert.False(read.Completed);
        }
        finally
        {
            await http.DeleteAsync($"/api/Todos/{todo.Id}"); // leave the shared data as it was
        }
    }

    [Fact]
    public async Task Put_replaces_the_todo()
    {
        var title = UniqueTitle();
        using var created = await http.PostAsJsonAsync("/api/Todos", new { userId = 101, title });
        var todo = (await created.Content.ReadFromJsonAsync<Envelope<Todo>>())!.Data;

        try
        {
            using var updated = await http.PutAsJsonAsync($"/api/Todos/{todo.Id}", new { userId = 101, title, completed = true });
            Assert.Equal(HttpStatusCode.OK, updated.StatusCode);

            var read = await http.GetFromJsonAsync<Todo>($"/api/Todos/{todo.Id}");
            Assert.True(read!.Completed);
        }
        finally
        {
            await http.DeleteAsync($"/api/Todos/{todo.Id}");
        }
    }

    [Fact]
    public async Task A_deleted_todo_is_gone()
    {
        using var created = await http.PostAsJsonAsync("/api/Todos", new { userId = 101, title = UniqueTitle() });
        var todo = (await created.Content.ReadFromJsonAsync<Envelope<Todo>>())!.Data;

        using var deleted = await http.DeleteAsync($"/api/Todos/{todo.Id}");
        Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);

        using var read = await http.GetAsync($"/api/Todos/{todo.Id}");
        Assert.Equal(HttpStatusCode.NotFound, read.StatusCode);
    }

    [Fact]
    public async Task A_todo_without_a_title_is_rejected()
    {
        using var response = await http.PostAsJsonAsync("/api/Todos", new { userId = 101 });

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.Contains("title", await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task An_unknown_id_is_404()
    {
        using var response = await http.GetAsync("/api/Todos/999999");
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }
}
Ausgabe
$ dotnet test --filter "Category=Integration"
Passed!  - Failed:     0, Passed:     6, Skipped:     0, Total:     6, Duration: 862 ms - TodoApi.IntegrationTests.dll (net10.0)

Regeln für Tests gegen eine geteilte API

  • Leg an, worauf du prüfst. Prüfe nicht, ob Todo 1 einen bestimmten Titel hat: Jemand anders hat ihn vielleicht gerade geändert. Leg ein Todo an und prüfe dieses.
  • Prüfe keine Gesamtzahlen. X-Total-Count ändert sich, während andere anlegen und löschen. Prüfe, dass es da und plausibel ist, nicht, dass es 30 ist.
  • Räum auf. Lösch, was ein Test anlegt, in finally, damit eine fehlgeschlagene Assertion keine Zeilen zurücklässt.
  • Rechne damit, dass die API manchmal ausfällt. Ein öffentlicher Dienst kann langsam sein oder gerade neu starten. Ein Timeout am Client verhindert, dass ein hängender Test den Build hängen lässt.

In GitHub Actions ausführen

Erst die Unit-Tests, dann die Integrationstests, mit einem Timeout für den Job. Ein täglicher Zeitplan zusätzlich zu jedem Push erwischt den Tag, an dem sich die API ändert, von der du abhängst, und nicht erst den Tag deines nächsten Pushs.

YAML
# .github/workflows/integration-tests.yml
name: Integration tests

on:
  push:
  schedule:
    - cron: "0 6 * * *"   # also daily, to notice when the API changes under you

jobs:
  integration:
    runs-on: ubuntu-latest
    timeout-minutes: 10   # a hung test fails the job instead of using up minutes
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "10.0.x"
      - run: dotnet test --filter "Category!=Integration"   # unit tests first, fast
      - run: dotnet test --filter "Category=Integration"
        env:
          API_BASE_URL: https://api.testingapis.com

Testen, wie dein Client mit Fehlern umgeht

Integrationstests für den Normalfall sind die halbe Arbeit. Dieselbe API kann auf Wunsch scheitern: Hänge ?error=503 oder ?delay=5 an einen beliebigen Request. Die Leitfäden zu 503-Behandlung in C#, Timeouts und Cancellation Tokens und fehlerhaftem JSON in .NET bringen jeweils xUnit-Tests für diese Fälle mit.

Checkliste

  • Ein geteilter HttpClient pro Testklasse, mit Timeout.
  • Die Basis-URL kommt aus der Konfiguration, nicht aus dem Code.
  • Jeder Schreibzugriff nutzt eindeutige Daten und räumt hinter sich auf.
  • Statuscodes und Fehler-Bodies werden geprüft, nicht nur die Erfolgsfälle.
  • Integrationstests tragen ein Trait, damit CI sie getrennt und nach Zeitplan ausführen kann.