ASP.NET Core 支持依赖关系注入 (DI) 软件设计模式,而且默认注入了不少服务,具体能够参考 官方文档, 相信只要使用过依赖注入框架的同窗,都会对此有不一样深刻的理解,在此无需赘言。git
然而,在引入 IOC 框架以后,对于以前常规的对于类的依赖(new Class)变成经过构造函数对于接口的依赖(ASP.NET CORE 默认注入方式),这自己更加符合依赖倒置原则,可是对于单元测试来讲确会带来另外一个问题:因为层层依赖,致使在某个类的方法进行测试的时候,须要构造一大堆该类依赖的接口的实现,很是麻烦。github
这个时候,咱们脑子里会下意识想一个问题:为何经常使用的 .Net 单元测试框架不支持依赖注入?
数据库
因而笔者带着这个问题在查阅了一些关于在单元测试中支持依赖注入的讨论Github Issue,以及其余的相关文档,忽然明白一个以前一直忽视但实际却很是重要的问题:编程
在对于一个方法的单元测试中,咱们应该关注的是这个方法内部的逻辑测试,而这个方法内部对于外部的依赖,则不在这个单元测试关注的范围内 |
换言之,单元测试永远都只关注须要测试的方法内部的逻辑实现,至于外部依赖方法的测试,则应该放在另外一个专门针对这个方法的单元测试用例中。弄清楚这个问题,咱们才能更加理解另外一个单元测试不可缺乏的框架——Mock框架,在咱们写的测试中,应该忽略外部依赖具体的实现,而是经过模拟该接口方法来显示的指定返回值,从而下降该返回值对于当前单元测试结果的影响,而 Mock 框架(例如最经常使用的Moq),恰好能够知足咱们对于接口的模拟需求。设计模式
相信有同窗跟我有一样的疑惑,而且当我尝试在 ASP.NET CORE 单元测试中的一切外部依赖经过 Mock 的方式进行编写的时候,遇到了一些问题,才有了本篇文章,但愿对有一样疑惑的同窗有所帮助。api
本文以 Xunit 以及 Moq 4.x 为例,展现在经常使用的 ASP.NET CORE 中会遇到的各类测试状况。框架
业务服务类示例以下:asp.net
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 something this._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; } }
获取单个配置:async
var mockConfiguration = new Mock<IConfiguration>(); mockConfiguration.SetupGet(_ => _["RabbitMqOptions:Host"]).Returns("127.0.0.1");
Mock IOptions<T>ide
var mockRabbitmqOptions = new Mock<IOptions<RabbitMqOptions>>(); mockRabbitmqOptions.Setup(_ => _.Value).Returns(new RabbitMqOptions { Host = "127.0.0.1", UserName = "root", Password = "123456" });
[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); }
经过 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); }
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); }
Middleware单元测试重点在于对委托 _next 的模拟
示例 HealthMiddleware:
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!"); } else await _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 next public 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 委托 Mock this._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);//执行middleware this._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! } }
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 流程中应该包含单元测试
例如在编写 Repository 层进行单元测试时,常常有同窗会编写依赖于数据库数据的单元测试,这样并不利于随时随地的进行单元测试检查,若是将该流程放在CI/CD中,在代码的发布过程当中经过单元测试能够检查代码逻辑的正确性,同时依赖于数据库的单元测试将不会经过(一般状况下,生产环境和开发环境隔离),变相迫使开发小伙伴经过 mock 方式模拟数据库返回结果。这个原则一样适用于不能依赖三方API编写单元测试。
单元测试覆盖率
一般不少开发 Leader 都会要求开发团队编写单元测试,可是不多检查单元测试的质量,即单元测试最重要的指标——单元测试代码覆盖率,若是不注重覆盖率的提高,那么颇有可能会致使开发成员为了单元测试而写单元测试,预期就会与实际状况相差甚远。保证单元测试代码覆盖率,将会大大下降代码变动带来的 Bug 率,从而节省总体开发成本。
新人问题:为什么要写单元测试?
对于初次开始编写单元测试的开发人员,脑中常常会对此表示怀疑:我为何要去验证一堆我本身写的正确的逻辑?实际这个问题包含了区分一个通常开发人员和优秀开发人员很重要的一个条件:他是否会反向思考当前逻辑的正确性。有了这种思惟,看待问题才会从多个角度入手分析,对问题的本质掌握更加全面。不要怀疑,坚持写单元测试,由于这自己也是对反向思惟的一种锻炼,以笔者的经验,只有当编写过一段时间以后,才会真正认识单元测试的魅力,而且开始很是习惯的在写一段逻辑以后,顺手写了对于它的单元测试。即便笔者也算很早就开始写单元测试了,但直到写这篇文章,仍然不断在加深对单元测试的认识。
其实编程也如人生三境:看山是山;看山不是山;看山仍是山;阶段不一样,认知不一样,惟有坚持不懈,锲而不舍,才能不断进步,提高境界,这不就是人追求的根本么!