跳至主要內容

測試 ​

每次改完程式碼,都手動發一遍 curl,很容易漏掉某個錯誤分支。這一章把“建立任務,再讀回來核對”寫成整合測試(integration test):讓一次請求經過路由、驗證、身分驗證、處理常式和 SQLite,然後檢查結果。

先看第一個完整測試檔案。API 沿用第 20 章,本章新增 Tests 專案:

Tests/CreateTodoTests.cs
cs
using System.Net;
using System.Net.Http.Json;

public class CreateTodoTests
{
    [Fact]
    public async Task Create_then_read_returns_saved_todo()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient(editor: true);
        var ct = TestContext.Current.CancellationToken;

        var created = await client.PostAsJsonAsync("/todos", new { title = "Buy milk", categoryId = 1 }, ct);

        Assert.Equal(HttpStatusCode.Created, created.StatusCode);
        Assert.Equal("/todos/1", created.Headers.Location?.ToString());
        var saved = await client.GetFromJsonAsync<TodoResponse>(created.Headers.Location, ct);
        Assert.NotNull(saved);
        Assert.Equal(new TodoResponse(1, "Buy milk", false, 1), saved);
    }
}

這個檔案需要下面的測試工廠和專案設定,均已包含在儲存庫中。

完整測試工廠與專案設定
cs
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.IdentityModel.Tokens;

public sealed class TodoApiFactory : WebApplicationFactory<Program>
{
    private readonly SqliteConnection _connection = new("Data Source=:memory:");
    private readonly SymmetricSecurityKey _key = new(RandomNumberGenerator.GetBytes(32));

    public TodoApiFactory() => _connection.Open();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Development");
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<DbContextOptions<TodoDbContext>>();
            services.RemoveAll<IDbContextOptionsConfiguration<TodoDbContext>>();
            services.AddDbContext<TodoDbContext>(options => options.UseSqlite(_connection));
            services.PostConfigure<JwtBearerOptions>("Bearer", options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidIssuer = "test-issuer",
                    ValidateAudience = true,
                    ValidAudience = "test-api",
                    ValidateLifetime = true,
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = _key,
                    ClockSkew = TimeSpan.Zero
                };
            });
        });
    }

    public HttpClient CreateUserClient(bool editor = false)
    {
        var claims = new List<Claim> { new(ClaimTypes.Name, "test-user") };
        if (editor) claims.Add(new Claim(ClaimTypes.Role, "editor"));
        var token = new JwtSecurityToken("test-issuer", "test-api", claims,
            expires: DateTime.UtcNow.AddMinutes(5),
            signingCredentials: new SigningCredentials(_key, SecurityAlgorithms.HmacSha256));
        var client = CreateClient();
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
            "Bearer", new JwtSecurityTokenHandler().WriteToken(token));
        return client;
    }

    public override async ValueTask DisposeAsync()
    {
        await base.DisposeAsync();
        await _connection.DisposeAsync();
    }
}
xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsTestProject>true</IsTestProject>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
    <PackageReference Include="xunit.v3.mtp-v2" Version="4.0.1" />
    <Using Include="Xunit" />
    <ProjectReference Include="../Testing.csproj" />
  </ItemGroup>
</Project>
json
{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}
cs
// Lets the separate test project reference the entry-point type generated from top-level statements.
public partial class Program;
xml
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>aspnetcore-first-steps-21-testing</UserSecretsId>
  </PropertyGroup>
  <ItemGroup>
    <Compile Remove="Tests/**/*.cs" />
    <Content Remove="Tests/**" />
    <None Remove="Tests/**" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
    <PackageReference Include="Scalar.AspNetCore" Version="2.17.10" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
  </ItemGroup>
</Project>

執行與驗證 ​

從儲存庫根目錄執行:

bash
cd samples/21-testing
dotnet test --project Tests/TodoApi.Tests.csproj

不需要先 dotnet run,也不需要產生開發 JWT。摘要中的結果應為下面這樣,耗時和完整路徑因機器而異:

text
测试运行摘要: 已通过!
  总计: 11
  失败: 0
  成功: 11
  已跳过: 0

範例使用 xUnit 測試框架,並在本章 global.json 中選擇 .NET 10 的 Microsoft Testing Platform(MTP) 測試執行器。因此指令使用 --project 指定測試專案;請從本章目錄執行,讓 SDK 找到這份設定。xUnit 套件名稱中的 v3 是產品系列名,與套件版本號不必相同。xUnit 入門

一條測試檢查什麼 ​

[Fact] 標出一條測試。方法名描述要驗證的行為:建立成功後,可以讀取儲存的資料。方法分三步:

  1. 建立測試應用程式,取得帶 editor 身分的 HttpClient。
  2. 向 /todos 傳送 JSON 請求。
  3. 用斷言(assertion)核對狀態碼、Location,以及再次讀取的任務內容。

PostAsJsonAsync 把物件序列化為 JSON,並設定請求的 Content-Type;GetFromJsonAsync<TodoResponse> 將回應反序列化為指定型別。這裡使用前面已有的 DTO,不再手動解析 JSON 字串。

為什麼不只檢查 201?處理常式可能回傳了成功,卻沒有儲存任務,或者 Location 指向了錯誤的編號。再讀一次,才能確認用戶端拿到的地址確實可用。

TestContext.Current.CancellationToken 來自測試執行器,取消測試時可以中止尚未完成的 HTTP 操作。using 和 await using 則在測試結束後釋放用戶端、測試應用程式及資料庫連線。

WebApplicationFactory 做了什麼 ​

WebApplicationFactory<Program> 建立測試宿主,用測試伺服器處理 HttpClient 發出的請求,不佔用真實的 5080 連接埠。Program 指應用程式入口;TestAccess.cs 中的公開部分宣告讓另一個專案能引用這個型別,不會新增 HTTP 端點。

Microsoft.AspNetCore.Mvc.Testing 雖然名字裡有 Mvc,同樣可以測試 Minimal API,不需要引入控制器。ASP.NET Core 整合測試

測試工廠替換了兩項設定:

設定測試中的處理原因
資料庫每個工廠單獨開啟一個 SQLite 記憶體連線不讀寫練習用的資料庫檔案,測試之間不會爭搶編號
JWT每個工廠產生隨機簽名金鑰,建立短期測試權杖不依賴本機 User Secrets,也不用連線外部身分服務

這裡仍然使用真實的 SQLite 提供者和 JWT 驗證處理器。只是更換資料庫位置和可信簽發者設定,沒有把“允許存取”寫死。工廠裡的簽發程式碼僅屬於測試專案,不是登入 API。

技術細節

SQLite 記憶體資料庫在連線關閉時消失,所以工廠先開啟連線,等測試應用程式釋放後再關閉。還要移除舊的 DbContextOptions 及其設定註冊,避免兩套連線設定一起生效。同一個工廠中的請求共享這份測試庫;本例每條測試建立自己的工廠,並順序傳送請求。

錯誤分支也要檢查 ​

下面是其餘測試的完整檔案:

驗證、身分驗證、角色與更新刪除測試
Tests/TodoApiTests.cs
cs
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;

public class TodoApiTests
{
    [Theory]
    [InlineData("")]
    [InlineData(" ")]
    public async Task Invalid_title_does_not_insert(string title)
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient(editor: true);
        var ct = TestContext.Current.CancellationToken;
        var response = await client.PostAsJsonAsync("/todos", new { title, categoryId = 1 }, ct);
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        var items = await client.GetFromJsonAsync<TodoResponse[]>("/todos", ct);
        Assert.NotNull(items);
        Assert.Empty(items);
    }

    [Fact]
    public async Task Unknown_category_does_not_insert()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient(editor: true);
        var ct = TestContext.Current.CancellationToken;
        var response = await client.PostAsJsonAsync("/todos", new { title = "Buy milk", categoryId = 99 }, ct);
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.Empty((await client.GetFromJsonAsync<TodoResponse[]>("/todos", ct))!);
    }

    [Fact]
    public async Task Missing_todo_returns_404()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient();
        var response = await client.GetAsync("/todos/99", TestContext.Current.CancellationToken);
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }

    [Fact]
    public async Task Anonymous_request_returns_401()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateClient();
        var response = await client.GetAsync("/todos", TestContext.Current.CancellationToken);
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task Invalid_token_returns_401()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateClient();
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid");
        var response = await client.GetAsync("/todos", TestContext.Current.CancellationToken);
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Theory]
    [InlineData("POST")]
    [InlineData("PUT")]
    [InlineData("DELETE")]
    public async Task Reader_cannot_write(string method)
    {
        await using var app = new TodoApiFactory();
        using var editor = app.CreateUserClient(editor: true);
        using var reader = app.CreateUserClient();
        var ct = TestContext.Current.CancellationToken;
        var created = await editor.PostAsJsonAsync("/todos", new { title = "Keep me", categoryId = 1 }, ct);
        Assert.Equal(HttpStatusCode.Created, created.StatusCode);
        using var request = new HttpRequestMessage(new HttpMethod(method), method == "POST" ? "/todos" : "/todos/1")
        {
            Content = JsonContent.Create(new { title = "Changed", done = true, categoryId = 2 })
        };
        var response = await reader.SendAsync(request, ct);
        Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
        var saved = await reader.GetFromJsonAsync<TodoResponse>("/todos/1", ct);
        Assert.Equal(new TodoResponse(1, "Keep me", false, 1), saved);
        Assert.Single((await reader.GetFromJsonAsync<TodoResponse[]>("/todos", ct))!);
    }

    [Fact]
    public async Task Editor_can_replace_and_delete()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient(editor: true);
        var ct = TestContext.Current.CancellationToken;
        var created = await client.PostAsJsonAsync("/todos", new { title = "Buy milk", categoryId = 1 }, ct);
        Assert.Equal(HttpStatusCode.Created, created.StatusCode);
        var updated = await client.PutAsJsonAsync("/todos/1", new { title = "Bought milk", done = true, categoryId = 2 }, ct);
        Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
        Assert.Equal(new TodoResponse(1, "Bought milk", true, 2), await client.GetFromJsonAsync<TodoResponse>("/todos/1", ct));
        Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync("/todos/1", ct)).StatusCode);
        Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/todos/1", ct)).StatusCode);
    }
}

[Theory] 配合 [InlineData],讓同一段測試分別使用多組輸入。空標題有兩組,禁止寫入有 POST、PUT、DELETE 三組,所以測試方法數與最終用例數不同。

這些測試檢查的不只是狀態碼:

  • 標題為空或分類不存在:回傳 400,清單仍然為空。
  • 編號不存在:回傳 404。
  • 沒有權杖或權杖無效:回傳 401。
  • 普通讀者嘗試建立、修改、刪除:回傳 403,原任務和條數不變。
  • 編輯者修改後重新讀取,再刪除並查詢:依次確認儲存成功和 404。

故意改錯一次 ​

在本章 Program.cs 的 POST 註冊末尾,臨時移除 RequireAuthorization("CanWriteTodos"),保留群組上的身分驗證要求,再執行測試。

Reader_cannot_write 的 POST 用例應失敗:預期是 Forbidden(403),實際卻變成 Created(201)。這說明普通讀者獲得了寫權限。把策略呼叫恢復,11 個用例應重新全部透過。

這樣,第 22 章拆分檔案時就有了檢查依據:檔案可以換位置,用戶端看到的行為應保持一致。

注意

這些是 API 整合測試,不會驗證瀏覽器是否執行 CORS,也不會驗證反向代理、TLS 或外部身分服務的登入流程。涉及瀏覽器和部署的行為仍要在相應環境檢查。

FastAPI 對照

這類似用 pytest 和 TestClient 呼叫 FastAPI 應用程式,再斷言狀態碼和 JSON。這裡由 WebApplicationFactory 建立測試應用程式,測試工廠負責替換資料庫與身分驗證設定。

總結 ​

  • 整合測試透過請求驗證多個元件一起工作的結果,不必手動啟動 API。
  • [Fact] 寫單個案例,[Theory] 用同一段程式碼驗證多組輸入。
  • 成功回應要檢查內容和後續讀取,拒絕寫入還要檢查資料沒有被修改。
  • 每條測試使用獨立資料庫和測試簽名金鑰,結果不依賴執行順序。
  • 重構前先跑通測試,改完再跑,及時發現回應和權限變化。

下一章:依功能組織專案——把同一功能的程式碼放在一起。上一章:CORS。

採用 .NET 10 與 Minimal API · 所有範例皆可直接 dotnet run