Testes de integração de API com xUnit
Seis testes xUnit contra uma API de tarefas ao vivo, as regras para testar uma API compartilhada e um workflow do GitHub Actions para rodá-los.
Exemplos executados pela última vez contra a API em
Testes unitários com um HttpClient simulado provam que o seu código chama a API do jeito que você imagina. Testes de integração provam que a API responde do jeito que você imagina: códigos de status reais, JSON real, validação real. Este guia monta uma suíte de testes de integração com xUnit contra uma API REST ao vivo, a API gratuita de tarefas em https://api.testingapis.com, que não precisa de chave e salva as escritas. Ele cobre criar, ler, atualizar e apagar, erros de validação e 404, como manter os testes independentes numa API compartilhada e como rodá-los no GitHub Actions. Todos os testes daqui passaram com xUnit 2.9 no .NET 10.
A API testada
| Requisição | Sucesso | Erros |
|---|---|---|
GET /api/Todos?limit=2 | 200, um array JSON; X-Total-Count traz o total | |
GET /api/Todos/{id} | 200, a tarefa | 404 para um id desconhecido |
POST /api/Todos | 201, a tarefa nova em data | 400 sem title |
PUT /api/Todos/{id} | 200, a tarefa substituída em 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=2As escritas ficam em memória num servidor compartilhado: qualquer pessoa pode lê-las e alterá-las, e tudo volta aos dados iniciais quando o servidor reinicia. Isso define como os testes são escritos.
Preparar o projeto de testes
dotnet new xunit -n TodoApi.IntegrationTests
cd TodoApi.IntegrationTests
dotnet test # the template's empty test passesAdicione uma fixture dona de um HttpClient. O xUnit a cria uma vez para todos os testes de uma classe que a peça com IClassFixture. A URL base vem de uma variável de ambiente, para que os mesmos testes possam rodar depois contra a sua própria 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);Os testes
Seis testes: uma listagem, depois criar, ler, substituir e apagar, e por fim os dois erros. Cada teste que escreve cria a própria tarefa com um título único e a apaga no finally, então os testes rodam em qualquer ordem, em paralelo e ao lado de qualquer outra pessoa usando a 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)Regras para testar contra uma API compartilhada
- Crie aquilo sobre o que você faz asserções. Não verifique se a tarefa 1 tem determinado título: alguém pode ter acabado de mudá-lo. Crie uma tarefa e confira essa.
- Não faça asserções sobre totais.
X-Total-Countmuda conforme outros criam e apagam. Confira se ele está lá e é plausível, não se é igual a 30. - Limpe. Apague o que um teste cria, no
finally, para que uma asserção que falhe não deixe linhas para trás. - Conte com a API fora do ar às vezes. Um serviço público pode estar lento ou reiniciando. Um timeout no cliente impede que um teste travado trave o build.
Rodar no GitHub Actions
Primeiro os testes unitários, depois os de integração, com um timeout no job. Um agendamento diário, além de cada push, pega o dia em que a API de que você depende muda, e não o dia do seu próximo 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.comTestar como o seu cliente lida com falhas
Testes de integração do caminho feliz são metade do trabalho. A mesma API pode falhar quando você quiser: adicione ?error=503 ou ?delay=5 a qualquer requisição. Os guias sobre tratamento de 503 em C#, timeouts e cancellation tokens e JSON malformado no .NET trazem testes xUnit para esses casos.
Checklist
- Um
HttpClientcompartilhado por classe de testes, com timeout. - A URL base vem da configuração, não do código.
- Toda escrita usa dados únicos e limpa depois de si.
- Códigos de status e corpos de erro são verificados, não só os caminhos de sucesso.
- Os testes de integração têm um trait, para que o CI possa rodá-los separadamente e de forma agendada.