用 xUnit 编写 API 集成测试

针对线上待办 API 的六个 xUnit 测试、测试共享 API 的规则,以及运行它们的 GitHub Actions 工作流。

示例最后一次针对 API 运行于

使用模拟 HttpClient 的单元测试证明你的代码按你设想的方式调用 API。集成测试则证明 API 按你设想的方式响应:真实的状态码、真实的 JSON、真实的校验。本指南针对一个线上 REST API,即 https://api.testingapis.com 上免费、无需密钥且会保存写入的待办 API,构建一套 xUnit 集成测试。内容包括创建、读取、更新和删除,校验错误和 404,如何在共享 API 上保持测试相互独立,以及在 GitHub Actions 中运行它们。这里的每个测试都在 .NET 10 上用 xUnit 2.9 通过。

被测试的 API

请求成功错误
GET /api/Todos?limit=2200,一个 JSON 数组;X-Total-Count 中是总数
GET /api/Todos/{id}200,该待办未知 id 返回 404
POST /api/Todos201,新待办在 data 中缺少 title 返回 400
PUT /api/Todos/{id}200,被替换后的待办在 data 中404
DELETE /api/Todos/{id}200404
Terminal
$ 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":""}}
GET/api/Todos?limit=2

写入保存在共享服务器的内存中:任何人都能读取和修改,服务器重启后一切恢复为初始数据。这决定了测试的写法。

搭建测试项目

Terminal
dotnet new xunit -n TodoApi.IntegrationTests
cd TodoApi.IntegrationTests
dotnet test   # the template's empty test passes

添加一个持有 HttpClient 的 fixture。对于通过 IClassFixture 请求它的类,xUnit 只会为该类的所有测试创建一次。基础 URL 来自环境变量,这样同一套测试以后也能针对你自己的 API 运行。

ApiFixture.cs
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);

测试

共六个测试:一个列表测试,然后是创建、读取、替换和删除,最后是两个错误测试。每个会写入的测试都用唯一的标题创建自己的待办,并在 finally 中删除它,所以这些测试可以按任意顺序、并行运行,也不受其他 API 使用者的影响。

TodosApiTests.cs
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 测试的规则

  • 自己创建要断言的对象。不要断言待办 1 有某个标题:别人可能刚刚改过它。创建一个待办,然后检查这个。
  • 不要断言总数。X-Total-Count 会随着别人的创建和删除而变化。检查它存在且合理,而不是等于 30。
  • 收拾干净。在 finally 中删除测试创建的内容,这样断言失败时也不会留下数据。
  • 要预料到 API 偶尔会不可用。公共服务可能很慢或正在重启。客户端上的超时可以防止一个挂起的测试拖住整个构建。

在 GitHub Actions 中运行

先运行单元测试,再运行集成测试,并给作业设置超时。除了每次 push 之外再加一个每日定时任务,就能在你依赖的 API 发生变化的当天发现问题,而不是等到你下次 push 的时候。

YAML
# .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 处理、超时与取消令牌和.NET 中格式错误的 JSON 的指南,都为这些情况提供了 xUnit 测试。

检查清单

  • 每个测试类共享一个 HttpClient,并设置超时。
  • 基础 URL 来自配置,而不是写死在代码里。
  • 每次写入都使用唯一的数据,并在之后自行清理。
  • 断言状态码和错误响应体,而不只是成功路径。
  • 集成测试带有 trait,这样 CI 可以单独并按计划运行它们。