背景
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 流程中应该包含单元测试
单元测试覆盖率
新人问题:为何要写单元测试?
其实编程也如人生三境:看山是山;看山不是山;看山还是山;阶段不同,认知不同,唯有坚持不懈,持之以恒,才能不断进步,提升境界,这不就是人追求的根本么!