Skip to content

Commit 8e14559

Browse files
authored
Add a new UseKestel API to the WebApplicationFactory (#60635)
- Added `UseKestrel(...)` APIs to the WebApplicationFactory<T> type. The API configures the WAF, so that later during initialization it will use Kestrel, instead of a TestServer for WebHostBuilder-based applications. - Two different overloads (described in the API Proposal #60758) are added - Renamed the `EnsureServer()` private method to `StartServer()` and made it public, so that consumers can call it directly, without the need of creating a client, as in many situations customers didn't need a client to interact with. This is an alternative design to enabling real server usage with WebApplicationFactory, which was considered here: #60247 Fixes #4892
1 parent d4bdf0a commit 8e14559

10 files changed

+415
-21
lines changed

src/Mvc/Mvc.Testing/src/Microsoft.AspNetCore.Mvc.Testing.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<ItemGroup>
1414
<Reference Include="Microsoft.AspNetCore.TestHost" />
1515
<Reference Include="Microsoft.AspNetCore.Mvc.Core" />
16+
<Reference Include="Microsoft.AspNetCore.Server.Kestrel" />
1617
<Reference Include="Microsoft.Extensions.DependencyModel" />
1718
<Reference Include="Microsoft.Extensions.Hosting" />
1819
<Reference Include="Microsoft.Extensions.HostFactoryResolver.Sources" />
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.StartServer() -> void
3+
Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.UseKestrel() -> void
4+
Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.UseKestrel(int port) -> void
5+
Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.UseKestrel(System.Action<Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions!>! configureKestrelOptions) -> void

src/Mvc/Mvc.Testing/src/Resources.resx

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<?xml version="1.0" encoding="utf-8"?>
1+
<?xml version="1.0" encoding="utf-8"?>
22
<root>
33
<!--
44
Microsoft ResX Schema
@@ -126,4 +126,13 @@
126126
<data name="MissingDepsFile" xml:space="preserve">
127127
<value>Can't find '{0}'. This file is required for functional tests to run properly. There should be a copy of the file on your source project bin folder. If that is not the case, make sure that the property PreserveCompilationContext is set to true on your project file. E.g '&lt;PreserveCompilationContext&gt;true&lt;/PreserveCompilationContext&gt;'. For functional tests to work they need to either run from the build output folder or the {1} file from your application's output directory must be copied to the folder where the tests are running on. A common cause for this error is having shadow copying enabled when the tests run.</value>
128128
</data>
129+
<data name="ServerNotInitialized" xml:space="preserve">
130+
<value>Server hasn't been initialized yet. Please initialize the server first before trying to create a client.</value>
131+
</data>
132+
<data name="TestServerNotSupportedWhenUsingKestrel" xml:space="preserve">
133+
<value>Accessing the `Server` property isn't supported when using Kestrel server.</value>
134+
</data>
135+
<data name="UseKestrelCanBeCalledBeforeInitialization" xml:space="preserve">
136+
<value>UseKestrel should be called before server initialization. Calling UseKestrel after the server was initialized will have no effect.</value>
137+
</data>
129138
</root>

src/Mvc/Mvc.Testing/src/WebApplicationFactory.cs

+212-18
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Diagnostics.CodeAnalysis;
54
using System.Linq;
65
using System.Net.Http;
76
using System.Reflection;
87
using System.Text.Json;
98
using System.Text.Json.Serialization;
109
using Microsoft.AspNetCore.Hosting;
1110
using Microsoft.AspNetCore.Hosting.Server;
11+
using Microsoft.AspNetCore.Hosting.Server.Features;
12+
using Microsoft.AspNetCore.Server.Kestrel.Core;
1213
using Microsoft.AspNetCore.TestHost;
1314
using Microsoft.Extensions.Configuration;
1415
using Microsoft.Extensions.DependencyInjection;
@@ -26,9 +27,16 @@ public partial class WebApplicationFactory<TEntryPoint> : IDisposable, IAsyncDis
2627
{
2728
private bool _disposed;
2829
private bool _disposedAsync;
30+
31+
private bool _useKestrel;
32+
private int? _kestrelPort;
33+
private Action<KestrelServerOptions>? _configureKestrelOptions;
34+
2935
private TestServer? _server;
3036
private IHost? _host;
3137
private Action<IWebHostBuilder> _configuration;
38+
private IWebHost? _webHost;
39+
private Uri? _webHostAddress;
3240
private readonly List<HttpClient> _clients = new();
3341
private readonly List<WebApplicationFactory<TEntryPoint>> _derivedFactories = new();
3442

@@ -75,8 +83,13 @@ public TestServer Server
7583
{
7684
get
7785
{
78-
EnsureServer();
79-
return _server;
86+
if (_useKestrel)
87+
{
88+
throw new NotSupportedException(Resources.TestServerNotSupportedWhenUsingKestrel);
89+
}
90+
91+
StartServer();
92+
return _server!;
8093
}
8194
}
8295

@@ -87,11 +100,21 @@ public virtual IServiceProvider Services
87100
{
88101
get
89102
{
90-
EnsureServer();
91-
return _host?.Services ?? _server.Host.Services;
103+
StartServer();
104+
if (_useKestrel)
105+
{
106+
return _webHost!.Services;
107+
}
108+
109+
return _host?.Services ?? _server!.Host.Services;
92110
}
93111
}
94112

113+
/// <summary>
114+
/// Helps determine if the `StartServer` method has been called already.
115+
/// </summary>
116+
private bool ServerStarted => _webHost != null || _host != null || _server != null;
117+
95118
/// <summary>
96119
/// Gets the <see cref="IReadOnlyList{WebApplicationFactory}"/> of factories created from this factory
97120
/// by further customizing the <see cref="IWebHostBuilder"/> when calling
@@ -136,10 +159,92 @@ internal virtual WebApplicationFactory<TEntryPoint> WithWebHostBuilderCore(Actio
136159
return factory;
137160
}
138161

139-
[MemberNotNull(nameof(_server))]
140-
private void EnsureServer()
162+
/// <summary>
163+
/// Configures the factory to use Kestrel as the server.
164+
/// </summary>
165+
public void UseKestrel()
141166
{
142-
if (_server != null)
167+
if (ServerStarted)
168+
{
169+
throw new InvalidOperationException(Resources.UseKestrelCanBeCalledBeforeInitialization);
170+
}
171+
172+
_useKestrel = true;
173+
}
174+
175+
/// <summary>
176+
/// Configures the factory to use Kestrel as the server.
177+
/// </summary>
178+
/// <param name="port">The port to listen to when the server starts. Use `0` to allow dynamic port selection.</param>
179+
/// <exception cref="InvalidOperationException">Thrown, if this method is called after the WebHostFactory has been initialized.</exception>
180+
/// <remarks>This method should be called before the factory is initialized either via one of the <see cref="CreateClient()"/> methods
181+
/// or via the <see cref="StartServer"/> method.</remarks>
182+
public void UseKestrel(int port)
183+
{
184+
UseKestrel();
185+
186+
this._kestrelPort = port;
187+
}
188+
189+
/// <summary>
190+
/// Configures the factory to use Kestrel as the server.
191+
/// </summary>
192+
/// <param name="configureKestrelOptions">A callback handler that will be used for configuring the server when it starts.</param>
193+
/// <exception cref="InvalidOperationException">Thrown, if this method is called after the WebHostFactory has been initialized.</exception>
194+
/// <remarks>This method should be called before the factory is initialized either via one of the <see cref="CreateClient()"/> methods
195+
/// or via the <see cref="StartServer"/> method.</remarks>
196+
public void UseKestrel(Action<KestrelServerOptions> configureKestrelOptions)
197+
{
198+
UseKestrel();
199+
200+
this._configureKestrelOptions = configureKestrelOptions;
201+
}
202+
203+
private IWebHost CreateKestrelServer(IWebHostBuilder builder)
204+
{
205+
ConfigureBuilderToUseKestrel(builder);
206+
207+
var host = builder.Build();
208+
209+
TryConfigureServerPort(() => GetServerAddressFeature(host));
210+
211+
host.Start();
212+
return host;
213+
}
214+
215+
private void TryConfigureServerPort(Func<IServerAddressesFeature?> serverAddressFeatureAccessor)
216+
{
217+
if (_kestrelPort.HasValue)
218+
{
219+
var saf = serverAddressFeatureAccessor();
220+
if (saf is not null)
221+
{
222+
saf.Addresses.Clear();
223+
saf.Addresses.Add($"http://127.0.0.1:{_kestrelPort}");
224+
saf.PreferHostingUrls = true;
225+
}
226+
}
227+
}
228+
229+
private void ConfigureBuilderToUseKestrel(IWebHostBuilder builder)
230+
{
231+
if (_configureKestrelOptions is not null)
232+
{
233+
builder.UseKestrel(_configureKestrelOptions);
234+
}
235+
else
236+
{
237+
builder.UseKestrel();
238+
}
239+
}
240+
241+
/// <summary>
242+
/// Initializes the instance by configurating the host builder.
243+
/// </summary>
244+
/// <exception cref="InvalidOperationException">Thrown if the provided <typeparamref name="TEntryPoint"/> type has no factory method.</exception>
245+
public void StartServer()
246+
{
247+
if (ServerStarted)
143248
{
144249
return;
145250
}
@@ -197,21 +302,53 @@ private void EnsureServer()
197302
{
198303
SetContentRoot(builder);
199304
_configuration(builder);
200-
_server = CreateServer(builder);
305+
if (_useKestrel)
306+
{
307+
_webHost = CreateKestrelServer(builder);
308+
309+
TryExtractHostAddress(GetServerAddressFeature(_webHost));
310+
}
311+
else
312+
{
313+
_server = CreateServer(builder);
314+
}
315+
}
316+
}
317+
318+
private void TryExtractHostAddress(IServerAddressesFeature? serverAddressFeature)
319+
{
320+
if (serverAddressFeature?.Addresses.Count > 0)
321+
{
322+
// Store the web host address as it's going to be used every time a client is created to communicate to the server
323+
_webHostAddress = new Uri(serverAddressFeature.Addresses.Last());
324+
ClientOptions.BaseAddress = _webHostAddress;
201325
}
202326
}
203327

204-
[MemberNotNull(nameof(_server))]
205328
private void ConfigureHostBuilder(IHostBuilder hostBuilder)
206329
{
207330
hostBuilder.ConfigureWebHost(webHostBuilder =>
208331
{
209332
SetContentRoot(webHostBuilder);
210333
_configuration(webHostBuilder);
211-
webHostBuilder.UseTestServer();
334+
if (_useKestrel)
335+
{
336+
ConfigureBuilderToUseKestrel(webHostBuilder);
337+
}
338+
else
339+
{
340+
webHostBuilder.UseTestServer();
341+
}
212342
});
213343
_host = CreateHost(hostBuilder);
214-
_server = (TestServer)_host.Services.GetRequiredService<IServer>();
344+
if (_useKestrel)
345+
{
346+
TryExtractHostAddress(GetServerAddressFeature(_host));
347+
}
348+
else
349+
{
350+
_server = (TestServer)_host.Services.GetRequiredService<IServer>();
351+
}
215352
}
216353

217354
private void SetContentRoot(IWebHostBuilder builder)
@@ -438,10 +575,15 @@ private static void EnsureDepsFile()
438575
protected virtual IHost CreateHost(IHostBuilder builder)
439576
{
440577
var host = builder.Build();
578+
TryConfigureServerPort(() => GetServerAddressFeature(host));
441579
host.Start();
442580
return host;
443581
}
444582

583+
private static IServerAddressesFeature? GetServerAddressFeature(IHost host) => host.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>();
584+
585+
private static IServerAddressesFeature? GetServerAddressFeature(IWebHost webHost) => webHost.ServerFeatures.Get<IServerAddressesFeature>();
586+
445587
/// <summary>
446588
/// Gives a fixture an opportunity to configure the application before it gets built.
447589
/// </summary>
@@ -455,8 +597,21 @@ protected virtual void ConfigureWebHost(IWebHostBuilder builder)
455597
/// redirects and handles cookies.
456598
/// </summary>
457599
/// <returns>The <see cref="HttpClient"/>.</returns>
458-
public HttpClient CreateClient() =>
459-
CreateClient(ClientOptions);
600+
public HttpClient CreateClient()
601+
{
602+
var client = CreateClient(ClientOptions);
603+
604+
if (_useKestrel && object.ReferenceEquals(client.BaseAddress, WebApplicationFactoryClientOptions.DefaultBaseAddres))
605+
{
606+
// When using Kestrel, the server may start to listen on a pre-configured port,
607+
// which can differ from the one configured via ClientOptions.
608+
// Hence, if the ClientOptions haven't been set explicitly to a custom value, we will assume that
609+
// the user wants the client to communicate on a port that the server listens too, and because that port may be different, we overwrite it here.
610+
client.BaseAddress = ClientOptions.BaseAddress;
611+
}
612+
613+
return client;
614+
}
460615

461616
/// <summary>
462617
/// Creates an instance of <see cref="HttpClient"/> that automatically follows
@@ -476,12 +631,24 @@ public HttpClient CreateClient(WebApplicationFactoryClientOptions options) =>
476631
/// <returns>The <see cref="HttpClient"/>.</returns>
477632
public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
478633
{
479-
EnsureServer();
634+
StartServer();
480635

481636
HttpClient client;
482637
if (handlers == null || handlers.Length == 0)
483638
{
484-
client = _server.CreateClient();
639+
if (_useKestrel)
640+
{
641+
client = new HttpClient();
642+
}
643+
else
644+
{
645+
if (_server is null)
646+
{
647+
throw new InvalidOperationException(Resources.ServerNotInitialized);
648+
}
649+
650+
client = _server.CreateClient();
651+
}
485652
}
486653
else
487654
{
@@ -490,7 +657,7 @@ public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
490657
handlers[i - 1].InnerHandler = handlers[i];
491658
}
492659

493-
var serverHandler = _server.CreateHandler();
660+
var serverHandler = CreateHandler();
494661
handlers[^1].InnerHandler = serverHandler;
495662

496663
client = new HttpClient(handlers[0]);
@@ -503,6 +670,21 @@ public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
503670
return client;
504671
}
505672

673+
private HttpMessageHandler CreateHandler()
674+
{
675+
if (_useKestrel)
676+
{
677+
return new HttpClientHandler();
678+
}
679+
680+
if (_server is null)
681+
{
682+
throw new InvalidOperationException(Resources.ServerNotInitialized);
683+
}
684+
685+
return _server.CreateHandler();
686+
}
687+
506688
/// <summary>
507689
/// Configures <see cref="HttpClient"/> instances created by this <see cref="WebApplicationFactory{TEntryPoint}"/>.
508690
/// </summary>
@@ -511,7 +693,19 @@ protected virtual void ConfigureClient(HttpClient client)
511693
{
512694
ArgumentNullException.ThrowIfNull(client);
513695

514-
client.BaseAddress = new Uri("http://localhost");
696+
if (_useKestrel)
697+
{
698+
if (_webHost is null && _host is null)
699+
{
700+
throw new InvalidOperationException(Resources.ServerNotInitialized);
701+
}
702+
703+
client.BaseAddress = _webHostAddress;
704+
}
705+
else
706+
{
707+
client.BaseAddress = new Uri("http://localhost");
708+
}
515709
}
516710

517711
/// <summary>

0 commit comments

Comments
 (0)