Asp.Net Core 轻松学-利用xUnit进行主机级别的网络集成测试
前言
在开发 asp.net core 应用程序的过程中,我们常常需要对业务代码编写单元测试,这种方法既快速又有效,利用单元测试做代码覆盖测试,也是非常必要的事情;但是,但我们需要对系统进行集成测试的时候,需要启动服务主机,利用浏览器或者postman 等网络工具对接口进行集成测试,这就非常的不方便,同时浪费了大量的时间在重复启动应用程序上;今天要介绍就是如何在不启动应用程序的情况下,对 asp.net core webapi 项目进行网络集成测试。
1.1 建立项目
1.1 首先我们建立两个项目,asp.net core webapi 和 xunit 单元测试项目,如下
1.2 上图的单元测试项目 ron.xunittest 必须应用待测试的 webapi 项目 ron.testdemo
1.3 接下来打开 ron.xunittest 项目文件 .csproj,添加包引用
microsoft.aspnetcore.app microsoft.aspnetcore.testhost
1.4 为什么要引用这两个包呢,因为我刚才创建的 webapi 项目是引用 microsoft.aspnetcore.app 的,至于 microsoft.aspnetcore.testhost,它是今天的主角,为了使用测试主机,必须对其进行引用,下面会详细说明
2. 编写业务
2.1 创建一个接口,代码如下
[route("api/[controller]")] [apicontroller] public class valuescontroller : controllerbase { private iconfiguration configuration; public valuescontroller(iconfiguration configuration) { this.configuration = configuration; } [httpget("{id}")] public actionresult<int> get(int id) { var result= id + this.configuration.getvalue<int>("max"); return result; } }
2.1 接口代码非常简单,接受一个参数 id,然后和配置文件中获取的值 max 相加,然后输出结果给客户端
3. 编写测试用例
3.1 为了能够使用主机集成测试,我们需要使用类
microsoft.aspnetcore.testhost.testserver
3.2 我们来看一下 testserver 的源码,代码较长,你可以直接跳过此段,进入下一节 3.3
public class testserver : iserver { private iwebhost _hostinstance; private bool _disposed = false; private ihttpapplication<context> _application; public testserver(): this(new featurecollection()) { } public testserver(ifeaturecollection featurecollection) { features = featurecollection ?? throw new argumentnullexception(nameof(featurecollection)); } public testserver(iwebhostbuilder builder): this(builder, new featurecollection()) { } public testserver(iwebhostbuilder builder, ifeaturecollection featurecollection): this(featurecollection) { if (builder == null) { throw new argumentnullexception(nameof(builder)); } var host = builder.useserver(this).build(); host.startasync().getawaiter().getresult(); _hostinstance = host; } public uri baseaddress { get; set; } = new uri("http://localhost/"); public iwebhost host { get { return _hostinstance ?? throw new invalidoperationexception("the testserver constructor was not called with a iwebhostbuilder so iwebhost is not available."); } } public ifeaturecollection features { get; } private ihttpapplication<context> application { get => _application ?? throw new invalidoperationexception("the server has not been started or no web application was configured."); } public httpmessagehandler createhandler() { var pathbase = baseaddress == null ? pathstring.empty : pathstring.fromuricomponent(baseaddress); return new clienthandler(pathbase, application); } public httpclient createclient() { return new httpclient(createhandler()) { baseaddress = baseaddress }; } public websocketclient createwebsocketclient() { var pathbase = baseaddress == null ? pathstring.empty : pathstring.fromuricomponent(baseaddress); return new websocketclient(pathbase, application); } public requestbuilder createrequest(string path) { return new requestbuilder(this, path); } public async task<httpcontext> sendasync(action<httpcontext> configurecontext, cancellationtoken cancellationtoken = default) { if (configurecontext == null) { throw new argumentnullexception(nameof(configurecontext)); } var builder = new httpcontextbuilder(application); builder.configure(context => { var request = context.request; request.scheme = baseaddress.scheme; request.host = hoststring.fromuricomponent(baseaddress); if (baseaddress.isdefaultport) { request.host = new hoststring(request.host.host); } var pathbase = pathstring.fromuricomponent(baseaddress); if (pathbase.hasvalue && pathbase.value.endswith("/")) { pathbase = new pathstring(pathbase.value.substring(0, pathbase.value.length - 1)); } request.pathbase = pathbase; }); builder.configure(configurecontext); return await builder.sendasync(cancellationtoken).configureawait(false); } public void dispose() { if (!_disposed) { _disposed = true; _hostinstance.dispose(); } } task iserver.startasync<tcontext>(ihttpapplication<tcontext> application, cancellationtoken cancellationtoken) { _application = new applicationwrapper<context>((ihttpapplication<context>)application, () => { if (_disposed) { throw new objectdisposedexception(gettype().fullname); } }); return task.completedtask; } task iserver.stopasync(cancellationtoken cancellationtoken) { return task.completedtask; } private class applicationwrapper<tcontext> : ihttpapplication<tcontext> { private readonly ihttpapplication<tcontext> _application; private readonly action _preprocessrequestasync; public applicationwrapper(ihttpapplication<tcontext> application, action preprocessrequestasync) { _application = application; _preprocessrequestasync = preprocessrequestasync; } public tcontext createcontext(ifeaturecollection contextfeatures) { return _application.createcontext(contextfeatures); } public void disposecontext(tcontext context, exception exception) { _application.disposecontext(context, exception); } public task processrequestasync(tcontext context) { _preprocessrequestasync(); return _application.processrequestasync(context); } } }
3.3 testserver 类代码量比较大,不过不要紧,我们只需要关注它的构造方法就可以了
public testserver(iwebhostbuilder builder) : this(builder, new featurecollection()) { }
3.4 其构造方法接受一个 iwebhostbuilder 对象,只要我们传入一个 webhostbuilder 就可以创建一个测试主机了
3.5 创建测试主机和 httpclient 客户端,我们在测试类 valuesunittest 编写如下代码
public class valuesunittest { private testserver testserver; private httpclient httpclient; public valuesunittest() { testserver = new testserver(new webhostbuilder().usestartup<ron.testdemo.startup>()); httpclient = testserver.createclient(); } [fact] public async void gettest() { var data = await httpclient.getasync("/api/values/100"); var result = await data.content.readasstringasync(); assert.equal("300", result); } }
代码解释
这段代码非常简单,首先,我们声明了一个 testserver 和 httpclient 对象,并在构造方法中初始化他们; testserver 的初始化是由我们 new 了一个 builder 对象,并指定其使用待测试项目 ron.testdemo 中的 startup 类来启动,这样我们能可以直接使用待测试项目的路由和管道了,甚至我们无需指定测试站点,因为这些都会在 testserver 自动配置一个 localhost 的主机地址
3.7 接下来就是创建了一个单元测试的方法,直接使用刚才初始化的 httpclient 对象进行网络请求,这个时候,我们只需要知道 action 即可,同时传递参数 100,最后断言服务器输出值为:"300",回顾一下我们创建的待测试方法,其业务正是将客户端传入的 id 值和配置文件 max 值相加后输出,而 max 值在这里被配置为 200
3.8 运行单元测试
3.9 测试通过,可以看到,测试达到了预期的结果,服务器正确返回了计算后的值
4. 配置文件注意事项
4.1 在待测试项目中的配置文件 appsettings.json 并不会被测试主机所读取,因为我们在上面创建测试主机的时候没有调用方法
webhost.createdefaultbuilder
4.2 我们只是创建了一个 webhostbuilder 对象,非常轻量的主机配置,简单来说就是无配置,如果对于 webhost.createdefaultbuilder 不理解的同学,建议阅读我的文章 .
4.3 所以,为了能够在单元测试中使用项目配置文件,我在 ron.testdemo 项目中的 startup 类加入了下面的代码
public class startup { public startup(iconfiguration configuration, ihostingenvironment env) { this.configuration = new configurationbuilder() .addjsonfile("appsettings.json") .addenvironmentvariables() .setbasepath(env.contentrootpath) .build(); } public iconfiguration configuration { get; } // this method gets called by the runtime. use this method to add services to the container. public void configureservices(iservicecollection services) { services.addsingleton<iconfiguration>(this.configuration); services.addmvc().setcompatibilityversion(compatibilityversion.version_2_2); } }
4.4 其目的就是手动读取配置文件,重新初始化 iconfiguration 对象,并将 this.configuration 对象加入依赖注入容器中
结语
- 本文从单元测试入手,针对常见的系统集成测试提供了另外一种便捷的测试方案,通过创建 testserver 测试主机开始,利用主机创建 httpclient 对象进行网络集成测试
- 减少重复启动程序和测试工具,提高了测试效率
- 充分利用了 visual studio 的优势,既可以做单元测试,还能利用这种测试方案进行快速代码调试
- 最后,还了解如何通过 testserver 主机加载待测试项目的配置文件对象 iconfiguration