Integration test API dengan xUnit
Enam tes xUnit terhadap API todo langsung, aturan menguji API bersama, dan workflow GitHub Actions untuk menjalankannya.
Contoh terakhir dijalankan terhadap API pada
Unit test dengan HttpClient tiruan membuktikan kode Anda memanggil API seperti yang Anda kira. Integration test membuktikan API menjawab seperti yang Anda kira: kode status sungguhan, JSON sungguhan, validasi sungguhan. Panduan ini membangun suite integration test xUnit terhadap REST API langsung, yaitu API todo gratis di https://api.testingapis.com yang tidak butuh key dan menyimpan penulisan. Isinya mencakup membuat, membaca, mengubah, dan menghapus, error validasi dan 404, cara menjaga tes tetap independen di API bersama, dan menjalankannya di GitHub Actions. Setiap tes di sini lulus dengan xUnit 2.9 di .NET 10.
API yang diuji
| Request | Sukses | Error |
|---|---|---|
GET /api/Todos?limit=2 | 200, array JSON; X-Total-Count berisi totalnya | |
GET /api/Todos/{id} | 200, todo-nya | 404 untuk id yang tidak dikenal |
POST /api/Todos | 201, todo baru di data | 400 tanpa title |
PUT /api/Todos/{id} | 200, todo yang diganti di 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=2Penulisan disimpan di memori server bersama: siapa pun bisa membaca dan mengubahnya, dan semuanya kembali ke data awal saat server dimulai ulang. Itulah yang menentukan cara tes ditulis.
Menyiapkan proyek tes
dotnet new xunit -n TodoApi.IntegrationTests
cd TodoApi.IntegrationTests
dotnet test # the template's empty test passesTambahkan fixture yang memiliki satu HttpClient. xUnit membuatnya sekali untuk semua tes di kelas yang memintanya lewat IClassFixture. Base URL diambil dari environment variable, agar tes yang sama nanti bisa dijalankan terhadap API Anda sendiri.
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);Tes-tesnya
Enam tes: satu daftar, lalu membuat, membaca, mengganti, dan menghapus, lalu dua error. Setiap tes yang menulis membuat todo-nya sendiri dengan judul unik, dan menghapusnya di finally, sehingga tes bisa berjalan dalam urutan apa pun, paralel, dan berdampingan dengan siapa pun yang memakai 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)Aturan menguji terhadap API bersama
- Buat sendiri apa yang Anda periksa. Jangan memeriksa bahwa todo 1 punya judul tertentu: bisa jadi seseorang baru saja mengubahnya. Buat sebuah todo dan periksa yang itu.
- Jangan memeriksa total.
X-Total-Countberubah saat orang lain membuat dan menghapus. Periksa bahwa ia ada dan masuk akal, bukan bahwa nilainya 30. - Bersihkan. Hapus apa yang dibuat tes, di
finally, agar asersi yang gagal tidak meninggalkan baris data. - Perhitungkan API yang kadang mati. Layanan publik bisa lambat atau sedang dimulai ulang. Timeout di klien mencegah tes yang macet membuat build ikut macet.
Menjalankannya di GitHub Actions
Unit test lebih dulu, lalu integration test, dengan timeout untuk job-nya. Jadwal harian selain di setiap push akan menangkap hari ketika API yang Anda andalkan berubah, bukan hari ketika Anda melakukan push berikutnya.
# .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.comMenguji cara klien Anda menangani kegagalan
Integration test untuk jalur normal baru separuh pekerjaan. API yang sama bisa gagal sesuai permintaan: tambahkan ?error=503 atau ?delay=5 ke request apa pun. Panduan tentang penanganan 503 di C#, timeout dan cancellation token, dan JSON rusak di .NET masing-masing dilengkapi tes xUnit untuk kasus-kasus itu.
Daftar periksa
- Satu
HttpClientbersama per kelas tes, dengan timeout. - Base URL diambil dari konfigurasi, bukan dari kode.
- Setiap penulisan memakai data unik dan membersihkan dirinya sendiri.
- Kode status dan body error diperiksa, bukan hanya jalur sukses.
- Integration test diberi trait, agar CI bisa menjalankannya terpisah dan sesuai jadwal.