Tests de integración de API con xUnit
Seis tests de xUnit contra una API de tareas real, las reglas para probar una API compartida y un workflow de GitHub Actions para ejecutarlos.
Ejemplos ejecutados por última vez contra la API el
Los tests unitarios con un HttpClient simulado demuestran que tu código llama a la API como crees. Los tests de integración demuestran que la API responde como crees: códigos de estado reales, JSON real, validación real. Esta guía monta una suite de tests de integración con xUnit contra una API REST real, la API de tareas gratuita de https://api.testingapis.com, que no necesita clave y guarda las escrituras. Cubre crear, leer, actualizar y borrar, errores de validación y 404, cómo mantener los tests independientes en una API compartida y cómo ejecutarlos en GitHub Actions. Todos los tests de esta guía pasaron con xUnit 2.9 en .NET 10.
La API que se prueba
| Petición | Éxito | Errores |
|---|---|---|
GET /api/Todos?limit=2 | 200, un array JSON; X-Total-Count trae el total | |
GET /api/Todos/{id} | 200, la tarea | 404 para un id desconocido |
POST /api/Todos | 201, la tarea nueva en data | 400 sin title |
PUT /api/Todos/{id} | 200, la tarea reemplazada en 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=2Las escrituras se guardan en memoria en un servidor compartido: cualquiera puede leerlas y cambiarlas, y todo vuelve a los datos iniciales cuando se reinicia el servidor. Eso condiciona cómo se escriben los tests.
Preparar el proyecto de tests
dotnet new xunit -n TodoApi.IntegrationTests
cd TodoApi.IntegrationTests
dotnet test # the template's empty test passesAñade un fixture que sea dueño de un HttpClient. xUnit lo crea una vez para todos los tests de una clase que lo pida con IClassFixture. La URL base sale de una variable de entorno, para que los mismos tests puedan ejecutarse más adelante contra tu propia API.
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);Los tests
Seis tests: una lista, luego crear, leer, reemplazar y borrar, y después los dos errores. Cada test que escribe crea su propia tarea con un título único y la borra en finally, así que los tests pueden ejecutarse en cualquier orden, en paralelo y junto a cualquier otra persona que use la 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)Reglas para probar contra una API compartida
- Crea aquello sobre lo que haces aserciones. No compruebes que la tarea 1 tiene un título concreto: puede que alguien acabe de cambiarlo. Crea una tarea y comprueba esa.
- No hagas aserciones sobre totales.
X-Total-Countcambia a medida que otros crean y borran. Comprueba que está y que es plausible, no que vale 30. - Limpia. Borra lo que crea un test, en
finally, para que una aserción fallida no deje filas por ahí. - Cuenta con que la API caiga a veces. Un servicio público puede ir lento o estar reiniciándose. Un timeout en el cliente evita que un test colgado cuelgue la build.
Ejecutarlos en GitHub Actions
Primero los tests unitarios, luego los de integración, con un timeout para el job. Una ejecución diaria programada, además de en cada push, detecta el día en que cambia la API de la que dependes, en lugar del día en que vuelves a hacer 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.comProbar cómo maneja los fallos tu cliente
Los tests de integración del camino feliz son la mitad del trabajo. La misma API puede fallar cuando quieras: añade ?error=503 o ?delay=5 a cualquier petición. Las guías sobre el manejo de 503 en C#, timeouts y cancellation tokens y JSON mal formado en .NET traen cada una tests de xUnit para esos casos.
Lista de comprobación
- Un
HttpClientcompartido por clase de tests, con timeout. - La URL base sale de la configuración, no del código.
- Cada escritura usa datos únicos y limpia después.
- Se hacen aserciones sobre códigos de estado y cuerpos de error, no solo sobre los caminos de éxito.
- Los tests de integración llevan un trait, para que CI pueda ejecutarlos por separado y de forma programada.