這篇聊聊 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)

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:

以上測試會依據 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 專案進行自動測試囉!

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

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

【參考資料】

Tutorial for how to do integration tests on ASP.NET Core minimal API.


Comments

Be the first to post a comment

Post a comment