TestingAPIs is still being built. If something breaks, email support@testingapis.com.

API integration tests with xUnit

Six xUnit tests against a live Todos API, the rules for testing a shared API, and a GitHub Actions workflow to run them.

Examples last run against the API on

Unit tests with a mocked HttpClient prove your code calls the API the way you think it does. Integration tests prove the API answers the way you think it does: real status codes, real JSON, real validation. This guide builds an xUnit integration test suite against a live REST API, the free Todos API at https://api.testingapis.com, which needs no key and saves writes. It covers create, read, update and delete, validation errors and 404s, how to keep tests independent on a shared API, and running them in GitHub Actions. Every test here passed with xUnit 2.9 on .NET 10.

The API under test

RequestSuccessErrors
GET /api/Todos?limit=2200, a JSON array; X-Total-Count has the total
GET /api/Todos/{id}200, the todo404 for an unknown id
POST /api/Todos201, the new todo in data400 without a title
PUT /api/Todos/{id}200, the replaced 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

Writes are kept in memory on a shared server: anyone can read and change them, and everything returns to the starting data when the server restarts. That shapes how the tests are written.

Set up the test project

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

Add a fixture that owns one HttpClient. xUnit creates it once for every test in a class that asks for it with IClassFixture. The base URL comes from an environment variable, so the same tests can run against your own API later.

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);

The tests

Six tests: a list, then create, read, replace and delete, then the two errors. Each test that writes makes its own todo with a unique title, and deletes it in finally, so the tests can run in any order, in parallel, and next to anyone else using the API.

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);
    }
}
Output
$ dotnet test --filter "Category=Integration"
Passed!  - Failed:     0, Passed:     6, Skipped:     0, Total:     6, Duration: 862 ms - TodoApi.IntegrationTests.dll (net10.0)

Rules for testing against a shared API

  • Create what you assert on.Don't assert that todo 1 has a certain title: someone else may have just changed it. Create a todo and check that one.
  • Don't assert totals. X-Total-Countchanges as others create and delete. Check that it's there and plausible, not that it equals 30.
  • Clean up. Delete what a test creates, in finally, so a failed assertion doesn't leave rows behind.
  • Expect the API to be down sometimes. A public service can be slow or restarting. A timeout on the client keeps a hung test from hanging the build.

Run them in GitHub Actions

Unit tests first, then integration tests, with a job timeout. A daily schedule as well as every push catches the day the API you depend on changes, rather than the day you next push.

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

Test how your client handles failures

Integration tests for the happy path are half the job. The same API can fail on demand: add ?error=503 or ?delay=5 to any request. The guides on 503 handling in C#, timeouts and cancellation tokens and malformed JSON in .NET each come with xUnit tests for those cases.

Checklist

  • One shared HttpClient per test class, with a timeout.
  • The base URL comes from configuration, not code.
  • Every write uses unique data, and cleans up after itself.
  • Status codes and error bodies are asserted, not just success paths.
  • Integration tests carry a trait, so CI can run them separately and on a schedule.