在 ASP.NET Core 的整合測試我們可以使用 WebApplicationFactory、TestServer,這我前面幾篇已經提過了需要的可以參考之前的文章 WebApplicationFactory、TestServer。Middleware 用上述的步驟肯定是沒有問題的,但是需要的環境、步驟也比較多,可能還會因為其他 Middleware 順序所帶來的影響,今天我還要分享 ASP.NET Core內建 Mock HttpContext 做法,讓我們可以快速的針對某一個 Middleware 進行單元測試
回顧一下我以往 ASP.NET Core 有關 Middleware 的整合測試怎麼做的
- 開啟 ASP.NET Core 專案
- 寫 Middleware 並套用,參考 ASP.NET Core 中介軟體 | Microsoft Docs
- 建立 Controller/ Action,規劃 Route
- 在測試使用 WebApplicationFactory or TestServer 套用 Startup 把 Web 服務建立起來
- 用 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