xUnit による API の統合テスト
ライブの ToDo API に対する 6 つの xUnit テスト、共有 API をテストするときのルール、実行用の GitHub Actions ワークフロー。
例を API に対して最後に実行した日:
モックした HttpClient を使うユニットテストは、コードが思ったとおりに API を呼んでいることを証明します。統合テストは、 API が思ったとおりに応答することを証明します。本物のステータスコード、本物の JSON、本物の検証です。このガイドでは、ライブの REST API、つまりキー不要で書き込みを保存する https://api.testingapis.com の無料 ToDo API に対して、xUnit の統合テストスイートを作ります。作成・取得・更新・削除、検証エラーと 404、共有 API でテストを独立させる方法、GitHub Actions での実行までを扱います。ここにあるテストはすべて .NET 10 上の xUnit 2.9 で通りました。
テスト対象の API
| リクエスト | 成功 | エラー |
|---|---|---|
GET /api/Todos?limit=2 | 200、JSON 配列。X-Total-Count に総数 | |
GET /api/Todos/{id} | 200、その ToDo | 未知の id には 404 |
POST /api/Todos | 201、data に新しい ToDo | title がないと 400 |
PUT /api/Todos/{id} | 200、data に置き換えた ToDo | 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=2書き込みは共有サーバーのメモリに保存されます。誰でも読んだり変更したりでき、サーバーが再起動するとすべて初期データに戻ります。 テストの書き方はこれで決まります。
テストプロジェクトを準備する
dotnet new xunit -n TodoApi.IntegrationTests
cd TodoApi.IntegrationTests
dotnet test # the template's empty test passesHttpClient を 1 つ持つフィクスチャを追加します。xUnit は、IClassFixture でそれを求めるクラスのすべてのテストに対して、一度だけそれを作ります。ベース URL は環境変数から取るので、同じテストを後で 自分の 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);テスト
テストは 6 つです。一覧、続いて作成・取得・置き換え・削除、最後に 2 つのエラー。書き込むテストはそれぞれ一意のタイトルで 自分の ToDo を作り、finally で削除するので、テストはどの順番でも、並列でも、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)共有 API に対してテストするときのルール
- アサーションの対象は自分で作る。ToDo 1 が特定のタイトルだと検証しないこと。誰かがたった今変えたかもしれ ません。ToDo を作って、それを確かめてください。
- 総数をアサーションしない。
X-Total-Countはほかの人の作成や削除で変わります。30 に等しいかではなく、存在してもっともらしい値かを確かめてください。 - 後片付けをする。テストが作ったものは
finallyで削除し、アサーションが失敗しても行が残らないようにします。 - API がときどき落ちることを想定する。公開サービスは遅かったり再起動中だったりします。クライアントに タイムアウトを設定すれば、止まったテストのせいでビルド全体が止まることはありません。
GitHub Actions で実行する
先にユニットテスト、次に統合テストを、ジョブのタイムアウト付きで実行します。push のたびに加えて毎日のスケジュール実行も 入れておけば、次に push した日ではなく、依存している API が変わったその日に気づけます。
# .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.comクライアントの失敗時の扱いをテストする
正常系の統合テストは仕事の半分です。同じ API は好きなときに失敗させられます。どのリクエストにも ?error=503 や ?delay=5 を付けてください。C# での 503 の処理、タイムアウトと CancellationToken、.NET での不正な JSON のガイドには、それぞれのケースの xUnit テストが付いています。
チェックリスト
- テストクラスごとに共有の
HttpClientを 1 つ、タイムアウト付きで。 - ベース URL はコードではなく設定から取る。
- 書き込みはすべて一意のデータを使い、後片付けをする。
- 成功の経路だけでなく、ステータスコードとエラーボディもアサーションする。
- 統合テストに trait を付け、CI で別に、スケジュールでも実行できるようにする。