Asp.Net Core 单元测试正确姿势
背景
ASP.NET Core 支持依賴關系注入 (DI) 軟件設計模式,并且默認注入了很多服務,具體可以參考?官方文檔, 相信只要使用過依賴注入框架的同學,都會對此有不同深入的理解,在此無需贅言。
然而,在引入 IOC 框架之后,對于之前常規的對于類的依賴(new Class)變成通過構造函數對于接口的依賴(ASP.NET CORE 默認注入方式),這本身更加符合依賴倒置原則,但是對于單元測試來說確會帶來另一個問題:由于層層依賴,導致在某個類的方法進行測試的時候,需要構造一大堆該類依賴的接口的實現,非常麻煩。
這個時候,我們腦子里會下意識想一個問題:為什么常用的 .Net 單元測試框架不支持依賴注入?
于是筆者帶著這個問題在查閱了一些關于在單元測試中支持依賴注入的討論Github Issue,以及其他的相關文檔,突然明白一個之前一直忽視但實際卻非常重要的問題:
| 在對于一個方法的單元測試中,我們應該關注的是這個方法內部的邏輯測試,而這個方法內部對于外部的依賴,則不在這個單元測試關注的范圍內 |
換言之,單元測試永遠都只關注需要測試的方法內部的邏輯實現,至于外部依賴方法的測試,則應該放在另一個專門針對這個方法的單元測試用例中。弄清楚這個問題,我們才能更加理解另一個單元測試不可缺少的框架——Mock框架,在我們寫的測試中,應該忽略外部依賴具體的實現,而是通過模擬該接口方法來顯示的指定返回值,從而降低該返回值對于當前單元測試結果的影響,而 Mock 框架(例如最常用的Moq),剛好可以滿足我們對于接口的模擬需求。
相信有同學跟我有同樣的疑惑,并且當我嘗試在 ASP.NET CORE 單元測試中的一切外部依賴通過 Mock 的方式進行編寫的時候,遇到了一些問題,才有了本篇文章,希望對有同樣疑惑的同學有所幫助。
如何對 ASP.NET CORE 常用服務進行單元測試和 Mock
本文以 Xunit 以及 Moq 4.x 為例,展示在常用的 ASP.NET CORE 中會遇到的各種測試情況。
業務服務類示例如下:
public class UserService : IUserService {private ILogger _logger;private IOptions<RabbitMqOptions> _options;private IConfiguration _configuration;public UserService(ILogger<UserService> logger, IConfiguration configuration, IOptions<RabbitMqOptions> options){this._logger = logger;this._options = options;this._configuration = configuration;}public void Login(){var hostName = this._configuration["RabbitMqOptions:Host"];var options = this._options.Value;//do somethingthis._logger.Log(LogLevel.Information, new EventId(), "Login", null, (m, e) => m);}public string GetUserInfo(){return $"hello world!";} }public class RabbitMqOptions {public string Host { get; set; }public string UserName { get; set; }public string Password { get; set; } }1. IConfiguration 獲取配置Mock
獲取單個配置:
var mockConfiguration = new Mock<IConfiguration>(); mockConfiguration.SetupGet(_ => _["RabbitMqOptions:Host"]).Returns("127.0.0.1");Mock IOptions<T>
var mockRabbitmqOptions = new Mock<IOptions<RabbitMqOptions>>(); mockRabbitmqOptions.Setup(_ => _.Value).Returns(new RabbitMqOptions {Host = "127.0.0.1",UserName = "root",Password = "123456" });2. Mock 方法返回參數
[Fact] public void mock_return_test() {var mockInfo = "mock hello world";var mockUserService = new Mock<IUserService>();mockUserService.Setup(_ => _.GetUserInfo()).Returns(mockInfo);var userInfo= mockUserService.Object.GetUserInfo();Assert.Equal(mockInfo, userInfo); }3. ILogger 日志組件 Mock
通過 logger.Verify 驗證日志至少輸出一次:
[Fact] public void log_in_login_test() {var logger = new Mock<ILogger<UserService>>();var userService = new UserService(logger.Object);userService.Login();logger.Verify(_ => _.Log(It.IsAny<LogLevel>(),It.IsAny<EventId>(),It.IsAny<string>(),It.IsAny<Exception>(),It.IsAny<Func<string, Exception, string>>()),Times.Once); }4. ServiceCollection 單元測試
public static void AddUserService(this IServiceCollection services, IConfiguration configuration) {services.TryAddSingleton<IUserService, UserService>(); } [Fact] public void add_user_service_test() {var mockConfiguration = new Mock<IConfiguration>();var serviceConllection = new ServiceCollection();serviceConllection.AddUserService(mockConfiguration.Object);var provider = serviceConllection.BuildServiceProvider();var userService = provider.GetRequiredService<IUserService>();Assert.NotNull(userService); }5. Middleware 單元測試
Middleware單元測試重點在于對委托 _next 的模擬
public class HealthMiddleware {private readonly RequestDelegate _next;private readonly ILogger _logger;private readonly string _healthPath = "/health";public HealthMiddleware(RequestDelegate next, ILogger<HealthMiddleware> logger, IConfiguration configuration){this._next = next;this._logger = logger;var healthPath = configuration["Consul:HealthPath"];if (!string.IsNullOrEmpty(healthPath)){this._healthPath = healthPath;}}public async Task Invoke(HttpContext httpContext){if (httpContext.Request.Path == this._healthPath){httpContext.Response.StatusCode = (int)HttpStatusCode.OK;await httpContext.Response.WriteAsync("I'm OK!");}elseawait _next(httpContext);} }單元測試:
public class HealthMiddlewareTest {private readonly Mock<ILogger<HealthMiddleware>> _mockLogger;private readonly Mock<IConfiguration> _mockConfiguration;private readonly string _healthPath = "/health";private readonly HttpContext _httpContext;private readonly Mock<RequestDelegate> _mockNext; //middleware nextpublic HealthMiddlewareTest(){this._mockConfiguration = new Mock<IConfiguration>();this._mockConfiguration.SetupGet(c => c["Consul:HealthPath"]).Returns(_healthPath);this._mockLogger = new Mock<ILogger<HealthMiddleware>>();this._mockLogger.Setup(_ => _.Log<object>(It.IsAny<LogLevel>(), It.IsAny<EventId>(),It.IsAny<object>(), It.IsAny<Exception>(), It.IsAny<Func<object, Exception, string>>())).Callback<LogLevel, EventId, object, Exception, Func<object, Exception, string>>((logLevel, eventId, message, ex, fun) =>{Console.WriteLine($"{logLevel}\n{eventId}\n{message}\n{message}");});this._httpContext = new DefaultHttpContext();this._httpContext.Response.Body = new MemoryStream();this._httpContext.Request.Path = this._healthPath;this._mockNext = new Mock<RequestDelegate>();//next 委托 Mockthis._mockNext.Setup(_ => _(It.IsAny<HttpContext>())).Returns(async () =>{await this._httpContext.Response.WriteAsync("Hello World!"); //模擬http請求最終輸出});}[Fact]public async Task health_request_test(){var middleWare = new HealthMiddleware(this._mockNext.Object, this._mockLogger.Object,this._mockConfiguration.Object);await middleWare.Invoke(this._httpContext);//執行middlewarethis._httpContext.Response.Body.Seek(0, SeekOrigin.Begin); //獲取監控檢查請求獲取到的response內容var reader = new StreamReader(this._httpContext.Response.Body);var returnStrs = await reader.ReadToEndAsync();Assert.Equal("I'm OK!", returnStrs);//斷言健康檢查api是否中間件攔截輸出 "I'm OK!"}[Fact]public async Task general_request_test(){this._mockConfiguration.SetupGet(c => c["Consul:HealthPath"]).Returns("/api/values");var middleWare = new HealthMiddleware(this._mockNext.Object, this._mockLogger.Object,this._mockConfiguration.Object);await middleWare.Invoke(this._httpContext);this._httpContext.Response.Body.Seek(0, SeekOrigin.Begin);var reader = new StreamReader(this._httpContext.Response.Body);var returnStrs = await reader.ReadToEndAsync();Assert.Equal("Hello World!", returnStrs); //斷言非健康檢查請求api返回模擬 Hello World!} }6. Mock HttpClient
HttpClient 中的 GetAsync、PostAsync 等方法底層實際都是通過HttpMessageHandler 調用 SendAsync 完成(見源碼),所以在 Mock HttpClient 時,實際需要 Mock 的是 HttpMessageHandler 的 SendAsync 方法:
[Fact] public async Task get_async_test() {var responseContent = "Hello world!";var mockHttpClient = this.BuildMockHttpClient("https://github.com/", responseContent);var response = await mockHttpClient.GetStringAsync("/api/values");Assert.Equal(responseContent, response); }private HttpClient BuildMockHttpClient(string baseUrl, string responseStr) {var mockHttpMessageHandler = new Mock<HttpMessageHandler>();mockHttpMessageHandler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync",ItExpr.IsAny<HttpRequestMessage>(),ItExpr.IsAny<CancellationToken>()).ReturnsAsync((HttpRequestMessage request, CancellationToken token) =>{HttpResponseMessage response = new HttpResponseMessage();response.Content = new StringContent(responseStr, Encoding.UTF8);return response;});var mockHttpClient = new HttpClient(mockHttpMessageHandler.Object);mockHttpClient.BaseAddress = new Uri(baseUrl);return mockHttpClient; }結語
幾個問題:
CI/CD 流程中應該包含單元測試
單元測試覆蓋率
新人問題:為何要寫單元測試?
其實編程也如人生三境:看山是山;看山不是山;看山還是山;階段不同,認知不同,唯有堅持不懈,持之以恒,才能不斷進步,提升境界,這不就是人追求的根本么!
總結
以上是生活随笔為你收集整理的Asp.Net Core 单元测试正确姿势的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: NPOI 导出 excel 性能测试
- 下一篇: 程序员与「中台」的爱恨交错