为AspNetCore中间件编写单元测试
1.编辑单元测试项目文件
例如使用xUnitTest项目时,在项目文件中的Project节点下加入如下代码:
1 <ItemGroup> 2 <FrameworkReference Include="Microsoft.AspNetCore.App"></FrameworkReference> 3 </ItemGroup>
2.配置和构建Host
例如配置服务、指定要使用的服务器以及加入需要测试的中间件,主要代码如下:
1 using IHost host = new HostBuilder() 2 .ConfigureWebHost(webHostBuilder => 3 { 4 webHostBuilder 5 .ConfigureServices(services => 6 { 7 services.AddHttpContextAccessor(); 8 services.AddLogging(); 9 services.AddRouting(); 10 }) 11 .UseKestrel() 12 .UseUrls(GetTestUrl(server)) 13 .UseWebRoot(AppContext.BaseDirectory) 14 .Configure(app => 15 { 16 app.UseRouting(); 17 18 app.Use(next => context => 19 { 20 // Assign an endpoint, this will make the default files noop. 21 context.SetEndpoint(new Endpoint((c) => 22 { 23 return context.Response.WriteAsync("endpoint."); 24 }, 25 new EndpointMetadataCollection(), "test")); 26 27 return next(context); 28 }); 29 30 app.UseStaticFiles(); 31 32 //这里是需要单测的中间件 33 app.UseDemoMiddleware(); 34 35 app.UseEndpoints(endpoints => { }); 36 }); 37 }) 38 .Build();
3.启动和使用Host
配置完Host后,将该Host实例启动,并且使用HttpClient模拟请求,以期执行自定义的中间件,主要代码如下:
1 await host.StartAsync(); 2 3 using var client = new HttpClient { BaseAddress = new Uri(GetAddress(host)) }; 4 string url = "/api/demo"; 5 var response = await client.GetAsync(url); 6 7 Assert.Equal(HttpStatusCode.OK, response.StatusCode);
4.完整示例如下
4.1 ServerType
1 public enum ServerType 2 { 3 None, 4 IISExpress, 5 IIS, 6 HttpSys, 7 Kestrel, 8 Nginx 9 }
4.2 TestUriHelper
1 public static class TestUriHelper 2 { 3 public static Uri BuildTestUri(ServerType serverType) 4 { 5 return BuildTestUri(serverType, hint: null); 6 } 7 8 public static Uri BuildTestUri(ServerType serverType, string hint) 9 { 10 // Assume status messages are enabled for Kestrel and disabled for all other servers. 11 var statusMessagesEnabled = (serverType == ServerType.Kestrel); 12 13 return BuildTestUri(serverType, Uri.UriSchemeHttp, hint, statusMessagesEnabled); 14 } 15 16 internal static Uri BuildTestUri(ServerType serverType, string scheme, string hint, bool statusMessagesEnabled) 17 { 18 if (string.IsNullOrEmpty(hint)) 19 { 20 if (serverType == ServerType.Kestrel && statusMessagesEnabled) 21 { 22 // Most functional tests use this codepath and should directly bind to dynamic port "0" and scrape 23 // the assigned port from the status message, which should be 100% reliable since the port is bound 24 // once and never released. Binding to dynamic port "0" on "localhost" (both IPv4 and IPv6) is not 25 // supported, so the port is only bound on "127.0.0.1" (IPv4). If a test explicitly requires IPv6, 26 // it should provide a hint URL with "localhost" (IPv4 and IPv6) or "[::1]" (IPv6-only). 27 return new UriBuilder(scheme, "127.0.0.1", 0).Uri; 28 } 29 else if (serverType == ServerType.HttpSys) 30 { 31 Debug.Assert(scheme == "http", "Https not supported"); 32 return new UriBuilder(scheme, "localhost", 0).Uri; 33 } 34 else 35 { 36 // If the server type is not Kestrel, or status messages are disabled, there is no status message 37 // from which to scrape the assigned port, so the less reliable GetNextPort() must be used. The 38 // port is bound on "localhost" (both IPv4 and IPv6), since this is supported when using a specific 39 // (non-zero) port. 40 return new UriBuilder(scheme, "localhost", GetNextPort()).Uri; 41 } 42 } 43 else 44 { 45 var uriHint = new Uri(hint); 46 if (uriHint.Port == 0) 47 { 48 // Only a few tests use this codepath, so it's fine to use the less reliable GetNextPort() for simplicity. 49 // The tests using this codepath will be reviewed to see if they can be changed to directly bind to dynamic 50 // port "0" on "127.0.0.1" and scrape the assigned port from the status message (the default codepath). 51 return new UriBuilder(uriHint) { Port = GetNextPort() }.Uri; 52 } 53 else 54 { 55 // If the hint contains a specific port, return it unchanged. 56 return uriHint; 57 } 58 } 59 } 60 61 private static int GetNextPort() 62 { 63 using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); 64 socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); 65 return ((IPEndPoint)socket.LocalEndPoint).Port; 66 } 67 }
4.3 DemoMiddlewareUnitTest
1 public class DemoMiddlewareUnitTest 2 { 3 [Fact] 4 public async Task DemoMiddlewareTest() 5 { 6 await DemoMiddleware(ServerType.IIS); 7 await DemoMiddleware(ServerType.IISExpress); 8 await DemoMiddleware(ServerType.Kestrel); 9 await DemoMiddleware(ServerType.Nginx); 10 } 11 12 private async Task DemoMiddleware(ServerType server) 13 { 14 using IHost host = new HostBuilder() 15 .ConfigureWebHost(webHostBuilder => 16 { 17 webHostBuilder 18 .ConfigureServices(services => 19 { 20 services.AddHttpContextAccessor(); 21 services.AddLogging(); 22 services.AddRouting(); 23 }) 24 .UseKestrel() 25 .UseUrls(GetTestUrl(server)) 26 .UseWebRoot(AppContext.BaseDirectory) 27 .Configure(app => 28 { 29 app.UseRouting(); 30 31 app.Use(next => context => 32 { 33 // Assign an endpoint, this will make the default files noop. 34 context.SetEndpoint(new Endpoint((c) => 35 { 36 return context.Response.WriteAsync("endpoint."); 37 }, 38 new EndpointMetadataCollection(), "test")); 39 40 return next(context); 41 }); 42 43 app.UseStaticFiles(); 44 45 app.UseDemoMiddleware(); 46 47 app.UseEndpoints(endpoints => { }); 48 }); 49 }) 50 .Build(); 51 52 await host.StartAsync(); 53 54 using var client = new HttpClient { BaseAddress = new Uri(GetAddress(host)) }; 55 string url = "/api/demo"; 56 var response = await client.GetAsync(url); 57 58 Assert.Equal(HttpStatusCode.OK, response.StatusCode); 59 Assert.Equal("endpoint.", await response.Content.ReadAsStringAsync()); 60 } 61 62 private string GetTestUrl(ServerType serverType) 63 { 64 return TestUriHelper.BuildTestUri(serverType).ToString(); 65 } 66 67 private string GetAddress(IHost server) 68 { 69 return server.Services.GetService<IServer>().Features.Get<IServerAddressesFeature>().Addresses.First(); 70 } 71 }