ASP.NET Core 6 Middleware 的單元測試

在 ASP.NET Core 的整合測試我們可以使用 WebApplicationFactory、TestServer,這我前面幾篇已經提過了需要的可以參考之前的文章 WebApplicationFactoryTestServer。Middleware 用上述的步驟肯定是沒有問題的,但是需要的環境、步驟也比較多,可能還會因為其他 Middleware 順序所帶來的影響,今天我還要分享 ASP.NET Core內建 Mock HttpContext 做法,讓我們可以快速的針對某一個 Middleware 進行單元測試

回顧一下我以往 ASP.NET Core 有關 Middleware 的整合測試怎麼做的

  1. 開啟 ASP.NET Core 專案
  2. 寫 Middleware 並套用,參考 ASP.NET Core 中介軟體 | Microsoft Docs
  3. 建立 Controller/ Action,規劃 Route
  4. 在測試使用 WebApplicationFactory or TestServer 套用 Startup 把 Web 服務建立起來
  5. 用 TestServer.CreateClient() 訪問 TestServer

開發環境

  • .NET 6
  • Rider 2022.1.2
  • Microsoft.AspNetCore.Mvc.Testing 6.0.3
  • SystemTextJson.JsonDiffPatch.MSTest 1.3.0

新增 Middleware

新增一個 Lib 專案,安裝以下套件

dotnet add package Microsoft.AspNetCore.Http.Abstractions --version 2.2.0

 

加入以下程式碼,這是用來檢查必要的 Header 是否存在,不存在的話就離開,不往下一關 Middleware,並響應給調用端錯誤訊息

using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;

namespace Lab.AspNetCoreMiddleware;

public class ValidateRequiredHeaderMiddleware
{
    private readonly string[] _requireHeaderNames =
    {
        HeaderNames.UserId,
        HeaderNames.Code,
    };

    private readonly RequestDelegate _next;

    public ValidateRequiredHeaderMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context,
        JsonSerializerOptions jsonSerializerOptions)
    {
        var failureResults = new List<FailureResult>();
        foreach (var name in this._requireHeaderNames)
        {
            if (context.Request.Headers.TryGetValue(name, out var value) == false)
            {
                failureResults.Add(new FailureResult
                {
                    Code = FailureCode.INVALID_FORMAT.ToString(),
                    PropertyName = name,
                    Messages = $"The '{name}' header is required.",
                });
            }
            else
            {
                if (name == HeaderNames.Code)
                {
                    if (long.TryParse(value, out var code) == false)
                    {
                        failureResults.Add(new FailureResult
                        {
                            Code = FailureCode.INVALID_TYPE.ToString(),
                            PropertyName = name,
                            Value = value,
                            Messages = $"'{value}' not numbers",
                        });
                    }
                }
            }
        }

        if (failureResults.Count > 0)
        {
            var failure = new Failure
            {
                Code = FailureCode.INVALID_REQUEST.ToString(),
                Messages = failureResults
            };
            var failureJson = JsonSerializer.Serialize(failure, jsonSerializerOptions);
            context.Response.StatusCode = 400;
            context.Response.ContentType = "application/json";
            await context.Response.WriteAsync(failureJson, Encoding.UTF8, context.RequestAborted);
            return;
        }

        await this._next(context);
    }
}

有關 Middleware 請 參考

Test

新增一個 Test 專案,安裝以下套件

dotnet add package Microsoft.AspNetCore.TestHost --version 6.0.6


建立一個 Host,有關 Host 的建立方式可以參考 如何使用 .NET Generic Host for Microsoft.Extensions.Hosting

using var host = await new HostBuilder()
	.ConfigureWebHost(webBuilder =>
	{
		webBuilder
			.UseTestServer()
			.ConfigureServices(services =>
			{
				// 註冊 DI Container
			})
			.Configure(app =>
			{
				app.UseMiddleware<ValidateRequiredHeaderMiddleware>();
			});
	})
	.StartAsync();

接下來有兩者選擇,一個是 GetTestServer 另一個是 GetTestClient,

Host.CreateTestClient

用法就跟一般的 HttpClient 一樣,差異在於它不需要有真實的端點 Endpoint 存在

var response = await host.GetTestClient().SendAsync();

 

詳細用法如下

[TestMethod]
public async Task HeaderCode型別錯誤會驗證失敗()
{
	var expected = @"
{
""code"": ""INVALID_REQUEST"",
""messages"": [
{
  ""code"": ""INVALID_TYPE"",
  ""propertyName"": ""X-Code"",
  ""messages"": ""'abc' not numbers"",
  ""value"": ""abc""
}
]
}
";
	using var httpClient = await CreateTestClient();
	var request = new HttpRequestMessage(HttpMethod.Get, "/青菜");
	request.Headers.Add(HeaderNames.UserId, "yao");
	request.Headers.Add(HeaderNames.Code, "abc");
	var response = await httpClient.SendAsync(request);
	var actual = await response.Content.ReadAsStringAsync();
	Assert.That.JsonAreEqual(expected, actual, true);
}

 

Host.CreateTestServer

建立 TestServer 之後是送出 HttpContext 

var response = await  host.GetTestServer().SendAsync();

 

資料最後會被放在 HttpContext.Response.Body,需要從這裡下手取得

[TestMethod]
public async Task HeaderCode型別錯誤會驗證失敗()
{
	var expected = @"
{
""code"": ""INVALID_REQUEST"",
""messages"": [
{
  ""code"": ""INVALID_TYPE"",
  ""propertyName"": ""X-Code"",
  ""messages"": ""'abc' not numbers"",
  ""value"": ""abc""
}
]
}
";
	using var testServer = await CreateTestServer();
	var httpContext = await testServer.SendAsync(context =>
	{
		context.Request.Headers[HeaderNames.UserId] = "yao";
		context.Request.Headers[HeaderNames.Code] = "abc";
	});
	var response = httpContext.Response;
	var stream = response.Body;

	var actual = await new StreamReader(stream).ReadToEndAsync();
	Assert.That.JsonAreEqual(expected, actual, true);
}

 

兩個都可以完成對 Middleware 的測試,但不需要真的有 Endpoint 存在

DefaultHttpContext

以往,我們在 .NET Fx 處理有關 HttpContext 的單元測試時,需要自己實作 mock HttpContext,

現在,DefaultHttpContext 繼承了HttpContext,同等於內建 mock HttpContext,我們可以使用它進行 Middleware 的單元測試

  • 實例化 DefaultHttpContext,並指定要使用哪一種 Stream
var httpContext = new DefaultHttpContext()
{
	Response = { Body = new MemoryStream()}
};
  • 實例化 ValidateRequiredHeaderMiddleware,然後調用 InvokeAsync
var target = new ValidateRequiredHeaderMiddleware((_) => Task.CompletedTask);
await target.InvokeAsync(httpContext, jsonSerializerOptions);

 

  • 讀取 HttpContext.Response.Body,這裡需要注意的是 MemoryStream 位置(position)已經在尾端,需要手動設定開始位置SeekOrigin.Begin
var stream = httpContext.Response.Body;
if (stream.CanSeek)
{
	stream.Seek(0, SeekOrigin.Begin);
}

 

完整代碼如下:

[TestMethod]
public async Task HeaderCode型別錯誤會驗證失敗()
{
	var expected = @"
{
""code"": ""INVALID_REQUEST"",
""messages"": [
{
  ""code"": ""INVALID_TYPE"",
  ""propertyName"": ""X-Code"",
  ""messages"": ""'abc' not numbers"",
  ""value"": ""abc""
}
]
}
";
	var jsonSerializerOptions = CreateJsonSerializerOptions();
	var httpContext = new DefaultHttpContext()
	{
		Response = { Body = new MemoryStream()}
	};
	httpContext.Request.Headers[HeaderNames.UserId] = "yao";
	httpContext.Request.Headers[HeaderNames.Code] = "abc";
	var target = new ValidateRequiredHeaderMiddleware((_) => Task.CompletedTask);
	await target.InvokeAsync(httpContext, jsonSerializerOptions);
	var response = httpContext.Response;
	var stream = response.Body;
	if (stream.CanSeek)
	{
		stream.Seek(0, SeekOrigin.Begin);
	}

	var actual = await new StreamReader(stream).ReadToEndAsync();
	Assert.That.JsonAreEqual(expected, actual, true);
}

 

範例位置

sample.dotblog/Test/Lab.AspNetCoreMiddleware at master · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo