3

ASP.NET Core Minimal API 整合測試

 1 year ago
source link: https://blog.darkthread.net/blog/testing-min-api/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

ASP.NET Core Minimal API 整合測試-黑暗執行緒

這篇聊聊 ASP.NET Core 的整合測試。

假設我寫了一個沒啥營養的展示用 Minimal API,其中宣告 GuidService 類別並用 DI 註冊成 Singleton (延伸閱讀:不可不知的 ASP.NET Core 依賴注入),MapGet("/guid") 時用它產生 GUID;MapGet("/") 時則在 Hello World 後方顯示來自 IConfiguration (appsettings.json) 的設定值 CodeName 及 Version。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<GuidService>();
var app = builder.Build();
app.MapGet("/", (IConfiguration config) => 
    $"Hello World from {config["CodeName"]}/{config["Version"]}");
app.MapGet("/guid", (GuidService guidService) => guidService.GetGuid());
app.Run();

public class GuidService
{
    private string _src;
    public GuidService(string src = "SUT")
    {
        _src = src;
    }
    public string GetGuid() => $"{_src}:{Guid.NewGuid()}";
}

我們可以為這個網站建個測試專案跑自動測試嗎?

不同於單元測試是針對單一類別、方法,整合測試著重於應用系統各環節是否能搭配運行,例如:資料庫、檔案系統、第三方服務... 等。以 ASP.NET Core 網站為例,最簡單的整合測試方式便是把網站跑起來,模擬 HTTP 客戶端呼叫網頁或 WebAPI,檢測回傳結果是否符合預期。

.NET 的單元測試專案也可以用來跑 ASP.NET Core 網站的整合測試,關鍵是加入 Microsoft.AspNetCore.Mvc.Testing 套件以輔助測試網站。在做整合測試時,被測試的網站對象習慣上被稱為 SUT (System Under Test),基本步驟是用 WebApplicationFactory 類別載入並啟動 SUT,Microsoft.AspNetCore.Mvc.Testing 套件會將 SUT 的相依檔案(.deps)、appsettings.json 等複製到測試專案的 /bin 目錄,並設定好 Content Root 及 Web Root 路徑執行網站。WebApplicationFactory.CreateClient() 可建立 HttpClient,這個 HttpClient 會自動識別網站的 Port,因此 GetAsync() 或 PostAsync() 只需提供相對路徑。說了這麼多,直接看程式大家就容易明白。

我建了一個測試解決方案,共有 MyMinApi (Minimal API 專案) 及 MinApiTest (微軟測試專案) 兩個專案:(延伸閱讀:MSTest,NUnit 3,xUnit.net 2.0 比較 by Yowko's Notes)

Fig1_638074340413697907.png

MyMinApi Program.cs 內容即文章開始的程式碼,但有個地方需要修改。由於 Minimal API 使用 Top-Level Statement,Program 被隱藏在背後並宣告為私有類別,無法被測試專案存取,故需加一行 public partial class Program { } 巧妙地轉為公開類別。

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<GuidService>();
    
var app = builder.Build();

app.MapGet("/", (IConfiguration config) =>
    $"Hello World from {config["CodeName"]}/{config["Version"]}");
app.MapGet("/guid", (GuidService guidService) => guidService.GetGuid());

app.Run();

// 增加 Program 類別公開宣告
public partial class Program { }

public class GuidService
{
    private string _src;
    public GuidService(string src = "SUT")
    {
        _src = src;
    }
    public string GetGuid() => $"{_src}:{Guid.NewGuid()}";
}

至於 MinApiTest,有兩個前置工作:

  1. 參照 MyMinApi 專案
  2. NuGet 下載安裝 Microsoft.AspNetCore.Mvc.Testing 套件

測試程式寫法如下。由於我不想每個測試都重新啟動一次網站,所以把 new WebApplicationFactory<Program>();放在 [ClassInitialize] 起始區,整個類別只跑一份供各 [TestMethod] 共用。測試時用 app.CreateClient() 建立 HttpClient,其餘比照標準 HttpClient 寫法。

using Microsoft.AspNetCore.Mvc.Testing;

namespace MinApiTest;

[TestClass]
public class IntegrationTests
{
    private static WebApplicationFactory<Program> app;

    [ClassInitialize]
    public static void Init(TestContext testcontext)
    {
        app = new WebApplicationFactory<Program>();
    }

    [TestMethod]
    public async Task TestHome()
    {
        var client = app.CreateClient();
        var resp = await client.GetStringAsync("/");
        Assert.AreEqual("Hello World from SUT/1.0", resp);
    }

    [TestMethod]
    public async Task TestGuid()
    {
        var client = app.CreateClient();
        var resp = await client.GetStringAsync("/guid");
        Assert.IsTrue(resp.StartsWith("SUT:"));
        Assert.IsTrue(Guid.TryParse(resp.Substring(4), out _));
    }
}

測試專案的 bin 目錄可看到 MyMinApi 編譯輸出的 .dll/.pdb/.exe 檔案以及 appsettings.json:

Fig5_638074349976058718.png

以上測試會依據 MyMinApi Program.cs 註冊的 GuidService 及 appsettings.json 啟動網站執行,而實務上我們可能要為測試改變設定,例如:連向自動測試專用的資料庫、DI 改註冊測試專用服務或元件... 等等。接下來介紹如何抽換 DI 服務及修改 IConfiguration 設定:

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace MinApiTest;

[TestClass]
public class CustIntTests
{
    public class CustWebAppFactory<TProgram> :
        WebApplicationFactory<TProgram> where TProgram : class
    {
        override protected void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services =>
            {
                // 移除原有註冊服務,換成自訂版本
                var svcDesc = services.FirstOrDefault(s => s.ServiceType == typeof(GuidService));
                if (svcDesc != null) services.Remove(svcDesc);
                services.AddSingleton<GuidService>((services) => new GuidService("TEST"));

                // 更改設定值
                svcDesc = services.First(s => s.ServiceType == typeof(IConfiguration));
                services.Remove(svcDesc);
                // 讀取原設定檔,並修改 Code
                var config = new ConfigurationBuilder()
                    .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
                    .AddInMemoryCollection(new Dictionary<string, string>() {
                        ["CodeName"] = "TEST"
                    })
                    .Build();
                services.AddSingleton<IConfiguration>((services) => config);
            });
        }
    }

    private static CustWebAppFactory<Program> app;

    [ClassInitialize]
    public static void Init(TestContext testContext)
    {
        app = new CustWebAppFactory<Program>();
    }

    [TestMethod]
    public async Task TestHome()
    {
        var client = app.CreateClient();
        var resp = await client.GetStringAsync("/");
        Assert.AreEqual("Hello World from TEST/1.0", resp);
    }

    [TestMethod]
    public async Task TestGuid()
    {
        var client = app.CreateClient();
        var resp = await client.GetStringAsync("/guid");
        Assert.IsTrue(resp.StartsWith("TEST:"));
        Assert.IsTrue(Guid.TryParse(resp.Substring(5), out _));
    }

}

重點在於繼承 WebApplicationFactory<TProgram> 實作一個自訂類別,在其中覆寫(Override) void ConfigureWebHost(IWebHostBuilder builder),這個 ConfigureWebHost() 會在 Program.cs 的 IWebHostBuilder 設定程序後執行,讓我們有機會修改 DI 註冊,在這個測試範例中我示範用 services.FirstOrDefault(s => s.ServiceType == typeof(GuidService); 找到原有的 GuidService 註冊,透過 services.Remove(svcDesc); 將之移除,再重新 AddSingleton() 註冊不同版本。更改 IConfiguration 設定部分,由於我只要改掉 CodeName 值其餘沿用 appsettings.json 的原設定,我採用的做法是先 AddJsonFile() 讀入 appsettings.json,再用 AddInMemoryCollection() 覆寫。

就這樣,我們就能在 Visual Studio 中對 ASP.NET Core 專案進行自動測試囉!

Fig3_638074340419112745.png

由於使用標準單元測試專案,Visual Studio 也支援邊測試邊偵錯,設定中斷點或 Line-By-Line 逐行執行,方便測試及修復問題,發揮地表最強開發工具的威力。

Fig4_638074340420971580.png

專案已放上 Github,有興趣的同學可以 Clone 回去玩。

【參考資料】


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK