背景

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. 原创电子书《菜鸟程序员成长之路:从技术小白到阿里巴巴Java工程师》

    <菜鸟程序员成长之路:从技术小白到阿里巴巴Java工程师> 国庆节快乐!一年一度长度排第二的假期终于来了. 难得有十一长假,作者也想要休息几天啦. 不管你是选择出门玩,还是在公司加班,在学 ...

  2. 序列标注(BiLSTM-CRF/Lattice LSTM)

    前言 在三大特征提取器中,我们已经接触了LSTM/CNN/Transormer三种特征提取器,这一节我们将介绍如何使用BiLSTM实现序列标注中的命名实体识别任务,以及Lattice-LSTM的模型原 ...

  3. 系统定时任务crond

    1.Linux的定时任务:crond(crontab)服务 (1)crond 是什么? crond 是 linux 系统中用来定期执行命令或者指定程序任务的一种服务和软件:crond 服务默认情况(每 ...

  4. NAT网络下tcp_tw_recycle参数引起的故障

    记录一次阿里云服务器故障排查思路 公司网络是nat 环境 问题: 同一个服务有两台服务器 172.19.19.252 172.19.19.187 两台服务器 要连node5 发现172.19.19.2 ...

  5. Java面试----01.JavaSE

    1.面向对象和面向过程的区别 面向过程:面向过程性能比面向对象高. 因为类调用时需要实例化,比较消耗资源,所以当性能是最重要的考虑因素时,比如单片机.嵌入式开发.Linux/Unix等一般采用面向对象 ...

  6. Solidity 编程实例--简单的公开拍卖

    通常简单的公开拍卖合约,是每个人可以在拍卖期间发送他们的竞拍出价.为了实现绑定竞拍人的到他们的拍卖,竞拍包括发送金额/ether.如果产生了新的最高竞拍价,前一个最高价竞拍人将会拿回他的钱.在竞拍阶段 ...

  7. Redis面试篇 -- Redis主从复制原理

        Redis一般是用来支撑读高并发的,为了分担读压力,Redis支持主从复制.架构是主从架构,一主多从, 主负责写,并且将数据复制到其它的 slave 节点,从节点负责读. 所有的读请求全部走从 ...

  8. web前端体系-了解前端,深入前端,架构前端,再看前端。大体系-知识-小细节

    1.了解前端,深入前端,架构前端,再看前端.大体系-知识-小细节 个人认为:前端发展最终的导向是前端工程化,智能化,模块化,组件化,层次化. 2.面试第一关:理论知识. 2-1.http标准 2-2. ...

  9. springmvc处理局部异常和全局异常

    springmvc通过HandlerExceptionResolver(是一个接口,在spring-webmvc依赖下)处理程序异常,包括处理器异常.数据绑定异常以及处理器执行时发生的异常.Handl ...

  10. SSH服务协议

    1.SSH介绍: SSH 是Secure Shell Protocol 的简写,由IETF网络小组(Network Working Group)制定:在进行数据传输之前,SSH先对联机数据包通过加密技 ...