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
| Request | Success | Errors |
|---|---|---|
GET /api/Todos?limit=2 | 200, a JSON array; X-Total-Count has the total | |
GET /api/Todos/{id} | 200, the todo | 404 for an unknown id |
POST /api/Todos | 201, the new todo in data | 400 without a title |
PUT /api/Todos/{id} | 200, the replaced todo in data | 404 |
DELETE /api/Todos/{id} | 200 | 404 |
$ 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":""}}/api/Todos?limit=2Writes 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
dotnet new xunit -n TodoApi.IntegrationTests
cd TodoApi.IntegrationTests
dotnet test # the template's empty test passesAdd 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.
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.
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);
}
}$ 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.
# .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.comTest 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
HttpClientper 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.