前言

    在开发 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 不理解的同学,建议阅读我的文章asp.netcore 深入了解配置文件加载过程
<https://www.cnblogs.com/viter/p/9967936.html>.

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
示例代码下载

https://github.com/lianggx/EasyAspNetCoreDemo/tree/master/Ron.TestDemo
<https://github.com/lianggx/EasyAspNetCoreDemo/tree/master/Ron.TestDemo>

友情链接
KaDraw流程图
API参考文档
OK工具箱
云服务器优惠
阿里云优惠券
腾讯云优惠券
华为云优惠券
站点信息
问题反馈
邮箱:ixiaoyang8@qq.com
QQ群:637538335
关注微信