背景

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 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; }
}

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(); this._mockLogger.Verify(_ => _.Log<It.IsAnyType>(It.IsAny<LogLevel>(),
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, 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 的模拟

示例 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!
}
}

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;
}

结语

几个问题:

  1. CI/CD 流程中应该包含单元测试

    例如在编写 Repository 层进行单元测试时,经常有同学会编写依赖于数据库数据的单元测试,这样并不利于随时随地的进行单元测试检查,如果将该流程放在CI/CD中,在代码的发布过程中通过单元测试可以检查代码逻辑的正确性,同时依赖于数据库的单元测试将不会通过(通常情况下,生产环境和开发环境隔离),变相迫使开发小伙伴通过 mock 方式模拟数据库返回结果。这个原则同样适用于不能依赖三方API编写单元测试。

  2. 单元测试覆盖率

    通常很多开发 Leader 都会要求开发团队编写单元测试,但是很少检查单元测试的质量,即单元测试最重要的指标——单元测试代码覆盖率,如果不注重覆盖率的提升,那么很有可能会导致开发成员为了单元测试而写单元测试,预期就会与实际情况相差甚远。保证单元测试代码覆盖率,将会大大降低代码变更带来的 Bug 率,从而节省整体开发成本。

  3. 新人问题:为何要写单元测试?

    对于初次开始编写单元测试的开发人员,脑中经常会对此表示怀疑:我为什么要去验证一堆我自己写的正确的逻辑?实际这个问题包含了区分一个一般开发人员和优秀开发人员很重要的一个条件:他是否会反向思考当前逻辑的正确性。有了这种思维,看待问题才会从多个角度入手分析,对问题的本质掌握更加全面。不要怀疑,坚持写单元测试,因为这本身也是对反向思维的一种锻炼,以笔者的经验,只有当编写过一段时间之后,才会真正认识单元测试的魅力,并且开始非常习惯的在写一段逻辑之后,顺手写了对于它的单元测试。即使笔者也算很早就开始写单元测试了,但直到写这篇文章,仍然不断在加深对单元测试的认识。

其实编程也如人生三境:看山是山;看山不是山;看山还是山;阶段不同,认知不同,唯有坚持不懈,持之以恒,才能不断进步,提升境界,这不就是人追求的根本么!

Asp.Net Core 单元测试正确姿势的更多相关文章

  1. 【转】.NET(C#):浅谈程序集清单资源和RESX资源 关于单元测试的思考--Asp.Net Core单元测试最佳实践 封装自己的dapper lambda扩展-设计篇 编写自己的dapper lambda扩展-使用篇 正确理解CAP定理 Quartz.NET的使用(附源码) 整理自己的.net工具库 GC的前世与今生 Visual Studio Package 插件开发之自动生

    [转].NET(C#):浅谈程序集清单资源和RESX资源   目录 程序集清单资源 RESX资源文件 使用ResourceReader和ResourceSet解析二进制资源文件 使用ResourceM ...

  2. 关于单元测试的思考--Asp.Net Core单元测试最佳实践

    在我们码字过程中,单元测试是必不可少的.但在从业过程中,很多开发者却对单元测试望而却步.有些时候并不是不想写,而是常常会碰到下面这些问题,让开发者放下了码字的脚步: 这个类初始数据太麻烦,你看:new ...

  3. 使用 xUnit 编写 ASP.NET Core 单元测试

    还记得 .NET Framework 的 ASP.NET WebForm 吗?那个年代如果要在 Web 层做单元测试简直就是灾难啊..NET Core 吸取教训,在设计上考虑到了可测试性,就连 ASP ...

  4. 撸.NET Core的正确姿势

    特点 案例基于刚发布的.NET Core 2.1 只需一台Linux服务器搞定一切, 全程无需自己配置dotnet环境, 需要熟悉git docker基础知识可有可无, 过了下面几个步骤,你就已经入门 ...

  5. ASP.NET CORE 中用单元测试测试控制器

    之前用ASP.NET CORE做的项目 加了一个新功能,数据库加了个字段balabala.... 更新到服务器上,新功能测试正常,然后就没管了..... 今天客户说网站有BUG,某个页面打开后出错了, ...

  6. K8S+GitLab-自动化分布式部署ASP.NET Core(一) 部署环境

    一.部署流程介绍 开发人员通过Git上传asp.net core 项目到Gilab,并编写好.gitlab-ci.yml , GitLab-Runner 自动拉取代码,然后进行Build,编译,单元测 ...

  7. ASP.NET Core 2.0 中读取 Request.Body 的正确姿势

    原文:ASP.NET Core 中读取 Request.Body 的正确姿势 ASP.NET Core 中的 Request.Body 虽然是一个 Stream ,但它是一个与众不同的 Stream ...

  8. ASP.NET Core 2.2 : 十六.扒一扒新的Endpoint路由方案 try.dot.net 的正确使用姿势 .Net NPOI 根据excel模板导出excel、直接生成excel .Net NPOI 上传excel文件、提交后台获取excel里的数据

    ASP.NET Core 2.2 : 十六.扒一扒新的Endpoint路由方案   ASP.NET Core 从2.2版本开始,采用了一个新的名为Endpoint的路由方案,与原来的方案在使用上差别不 ...

  9. ASP.NET Core 中读取 Request.Body 的正确姿势

    ASP.NET Core 中的 Request.Body 虽然是一个 Stream ,但它是一个与众不同的 Stream —— 不允许 Request.Body.Position=0 ,这就意味着只能 ...

随机推荐

  1. Python selenium+phantomjs的js动态爬取

    Selenium是一个用于Web应用程序测试的工具.Selenium测试直接运行在浏览器中,就像真正的用户在操作一样.支持的浏览器包括IE.Mozilla Firefox.Chrome等.Phanto ...

  2. MongoDB 学习笔记之 手动预先分片

    手动预先分片: 目的:手动预先分片是为了防止未来chunk的移动,减少IO. sh.shardCollection("shop.users",{"userId" ...

  3. vue.js 实战 todo list

    vue.js 起源 vue.js 的作者是尤雨溪,是一名中国人,之前在谷歌工作,现在在全职维护 vue 项目. vue.js 是 2014 年推出来的.现在已经更新到 2.x 版本,3.0 版本会在 ...

  4. vim设置golang语法高亮 (Centos)

    Go语言自带vim 的语法高亮文件. http://www.golangtc.com/download 下载   go1.3.3.src.tar.gzgo1.3.3 (source only),解压缩 ...

  5. C++代码注入

    一.C++代码注入原则: 在注入代码中不允许使用API. 在注入代码中不允许使用全局变量. 在注入代码中不允许使用字符串(编译时也被当做全局变量). 在注入代码中不允许使用函数嵌套. 二.注入代码编写 ...

  6. 计算机视觉(二)-opencv之createTrackbar()详解

    摘要: 我学习openCV3看的是<学习openCV3>这本书,很厚的一本,不知道是不是因为自己看的还不是很多,个人觉得里面的有些重要函数讲的不是很详细,比如createTrackbar( ...

  7. python模块常用用法

    1.time模块(※※※※) import time #导入时间模块 print(time.time()) #返回当前时间的时间戳,可用于计算程序运行时间 print(time.localtime() ...

  8. idea2019版与maven3.6.2版本不兼容引发的血案

    昨天遇到了点问题解决浪费了一些时间(导致更新内容较少)回顾下问题 项目出现Unable to import maven project: See logs for details 翻了好多博客 莫名的 ...

  9. C语言基于窗体命令行打包,解包和浏览程序

    #include<stdio.h>#include<stdlib.h>#include<string.h>#include<windows.h>#inc ...

  10. Java学习笔记之Object常用方法

    Object:万类之祖   == : 比较的是是否是同一个对象,比较的是地址   equals: 是Object里面的方法,默认的是==,比较的是地址,但在String类型里重写为比较内容 一般我们在 ...