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 不理解的同學,建議閱讀我的文章 ofollow,noindex" target="_blank">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 物件加入依賴注入容器中
結語
- 本文從單元測試入手,針對常見的系統整合測試提供了另外一種便捷的測試方案,通過建立 TestServer 測試主機開始,利用主機建立 HttpCLient 物件進行網路整合測試
- 減少重複啟動程式和測試工具,提高了測試效率
- 充分利用了 Visual Studio 的優勢,既可以做單元測試,還能利用這種測試方案進行快速程式碼除錯
- 最後,還了解如何通過 TestServer 主機載入待測試專案的配置檔案物件 IConfiguration