Tests d’intégration d’API avec xUnit
Six tests xUnit contre une API de todos en direct, les règles pour tester une API partagée et un workflow GitHub Actions pour les lancer.
Exemples exécutés pour la dernière fois contre l’API le
Les tests unitaires avec un HttpClient simulé prouvent que votre code appelle l’API comme vous le pensez. Les tests d’intégration prouvent que l’API répond comme vous le pensez : vrais codes de statut, vrai JSON, vraie validation. Ce guide construit une suite de tests d’intégration xUnit contre une API REST en direct, l’API de todos gratuite de https://api.testingapis.com, sans clé et qui enregistre les écritures. Il couvre la création, la lecture, la modification et la suppression, les erreurs de validation et les 404, la façon de garder les tests indépendants sur une API partagée, et leur exécution dans GitHub Actions. Chaque test de ce guide est passé avec xUnit 2.9 sur .NET 10.
L’API testée
| Requête | Succès | Erreurs |
|---|---|---|
GET /api/Todos?limit=2 | 200, un tableau JSON ; X-Total-Count donne le total | |
GET /api/Todos/{id} | 200, le todo | 404 pour un id inconnu |
POST /api/Todos | 201, le nouveau todo dans data | 400 sans title |
PUT /api/Todos/{id} | 200, le todo remplacé dans 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=2Les écritures sont gardées en mémoire sur un serveur partagé : tout le monde peut les lire et les modifier, et tout revient aux données de départ au redémarrage du serveur. Cela détermine la façon d’écrire les tests.
Mettre en place le projet de tests
dotnet new xunit -n TodoApi.IntegrationTests
cd TodoApi.IntegrationTests
dotnet test # the template's empty test passesAjoutez une fixture qui possède un HttpClient. xUnit la crée une seule fois pour tous les tests d’une classe qui la demande avec IClassFixture. L’URL de base vient d’une variable d’environnement, pour que les mêmes tests puissent tourner plus tard contre votre propre 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);Les tests
Six tests : une liste, puis création, lecture, remplacement et suppression, puis les deux erreurs. Chaque test qui écrit crée son propre todo avec un titre unique et le supprime dans finally, si bien que les tests peuvent tourner dans n’importe quel ordre, en parallèle, et à côté de n’importe qui d’autre utilisant l’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)Règles pour tester contre une API partagée
- Créez ce sur quoi vous faites des assertions. Ne vérifiez pas que le todo 1 a un certain titre : quelqu’un vient peut-être de le changer. Créez un todo et vérifiez celui-là.
- Ne faites pas d’assertions sur les totaux.
X-Total-Countchange au gré des créations et suppressions des autres. Vérifiez qu’il est présent et plausible, pas qu’il vaut 30. - Faites le ménage. Supprimez ce qu’un test crée, dans
finally, pour qu’une assertion en échec ne laisse pas de lignes derrière elle. - Attendez-vous à ce que l’API tombe parfois. Un service public peut être lent ou en redémarrage. Un timeout côté client empêche un test bloqué de bloquer le build.
Les lancer dans GitHub Actions
D’abord les tests unitaires, puis les tests d’intégration, avec un timeout sur le job. Une planification quotidienne, en plus de chaque push, repère le jour où l’API dont vous dépendez change, plutôt que le jour de votre prochain 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.comTester la gestion des pannes par votre client
Les tests d’intégration du cas nominal ne font que la moitié du travail. La même API peut échouer à la demande : ajoutez ?error=503 ou ?delay=5 à n’importe quelle requête. Les guides sur la gestion du 503 en C#, les timeouts et cancellation tokens et le JSON mal formé en .NET fournissent chacun des tests xUnit pour ces cas.
Liste de vérification
- Un
HttpClientpartagé par classe de tests, avec un timeout. - L’URL de base vient de la configuration, pas du code.
- Chaque écriture utilise des données uniques et fait le ménage derrière elle.
- Les codes de statut et les corps d’erreur sont vérifiés, pas seulement les cas de succès.
- Les tests d’intégration portent un trait, pour que la CI puisse les lancer séparément et selon un planning.