背景

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();

    logger.verify(_ => _.log(it.isany<loglevel>(),
                                it.isany<eventid>(),
                                it.isany<string>(),
                                it.isany<exception>(), 
                                it.isany<func<string, 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. 新人问题:为何要写单元测试?

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

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