ASP.NET Core项目使用xUnit进行单元测试

2022-07-16,,,,

一、前言

在以前的.net framework项目中,我们也写过一些单元测试的项目,而在asp.net core 这种web或者api应用程序中要做单元测试是很方便的。

这篇文章主要讲解如何使用xunit对asp.net core应用程序做单元测试。.net core中常用的测试工具还有nunit和mstest。

xunit是一个测试框架,可以针对.net/.net core项目进行测试。测试项目需要引用被测试的项目,从而对其进行测试。测试项目同时需要引用xunit库。测试编写好后,用test runner来运行测试。test runner可以读取测试代码,并且会知道我们所使用的测试框架,然后执行,并显示结果。目前可用的test runner包括vs自带的test explorer,或者dotnet core命令行,以及第三方工具,例如resharper等。

xunit可以支持多种平台的测试:

  • .net framework
  • .net core
  • .net standard
  • uwp
  • xamarin

二、创建示例项目

为了使示例项目更加的贴近真实的项目开发,这里采用分层的方式创建一个示例项目,创建完成后的项目结构如下图所示:

 下面讲解一下每层的作用,按照从上往下的顺序:

  • testdemo:从名字就可以看出来,这是一个单元测试的项目,针对控制器进行测试。
  • unittest.data:数据访问,封装与entityframeworkcore相关的操作。
  • unittest.irepository:泛型仓储接口,封装基础的增删改查。
  • unittest.model:实体层,定义项目中使用到的所有实体。
  • unittest.repository:泛型仓储接口实现层,实现接口里面定义的方法。
  • unittestdemo:asp.net core webapi,提供api接口。

1、unittest.model

实体层里面只有一个student类:

using system;
using system.collections.generic;
using system.text;

namespace unittest.model
{
    public class student
    {
        public int id { get; set; }

        public string name { get; set; }

        public int age { get; set; }

        public string gender { get; set; }
    }
}

2、unittest.data

里面封装与ef core有关的操作,首先需要引入microsoft.entityframeworkcore、microsoft.entityframeworkcore.sqlserver、microsoft.entityframeworkcore.tools三个nuget包,直接在管理nuget程序包里面引入,这里不在讲述。

引入相关nuget包以后,我们创建数据上下文类,该类继承自ef core的dbcontext,里面设置表名和一些属性:

using microsoft.entityframeworkcore;
using unittest.model;

namespace unittest.data
{
    /// <summary>
    /// 数据上下文类
    /// </summary>
    public class appdbcontext : dbcontext
    {
        /// <summary>
        /// 通过构造函数给父类构造传参
        /// </summary>
        /// <param name="options"></param>
        public appdbcontext(dbcontextoptions<appdbcontext> options) : base(options)
        {

        }

        public dbset<student> students { get; set; }

        protected override void onmodelcreating(modelbuilder modelbuilder)
        {
            modelbuilder.entity<student>().totable("t_student");
            modelbuilder.entity<student>().haskey(p => p.id);
            modelbuilder.entity<student>().property(p => p.name).hasmaxlength(32);

            // 添加种子数据
            modelbuilder.entity<student>().hasdata(
                new student()
                {
                    id = 1,
                    name = "测试1",
                    age = 20,
                    gender = "男"
                },
                new student()
                {
                    id = 2,
                    name = "测试2",
                    age = 22,
                    gender = "女"
                },
                new student()
                {
                    id = 3,
                    name = "测试3",
                    age = 23,
                    gender = "男"
                });
            base.onmodelcreating(modelbuilder);
        }
    }
}

这里采用数据迁移的方式生成数据库,需要在api项目中引入microsoft.entityframeworkcore、microsoft.entityframeworkcore.sqlserver、microsoft.entityframeworkcore.tools三个nuget包。引入方式同上。

然后在api项目的appsettings.json文件里面添加数据库链接字符串:

{
  "logging": {
    "loglevel": {
      "default": "information",
      "microsoft": "warning",
      "microsoft.hosting.lifetime": "information"
    }
  },
  "allowedhosts": "*",
  // 数据库连接字符串
  "connectionstring": {
    "dbconnection": "initial catalog=testdb;user id=sa;password=1234;data source=.;connection timeout=10;"
  }
}

在json文件中添加完连接字符串以后,修改startup类的configureservices方法,在里面配置使用在json文件中添加的连接字符串:

// 添加数据库连接字符串
services.adddbcontext<appdbcontext>(options => 
{
    options.usesqlserver(configuration.getsection("connectionstring").getsection("dbconnection").value);
});

这样就可以使用数据迁移的方式生成数据库了。

3、unittest.irepository

该项目中使用泛型仓储,定义一个泛型仓储接口:

using system.collections.generic;
using system.threading.tasks;

namespace unittest.irepository
{
    public interface irepository<t> where t:class,new()
    {
        task<list<t>> getlist();

        task<int?> add(t entity);

        task<int?> update(t entity);

        task<int?> delete(t entity);
    }
}

然后在定义istudentrepository接口继承自irepository泛型接口:

using unittest.model;

namespace unittest.irepository
{
    public interface istudentrepository: irepository<student>
    {
    }
}

4、unittest.repository

这里是实现上面定义的仓储接口:

using system.collections.generic;
using system.linq;
using system.threading.tasks;
using unittest.data;
using unittest.irepository;
using unittest.model;

namespace unittest.repository
{
    public class studentrepository : istudentrepository
    {
        private readonly appdbcontext _dbcontext;

        /// <summary>
        /// 通过构造函数实现依赖注入
        /// </summary>
        /// <param name="dbcontext"></param>
        public studentrepository(appdbcontext dbcontext)
        {
            _dbcontext = dbcontext;
        }

        public async task<int?> add(student entity)
        {
            _dbcontext.students.add(entity);
            return await _dbcontext.savechangesasync();
        }

        public async task<int?> delete(student entity)
        {
            _dbcontext.students.remove(entity);
            return await _dbcontext.savechangesasync();
        }

        public async task<list<student>> getlist()
        {
            list<student> list = new list<student>();

            list = await task.run<list<student>>(() => 
            {
                return _dbcontext.students.tolist();
            });
          
            return list;
        }

        public async task<int?> update(student entity)
        {
            student student = _dbcontext.students.find(entity.id);
            if (student != null)
            {
                student.name = entity.name;
                student.age = entity.age;
                student.gender = entity.gender;
                _dbcontext.entry<student>(student).state = microsoft.entityframeworkcore.entitystate.modified;
                return await _dbcontext.savechangesasync();
            }
            return 0;
        }
    }
}

5、unittestdemo

先添加一个value控制器,里面只有一个get方法,而且没有任何的依赖关系,先进行最简单的测试:

using microsoft.aspnetcore.mvc;

namespace unittestdemo.controllers
{
    [route("api/[controller]")]
    [apicontroller]
    public class valuecontroller : controllerbase
    {
        [httpget("{id}")]
        public actionresult<string> get(int id)
        {
            return $"para is {id}";
        }
    }
}

6、testdemo

我们在添加测试项目的时候,直接选择使用xunit测试项目,如下图所示:

这样项目创建完成以后,就会自动添加xunit的引用:

  <itemgroup>
    <packagereference include="microsoft.net.test.sdk" version="16.2.0" />
    <packagereference include="xunit" version="2.4.1" />
    <packagereference include="xunit.runner.visualstudio" version="2.4.0" />
  </itemgroup>

但要测试 asp.net core 应用还需要添加两个 nuget 包:

install-package microsoft.aspnetcore.app
install-package microsoft.aspnetcore.testhost

上面是使用命令的方式进行安装,也可以在管理nuget程序包里面进行搜索,然后安装。

千万不要忘记还要引入要测试的项目。最后的项目引入是这样的:

<project sdk="microsoft.net.sdk">

  <propertygroup>
    <targetframework>netcoreapp3.1</targetframework>

    <ispackable>false</ispackable>
  </propertygroup>

  <itemgroup>
    <packagereference include="microsoft.aspnetcore.app" version="2.2.8" />
    <packagereference include="microsoft.aspnetcore.testhost" version="3.1.2" />
    <packagereference include="microsoft.net.test.sdk" version="16.2.0" />
    <packagereference include="newtonsoft.json" version="12.0.3" />
    <packagereference include="xunit" version="2.4.1" />
    <packagereference include="xunit.runner.visualstudio" version="2.4.0" />
    <packagereference include="coverlet.collector" version="1.0.1" />
  </itemgroup>

  <itemgroup>
    <projectreference include="..\unittest.model\unittest.model.csproj" />
    <projectreference include="..\unittestdemo\unittestdemo.csproj" />
  </itemgroup>

</project>

都添加完以后,重新编译项目,保证生成没有错误。

三、编写单元测试

单元测试按照从上往下的顺序,一般分为三个阶段:

  • arrange:准备阶段。这个阶段做一些准备工作,例如创建对象实例,初始化数据等。
  • act:行为阶段。这个阶段是用准备好的数据去调用要测试的方法。
  • assert:断定阶段。这个阶段就是把调用目标方法的返回值和预期的值进行比较,如果和预期值一致则测试通过,否则测试失败。

我们在api项目中添加了一个value控制器,我们以get方法作为测试目标。一般一个单元测试方法就是一个测试用例。

我们在测试项目中添加一个valuetest测试类,然后编写一个单元测试方法,这里是采用模拟httpclient发送http请求的方式进行测试:

using microsoft.aspnetcore;
using microsoft.aspnetcore.hosting;
using microsoft.aspnetcore.testhost;
using system.net;
using system.net.http;
using system.threading.tasks;
using unittestdemo;
using xunit;

namespace testdemo
{
    public class valuetests
    {
        public httpclient _client { get; }

        /// <summary>
        /// 构造方法
        /// </summary>
        public valuetests()
        {
            var server = new testserver(webhost.createdefaultbuilder()
           .usestartup<startup>());
            _client = server.createclient();
        }

        [fact]
        public async task getbyid_shouldbe_ok()
        {
            // 1、arrange
            var id = 1;

            // 2、act
            // 调用异步的get方法
            var response = await _client.getasync($"/api/value/{id}");

            // 3、assert
            assert.equal(httpstatuscode.ok, response.statuscode);

        }
    }
}

我们在构造函数中,通过testserver拿到一个httpclient对象,用它来模拟http请求。我们写了一个测试用例,完整演示了单元测试的arrange、act和assert三个步骤。

1、运行单元测试

单元测试用例写好以后,打开“测试资源管理器”:

在底部就可以看到测试资源管理器了:

在要测试的方法上面右键,选择“运行测试”就可以进行测试了:

注意观察测试方法前面图标的颜色,目前是蓝色的,表示测试用例还没有运行过:

测试用例结束以后,我们在测试资源管理器里面可以看到结果:

绿色表示测试通过。我们还可以看到执行测试用例消耗的时间。

如果测试结果和预期结果一致,那么测试用例前面图标的颜色也会变成绿色:

如果测试结果和预期结果不一致就会显示红色,然后需要修改代码直到出现绿色图标。我们修改测试用例,模拟测试失败的情况:

using microsoft.aspnetcore;
using microsoft.aspnetcore.hosting;
using microsoft.aspnetcore.testhost;
using system.net;
using system.net.http;
using system.threading.tasks;
using unittestdemo;
using xunit;

namespace testdemo
{
    public class valuetests
    {
        public httpclient _client { get; }

        /// <summary>
        /// 构造方法
        /// </summary>
        public valuetests()
        {
            var server = new testserver(webhost.createdefaultbuilder()
           .usestartup<startup>());
            _client = server.createclient();
        }

        [fact]
        public async task getbyid_shouldbe_ok()
        {
            // 1、arrange
            var id = 1;

            // 2、act
            // 调用异步的get方法
            var response = await _client.getasync($"/api/value/{id}");

            //// 3、assert
            //assert.equal(httpstatuscode.ok, response.statuscode);

            // 3、assert
            // 模拟测试失败
            assert.equal(httpstatuscode.badrequest, response.statuscode);

        }
    }
}

然后运行测试用例:

2、调试单元测试

我们也可以通过添加断点的方式在测试用例中进行调试。调试单元测试很简单,只需要在要调试的方法上面右键选择“调试测试”,如下图所示:

其它操作就跟调试普通方法一样。

除了添加断点调试,我们还可以采用打印日志的方法来快速调试,xunit可以很方便地做到这一点。我们修改valuetest类:

using microsoft.aspnetcore;
using microsoft.aspnetcore.hosting;
using microsoft.aspnetcore.testhost;
using system.net;
using system.net.http;
using system.threading.tasks;
using unittestdemo;
using xunit;
using xunit.abstractions;

namespace testdemo
{
    public class valuetests
    {
        public httpclient _client { get; }
        public itestoutputhelper output { get; }

        /// <summary>
        /// 构造方法
        /// </summary>
        public valuetests(itestoutputhelper outputhelper)
        {
            var server = new testserver(webhost.createdefaultbuilder()
           .usestartup<startup>());
            _client = server.createclient();
            output = outputhelper;
        }

        [fact]
        public async task getbyid_shouldbe_ok()
        {
            // 1、arrange
            var id = 1;

            // 2、act
            // 调用异步的get方法
            var response = await _client.getasync($"/api/value/{id}");

            // 3、assert
            // 模拟测试失败
            //assert.equal(httpstatuscode.badrequest, response.statuscode);

            // 输出返回信息
            // output
            var responsetext = await response.content.readasstringasync();
            output.writeline(responsetext);

            // 3、assert
            assert.equal(httpstatuscode.ok, response.statuscode);

        }
    }
}

这里我们在构造函数中添加了 itestoutputhelper 参数,xunit 会将一个实现此接口的实例注入进来。拿到这个实例后,我们就可以用它来输出日志了。运行(注意不是 debug)此方法,运行结束后在测试资源管理器里面查看:

点击就可以看到输出的日志了:

在上面的例子中,我们是使用的简单的value控制器进行测试,控制器里面没有其他依赖关系,如果控制器里面有依赖关系该如何测试呢?方法还是一样的,我们新建一个student控制器,里面依赖istudentrepository接口,代码如下:

using system.collections.generic;
using system.threading.tasks;
using microsoft.aspnetcore.mvc;
using unittest.irepository;
using unittest.model;

namespace unittestdemo.controllers
{
    [route("api/student")]
    [apicontroller]
    public class studentcontroller : controllerbase
    {
        private readonly istudentrepository _repository;

        /// <summary>
        /// 通过构造函数注入
        /// </summary>
        /// <param name="repository"></param>
        public studentcontroller(istudentrepository repository)
        {
            _repository = repository;
        }

        /// <summary>
        /// get方法
        /// </summary>
        /// <returns></returns>
        [httpget]
        public async task<actionresult<list<student>>> get()
        {
            return await _repository.getlist();
        }
    }
}

然后在startup类的configureservices方法中注入:

public void configureservices(iservicecollection services)
{
    // 添加数据库连接字符串
    services.adddbcontext<appdbcontext>(options => 
    {
        options.usesqlserver(configuration.getsection("connectionstring").getsection("dbconnection").value);
    });
    // 添加依赖注入到容器中
    services.addscoped<istudentrepository, studentrepository>();
    services.addcontrollers();
}

在单元测试项目中添加studenttest类:

using microsoft.aspnetcore;
using microsoft.aspnetcore.hosting;
using microsoft.aspnetcore.testhost;
using newtonsoft.json;
using system.collections.generic;
using system.net.http;
using system.threading.tasks;
using unittest.model;
using unittestdemo;
using xunit;
using xunit.abstractions;

namespace testdemo
{
    public class studenttest
    {
        public httpclient client { get; }
        public itestoutputhelper output { get; }
        public studenttest(itestoutputhelper outputhelper)
        {
            var server = new testserver(webhost.createdefaultbuilder()
           .usestartup<startup>());
            client = server.createclient();
            output = outputhelper;
        }

        [fact]
        public async task get_shouldbe_ok()
        {
            // 2、act
            var response = await client.getasync($"api/student");

            // output
            string context = await response.content.readasstringasync();
            output.writeline(context);
            list<student> list = jsonconvert.deserializeobject<list<student>>(context);

            // assert
            assert.equal(3, list.count);
        }
    }
}

然后运行单元测试:

可以看到,控制器里面如果有依赖关系,也是可以使用这种方式进行测试的。

post方法也可以使用同样的方式进行测试,修改控制器,添加post方法:

using system.collections.generic;
using system.threading.tasks;
using microsoft.aspnetcore.mvc;
using unittest.irepository;
using unittest.model;

namespace unittestdemo.controllers
{
    [route("api/student")]
    [apicontroller]
    public class studentcontroller : controllerbase
    {
        private readonly istudentrepository _repository;

        /// <summary>
        /// 通过构造函数注入
        /// </summary>
        /// <param name="repository"></param>
        public studentcontroller(istudentrepository repository)
        {
            _repository = repository;
        }

        /// <summary>
        /// get方法
        /// </summary>
        /// <returns></returns>
        [httpget]
        public async task<actionresult<list<student>>> get()
        {
            return await _repository.getlist();
        }

        /// <summary>
        /// post方法
        /// </summary>
        /// <param name="entity"></param>
        /// <returns></returns>
        [httppost]
        public async task<bool> post([frombody]student entity)
        {
            int? result = await _repository.add(entity);
            if(result==null)
            {
                return false;
            }
            else
            {
                return result > 0 ? true : false;
            }
            
        }
    }
}

在增加一个post的测试方法:

using microsoft.aspnetcore;
using microsoft.aspnetcore.hosting;
using microsoft.aspnetcore.testhost;
using newtonsoft.json;
using system.collections.generic;
using system.net.http;
using system.threading.tasks;
using unittest.model;
using unittestdemo;
using xunit;
using xunit.abstractions;

namespace testdemo
{
    public class studenttest
    {
        public httpclient client { get; }
        public itestoutputhelper output { get; }
        public studenttest(itestoutputhelper outputhelper)
        {
            var server = new testserver(webhost.createdefaultbuilder()
           .usestartup<startup>());
            client = server.createclient();
            output = outputhelper;
        }

        [fact]
        public async task get_shouldbe_ok()
        {
            // 2、act
            var response = await client.getasync($"api/student");

            // output
            string context = await response.content.readasstringasync();
            output.writeline(context);
            list<student> list = jsonconvert.deserializeobject<list<student>>(context);

            // assert
            assert.equal(3, list.count);
        }

        [fact]
        public async task post_shouldbe_ok()
        {
            // 1、arrange
            student entity = new student()
            {
             name="测试9",
             age=25,
             gender="男"
            };

            var str = jsonconvert.serializeobject(entity);
            httpcontent content = new stringcontent(str);

            // 2、act
            content.headers.contenttype = new system.net.http.headers.mediatypeheadervalue("application/json");

            httpresponsemessage response = await client.postasync("api/student", content);
            string responsebody = await response.content.readasstringasync();
            output.writeline(responsebody);

            // 3、assert
            assert.equal("true", responsebody);
        }
    }
}

运行测试用例:

这样一个简单的单元测试就完成了。

我们观察上面的两个测试类,发现这两个类都有一个共同的特点:都是在构造函数里面创建一个httpclient对象,我们可以把创建httpclient对象抽离到一个共同的基类里面,所有的类都继承自基类。该基类代码如下:

using microsoft.aspnetcore.hosting;
using microsoft.aspnetcore.testhost;
using system.io;
using system.net.http;
using unittestdemo;

namespace testdemo
{
    /// <summary>
    /// 基类
    /// </summary>
    public class apicontrollertestbase
    {
        /// <summary>
        /// 返回httpclient对象
        /// </summary>
        /// <returns></returns>
        protected httpclient getclient()
        {
            var builder = new webhostbuilder()
                                // 指定使用当前目录
                                .usecontentroot(directory.getcurrentdirectory())
                                // 使用startup类作为启动类
                                .usestartup<startup>()
                                // 设置使用测试环境
                                .useenvironment("testing");
            var server = new testserver(builder);
            // 创建httpclient
            httpclient client = server.createclient();

            return client;
        }
    }
}

然后修改studenttest类,使该类继承自上面创建的基类:

using newtonsoft.json;
using system.collections.generic;
using system.net.http;
using system.threading.tasks;
using unittest.model;
using xunit;
using xunit.abstractions;

namespace testdemo
{
    public class studenttest: apicontrollertestbase
    {
        public httpclient client { get; }
        public itestoutputhelper output { get; }


        public studenttest(itestoutputhelper outputhelper)
        {
            // var server = new testserver(webhost.createdefaultbuilder()
            //.usestartup<startup>());
            // client = server.createclient();

            // 从父类里面获取httpclient对象
            client = base.getclient();
            output = outputhelper;
        }

        [fact]
        public async task get_shouldbe_ok()
        {
            // 2、act
            var response = await client.getasync($"api/student");

            // output
            string context = await response.content.readasstringasync();
            output.writeline(context);
            list<student> list = jsonconvert.deserializeobject<list<student>>(context);

            // assert
            assert.equal(3, list.count);
        }

        [fact]
        public async task post_shouldbe_ok()
        {
            // 1、arrange
            student entity = new student()
            {
             name="测试9",
             age=25,
             gender="男"
            };

            var str = jsonconvert.serializeobject(entity);
            httpcontent content = new stringcontent(str);

            // 2、act
            content.headers.contenttype = new system.net.http.headers.mediatypeheadervalue("application/json");

            httpresponsemessage response = await client.postasync("api/student", content);
            string responsebody = await response.content.readasstringasync();
            output.writeline(responsebody);

            // 3、assert
            assert.equal("true", responsebody);
        }
    }
}

文章中的示例代码地址:https://github.com/jxl1024/unittest

到此这篇关于asp.net core项目使用xunit进行单元测试的文章就介绍到这了。希望对大家的学习有所帮助,也希望大家多多支持。

《ASP.NET Core项目使用xUnit进行单元测试.doc》

下载本文的Word格式文档,以方便收藏与打印。