在開發 Asp.Net Core 應用程序的過程當中,咱們經常須要對業務代碼編寫單元測試,這種方法既快速又有效,利用單元測試作代碼覆蓋測試,也是很是必要的事情;可是,但咱們須要對系統進行集成測試的時候,須要啓動服務主機,利用瀏覽器或者Postman 等網絡工具對接口進行集成測試,這就很是的不方便,同時浪費了大量的時間在重複啓動應用程序上;今天要介紹就是如何在不啓動應用程序的狀況下,對 Asp.Net Core WebApi 項目進行網絡集成測試。html
1.1 首先咱們創建兩個項目,Asp.Net Core WebApi 和 xUnit 單元測試項目,以下git
1.2 上圖的單元測試項目 Ron.XUnitTest 必須應用待測試的 WebApi 項目 Ron.TestDemo
1.3 接下來打開 Ron.XUnitTest 項目文件 .csproj,添加包引用github
Microsoft.AspNetCore.App Microsoft.AspNetCore.TestHost
1.4 爲何要引用這兩個包呢,由於我剛纔建立的 WebApi 項目是引用 Microsoft.AspNetCore.App 的,至於 Microsoft.AspNetCore.TestHost,它是今天的主角,爲了使用測試主機,必須對其進行引用,下面會詳細說明web
2.1 建立一個接口,代碼以下json
[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 相加,而後輸出結果給客戶端api
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 編寫以下代碼app
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.1 在待測試項目中的配置文件 appsettings.json 並不會被測試主機所讀取,由於咱們在上面建立測試主機的時候沒有調用方法
WebHost.CreateDefaultBuilder
4.2 咱們只是建立了一個 WebHostBuilder 對象,很是輕量的主機配置,簡單來講就是無配置,若是對於 WebHost.CreateDefaultBuilder 不理解的同窗,建議閱讀個人文章 asp.netcore 深刻了解配置文件加載過程.
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 對象加入依賴注入容器中
https://github.com/lianggx/EasyAspNetCoreDemo/tree/master/Ron.TestDemo