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 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;
}
结语
几个问题:
CI/CD 流程中应该包含单元测试
例如在编写 Repository 层进行单元测试时,经常有同学会编写依赖于数据库数据的单元测试,这样并不利于随时随地的进行单元测试检查,如果将该流程放在CI/CD中,在代码的发布过程中通过单元测试可以检查代码逻辑的正确性,同时依赖于数据库的单元测试将不会通过(通常情况下,生产环境和开发环境隔离),变相迫使开发小伙伴通过 mock 方式模拟数据库返回结果。这个原则同样适用于不能依赖三方API编写单元测试。单元测试覆盖率
通常很多开发 Leader 都会要求开发团队编写单元测试,但是很少检查单元测试的质量,即单元测试最重要的指标——单元测试代码覆盖率,如果不注重覆盖率的提升,那么很有可能会导致开发成员为了单元测试而写单元测试,预期就会与实际情况相差甚远。保证单元测试代码覆盖率,将会大大降低代码变更带来的 Bug 率,从而节省整体开发成本。新人问题:为何要写单元测试?
对于初次开始编写单元测试的开发人员,脑中经常会对此表示怀疑:我为什么要去验证一堆我自己写的正确的逻辑?实际这个问题包含了区分一个一般开发人员和优秀开发人员很重要的一个条件:他是否会反向思考当前逻辑的正确性。有了这种思维,看待问题才会从多个角度入手分析,对问题的本质掌握更加全面。不要怀疑,坚持写单元测试,因为这本身也是对反向思维的一种锻炼,以笔者的经验,只有当编写过一段时间之后,才会真正认识单元测试的魅力,并且开始非常习惯的在写一段逻辑之后,顺手写了对于它的单元测试。即使笔者也算很早就开始写单元测试了,但直到写这篇文章,仍然不断在加深对单元测试的认识。
其实编程也如人生三境:看山是山;看山不是山;看山还是山;阶段不同,认知不同,唯有坚持不懈,持之以恒,才能不断进步,提升境界,这不就是人追求的根本么!
Asp.Net Core 单元测试正确姿势的更多相关文章
- 【转】.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 ...
- 关于单元测试的思考--Asp.Net Core单元测试最佳实践
在我们码字过程中,单元测试是必不可少的.但在从业过程中,很多开发者却对单元测试望而却步.有些时候并不是不想写,而是常常会碰到下面这些问题,让开发者放下了码字的脚步: 这个类初始数据太麻烦,你看:new ...
- 使用 xUnit 编写 ASP.NET Core 单元测试
还记得 .NET Framework 的 ASP.NET WebForm 吗?那个年代如果要在 Web 层做单元测试简直就是灾难啊..NET Core 吸取教训,在设计上考虑到了可测试性,就连 ASP ...
- 撸.NET Core的正确姿势
特点 案例基于刚发布的.NET Core 2.1 只需一台Linux服务器搞定一切, 全程无需自己配置dotnet环境, 需要熟悉git docker基础知识可有可无, 过了下面几个步骤,你就已经入门 ...
- ASP.NET CORE 中用单元测试测试控制器
之前用ASP.NET CORE做的项目 加了一个新功能,数据库加了个字段balabala.... 更新到服务器上,新功能测试正常,然后就没管了..... 今天客户说网站有BUG,某个页面打开后出错了, ...
- K8S+GitLab-自动化分布式部署ASP.NET Core(一) 部署环境
一.部署流程介绍 开发人员通过Git上传asp.net core 项目到Gilab,并编写好.gitlab-ci.yml , GitLab-Runner 自动拉取代码,然后进行Build,编译,单元测 ...
- ASP.NET Core 2.0 中读取 Request.Body 的正确姿势
原文:ASP.NET Core 中读取 Request.Body 的正确姿势 ASP.NET Core 中的 Request.Body 虽然是一个 Stream ,但它是一个与众不同的 Stream ...
- 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的路由方案,与原来的方案在使用上差别不 ...
- ASP.NET Core 中读取 Request.Body 的正确姿势
ASP.NET Core 中的 Request.Body 虽然是一个 Stream ,但它是一个与众不同的 Stream —— 不允许 Request.Body.Position=0 ,这就意味着只能 ...
随机推荐
- 基于HTML5 WebGL的工业化3D电子围栏
前言 现代工业化的推进在极大加速现代化进程的同时也带来的相应的安全隐患,在传统的可视化监控领域,一般都是基于 Web SCADA 的前端技术来实现 2D 可视化监控,本系统采用 Hightopo 的 ...
- [WP8.1]RSA 使用BouncyCastle 公钥解密
写应用的时候遇到个服务器返回私钥加密过的数据 ,然后要在客户端用公钥解密的需求 ,一直没找到方法,应用搁置了一个学期,多方搜索,结论就是.net没有实现公钥解密的方法,要自己实现,于是硬着头皮开始看 ...
- 服务器配置https协议,三种免费的方法
最近想搞一个网站玩玩,发布网站用https协议已经是大势所趋了.例如微信小程序,不使用https协议根本不让接入.所以,分享一下我尝试过的三种方法. 1.Linux自签(OPENSSL生成SSL自签证 ...
- Adobe PS常用快捷键
ps使用快捷键 新建图层 Ctrl+Shift+N 取消选择区 Ctrl + D 新建标题 Ctrl + N 图片放大 Alt+鼠标滑动 图片位置拖动 空格 + 鼠标拖动 移动图层 ...
- js中的toString和valueOf
数据的转换 基本上,所有JS数据类型都拥有valueOf和toString这两个方法,null除外.它们俩解决javascript值运算与显示的问题 所有对象继承了两个转换方法: 第一个是toStri ...
- WampServer配置可局域网访问站点
一 WampServer3.1.7版本 二 需求:项目开发阶段,服务器还未购买,前端需要调用接口进行测试,于是想到了在本地搭设一个站点,可以局域网内访问 三 先为这个项目单独在本地设置一个端口,比如我 ...
- requests模块(代理)篇
- 用户验证 - 代理验证 #可能需要使用HTTP basic Auth, 可以这样 # 格式为 用户名:密码@代理地址:端口地址 proxy = { "http": " ...
- 爬虫 xpath
xpath简介 1.xpath使用路径表达式在xml和html中进行导航 2.xpath包含标准函数库 3.xpath是一个w3c的标准 xpath节点关系 1.父节点 2.字节点 3.同胞节点 4. ...
- javascript语言学习
本课将和大家一起学习简单的js dom 操作,涵盖DOM API以及JQuery的方法. 相关简介 JavaScript一种直译式脚本语言,是一种动态类型.弱类型.基于原型的语 ...
- 05jmeter-responses中有多组json数据,提取同一条json数据的两个字段
某接口返回结果如上图,取id和groupNo 1.读取确定的某条json数据:如取第一条,一个JSON Extractor即可实现 2.随机读取某条json数据: 需要两个JSON Extractor ...