背景

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. Dedecms手机站三种不同建设方法和优劣分析

    dedecms简单易用功能强大,是国内使用最多的cms建站系统,百度站长平台专门推出了“织梦移动化指南”,由此可见dedecms的影响力.织梦也是站长使用和学习最早的cms建站系统,解放了我的双手,让 ...

  2. ['1', '2', '3'].map(parseInt) what & why ?

    这是今天在 Advanced-Frontend组织 看到一个比较有意思的题目.主要是讲JS的映射与解析早在 2013年, 加里·伯恩哈德就在微博上发布了以下代码段: ['10','10','10',' ...

  3. C语言复习————基本数据类型、运算符和表达式

    数据类型/运算符/表达式 主要分两部分介绍,第一部分介绍常用的数据类型,例如:常量和变量/整型/实型/字符型:第二部分介绍算数运算符和算数表达式以及赋值运算符和逗号运算符. 一. 常用的数据类型 1. ...

  4. Windows搭建SVN服务器

    安装 1.下载地址:https://www.visualsvn.com/files/VisualSVN-Server-4.0.3-x64.msi 2.开始安装: 3.安装配置: 4.Next直到完成 ...

  5. CSS隐藏元素 display、visibility、opacity的区别

    关于使指定元素无法在视野内看到,有3个方法 display: none; opacity: 0; visibility: hidden; 1.display: none; 该方法会改变页面布局. 元素 ...

  6. PCA 算法核心:高维度向量向低维度投影

    Principal Component Analysis:主成分分析 步骤 5 步: 1.去平均值,也就是将向量中每一项都减去各自向量的平均值 2.计算矩阵的方差,协方差,特征值, 3,.把特征值从大 ...

  7. IT爱心求助站

    最近发生的一些事情,让我对自己的专业有了另外一层认识. 小尹同学,你是做软件的是吗?能否帮我看一下我的电脑问题? 老同学,我的电脑安装一个软件这么都装不上,能否帮我看一下呢? 邻居你好,我的手机怎么没 ...

  8. Z-buffer算法

    1.Z缓冲区(Z-Buffer)算法 1973年,犹他大学学生艾德·卡姆尔(Edwin Catmull)独 立开发出了能跟踪屏幕上每个像素深度的算法 Z-buffer Z-buffer让计算机生成复杂 ...

  9. docker的使用之镜像命令

    说明 Docker运行容器前需要本地存在对应的镜像 ,如果镜像不存在本地,Docker会从镜像仓库下载 获取镜像 通过网址可以找到目标镜像 https://hub.docker.com/explore ...

  10. 像艺术家一样思考 Think Like an Artist

    艺术家是如何获得灵感,如何找到自己的独特风格和主题的? 艺术家在绘画.写作.表演或歌唱前不会去征求谁的允许,而是随心而行 要想在数字时代获得满足感,我们需要变得有创造性 1.艺术家富有事业心 艺术家是 ...