在 .NET 6 中使用 WebApplicationFactory 支持集成测试
这是该系列的第六篇文章:探索 .NET 6。
在上一篇文章中,我介绍了在 .NET 6 中添加的变通方法,以便以前依赖于特定存在的方法(如 CreateHostBuilder)的 EF Core 工具将继续能在新的最小托管 APIs中使用。
在这篇文章中,我将介绍一个相关的更改,以确保与 WebApplicationFactory 的集成测试在 .NET 6 中工作。WebApplicationFactory 使用与 EF Core 工具相同的 HostFactoryResolver 类,但它也需要更多的更改,我将在这篇文章中介绍这些更改。
# ASP.NET Core 3.x/5 中的 WebApplicationFactory
有多种方法可以测试 ASP.NET Core 3.x/5 应用。最彻底的方法之一是编写集成测试,在内存中运行整个应用程序。使用 Microsoft.AspNetCore.Mvc.Testing 软件包和WebApplicationFactory<T>,这非常容易。
例如,以下代码基于文档 (opens new window),演示如何使用 WebApplicationFactory 创建应用程序的内存中实例、创建用于发出请求的 HttpClient 以及发送内存中 HTTP 请求。
public class BasicTests : IClassFixture<WebApplicationFactory<Startup>>
{
private readonly WebApplicationFactory<Startup> _factory;
public BasicTests(WebApplicationFactory<Startup> factory)
{
_factory = factory;
}
[Fact]
public async Task Get_EndpointsReturnSuccessAndCorrectContentType()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/");
// Assert
response.EnsureSuccessStatusCode(); // Status Code 200-299
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在幕后,WebApplicationFactory<T> 使用我在上一篇文章中描述的相同的HostFactoryResolver (opens new window)。泛型参数 TEntryPoint 通常设置为 Startup,但它只需要是入口程序集中的类型,以便它可以找到 CreateHostBuilder() 方法:
public class WebApplicationFactory<TEntryPoint> : IDisposable where TEntryPoint : class
{
protected virtual IHostBuilder CreateHostBuilder()
{
var hostBuilder = HostFactoryResolver.ResolveHostBuilderFactory<IHostBuilder>(typeof(TEntryPoint).Assembly)?.Invoke(Array.Empty<string>());
if (hostBuilder != null)
{
hostBuilder.UseEnvironment(Environments.Development);
}
return hostBuilder;
}
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
正如我在上一篇文章中所描述的,HostFactoryResolver使用反射来查找约定命名的方法CreateHostBuilder() 或 CreateWebHostBuilder() 并调用它们。但是,在 .NET 6 中,最小托管 APIs 和顶级程序已经取消了这些约定,从头就破坏了 WebApplicationFactory(以及 EF Core 工具)。
# 在 .NET 6 中构建 IHost
在上一篇文章中,我描述了对 HostBuilder 所做的更改,以支持 WebApplicationFactory 和 EF Core 工具都使用的 HostFactoryResolver。这主要是通过向 HostBuilder 添加其他 DiagnosticSource 事件来实现的。这些为HostFactoryResolver 提供了一种访问 HostBuilder 的方法,而无需以前版本的约定。

WebApplicationFactory 也受益于这种新机制,但还需要进行一些额外的更改。EF Core 工具只需要访问构建的 IHost,以便它们可以检索 IServiceProvider。一旦您构建了 IHost,IServiceProvider就会固定,因此上图中所示的"中止应用程序"方法运行良好。
但是,这不适用于WebApplicationFactory。WebApplicationFactory 需要能够在调用 Build() 之前修改应用程序中的 HostBuilder,但它也不能在调用 HostBuilder.Build() 之后仅仅停止程序。
在 .NET 6 中,您可以(并且确实)在调用 WebApplicationBuilder.Build() 和 WebApplication.Run() 之间编写各种代码。您无法修改 IServiceCollection,但可以在以下调用之间注册端点和中间件:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
var app = builder.Build(); // calls HostBuilder.Build()
app.UseStaticFiles();
app.MapGet("/", () => "Hello World!");
app.MapRazorPages();
app.Run(); // calls Host.StartAsync()
2
3
4
5
6
7
8
9
10
11
这使得 WebApplicationFactory 变得更加棘手,因为它需要在 Program.cs 中运行所有代码,直到调用 app.Run(),因此它不能仅依赖于添加到 HostBuilder 的 DiagnosticSource 事件。在本文的其余部分,我们将看看 WebApplicationFactory 如何实现这一目标。
# .NET 6 中的 WebApplicationFactory
从表面上看,您使用 WebApplicationFactory 的方式在 .NET 6 中没有改变。我之前演示的完全相同的测试代码将在 .NET 6 中工作,即使您将新的最小托管 APIs 与 WebApplication 和 WebApplicationBuilder 配合使用也是如此。
一个轻微的烦恼是,在
WebApplicationFactory<T>中没有众所周知的Startup类用作 T 泛型参数的"标记" (opens new window)。在实践中,这可能只是演示软件的问题,因为您可以使用Web应用程序中的任何旧类作为标记,但这是需要注意的。
WebApplicationFactory 提供了多种在集成测试中自定义应用程序的方法,但其核心是它提供了一种在内存中运行应用程序的 Host 实例的方法。此过程的核心方法之一是 EnsureServer(),部分如下所示。
public class WebApplicationFactory<TEntryPoint> : IDisposable, IAsyncDisposable where TEntryPoint : class
{
private void EnsureServer()
{
// Ensure that we can find the .deps.json for the application
EnsureDepsFile();
// Attempt to create the application's HostBuilder using the conventional
// CreateHostBuilder method (used in ASP.NET Core 3.x/5) and HostFactoryResolver
var hostBuilder = CreateHostBuilder();
if (hostBuilder is not null)
{
// If we succeeded, apply customisation to the host builder (shown below)
ConfigureHostBuilder(hostBuilder);
return;
}
// Attempt to create the application's WebHostBuilder using the conventional
// CreateWebHostBuilder method (used in ASP.NET Core 2.x) and HostFactoryResolver
var builder = CreateWebHostBuilder();
if (builder is null)
{
// Failed to create the WebHostBuilder, so try the .NET 6 approach
// (shown in following section)
// ...
}
else
{
// succeeded in creating WebHostBuilder, so apply customisation and exit
SetContentRoot(builder);
_configuration(builder);
_server = CreateServer(builder);
}
}
private void ConfigureHostBuilder(IHostBuilder hostBuilder)
{
// Customise the web host
hostBuilder.ConfigureWebHost(webHostBuilder =>
{
SetContentRoot(webHostBuilder);
_configuration(webHostBuilder);
// Replace Kestrel with TestServer
webHostBuilder.UseTestServer();
});
// Create the IHost
_host = CreateHost(hostBuilder);
// Retrieve the TestServer instance
_server = (TestServer)_host.Services.GetRequiredService<IServer>();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
EnsureServer() 负责使用 HostFactoryResolver 填充 TestServer 字段 _server。它首先尝试通过查找 Program.CreateHostBuilder() 方法来创建 IHostBuilder 实例,该方法通常用于 ASP.NET Core 3.x/5。如果失败,它将查找在 ASP.NET Core 2.x 中使用的 Program.CreateWebHostBuilder() 方法。如果失败,它将求助于 .NET 6 方法,该方法已从上述方法中提取,如下所示。
// Create a DeferredHostBuilder, which I'll discuss shortly
var deferredHostBuilder = new DeferredHostBuilder();
deferredHostBuilder.UseEnvironment(Environments.Development);
// Ensure the application name is set correctly. Without this, the application name
// would be set to the testhost (see https://github.com/dotnet/aspnetcore/pull/35101)
deferredHostBuilder.ConfigureHostConfiguration(config =>
{
config.AddInMemoryCollection(new Dictionary<string, string>
{
{ HostDefaults.ApplicationKey, typeof(TEntryPoint).Assembly.GetName()?.Name ?? string.Empty }
});
});
// This helper call does the hard work to determine if we can fallback to diagnostic source events to get the host instance
var factory = HostFactoryResolver.ResolveHostFactory(
typeof(TEntryPoint).Assembly,
stopApplication: false,
configureHostBuilder: deferredHostBuilder.ConfigureHostBuilder,
entrypointCompleted: deferredHostBuilder.EntryPointCompleted);
if (factory is not null)
{
// If we have a valid factory it means the specified entry point's assembly can potentially resolve the IHost
// so we set the factory on the DeferredHostBuilder so we can invoke it on the call to IHostBuilder.Build.
deferredHostBuilder.SetHostFactory(factory);
ConfigureHostBuilder(deferredHostBuilder);
return;
}
// Failed to resolve the .NET 6 entrypoint, so failed at this point
throw new InvalidOperationException();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
此方法使用一种新类型,DeferredHostBuilder,我们稍后将对此进行研究,但重要的部分是对 HostFactoryResolver.ResolveHostFactory() 的调用。这是使用我在上一篇文章中讨论的 DiagnosticSource 事件来自定义 IHostBuilder 并访问 IHost 的方法。具体而言,该调用注册两个回调:
deferredHostBuilder.ConfigureHostBuilder:在构建IHostBuilder之前调用,并传递了IHostBuilder实例。deferredHostBuilder.EntryPointCompleted:如果在生成过程中发生异常,则调用。
重要的是,stopApplication 参数设置为 false;这可确保应用程序启动过程不间断地继续进行。
与此形成对比的是 EF Core 工具方法,其中
stopApplication=true。EF Core 工具不想运行您的应用程序,它们只需要访问IHost(和IServiceProvider),因此它们可以在构建这些应用程序后停止。
下图显示了与 WebApplicationFactory、HostFactoryResolver 和 DeferredHostBuilder 的交互,以及我们将在途中看到的其他类型的交互。现在不要担心完全理解这一点,但我认为现在将其视为我们前进方向的路标是有帮助的!

您可能想知道为什么我们在这里需要一种新类型,即 DevendHostBuilder。这是必要的,因为我们必须以异步方式等待"主"应用程序完成运行。在下一节中,我将详细介绍此类型。
# DeferredHostBuilder 并等待 StartAsync 信号
DeferredHostBuilder 是 YAHB(Yet Another IHostBuilder),它是在.NET 6 中引入的(以及许多其他)!它旨在"捕获"在其上调用的配置方法(例如 ConfigureServices() ,然后在实际应用程序的 IHostBuilder 可用时"重播"它们。
"延迟"方法的工作原理是将配置方法收集为多重转换委托,例如:
internal class DeferredHostBuilder : IHostBuilder
{
private Action<IHostBuilder> _configure;
public IHostBuilder ConfigureServices(Action<HostBuilderContext, IServiceCollection> configureDelegate)
{
_configure += b => b.ConfigureServices(configureDelegate);
return this;
}
// ...
}
2
3
4
5
6
7
8
9
10
11
当 DiagnosticSource HostBuilding 事件触发时,这些委托都将应用于IHostBuilder`:
public void ConfigureHostBuilder(object hostBuilder)
{
_configure(((IHostBuilder)hostBuilder));
}
2
3
4
为了让这一切顺利进行,WebApplicationFactory 在 DeferredHostBuilder 上调用 Build()。此方法(如下所示)调用 HostResolverFactory 返回的 _hostFactory 方法。调用此方法将启动上一篇文章中所述的过程,其中应用程序在单独的线程中执行,使用 DiagnosticSource 事件调用 ConfigureHostBuilder() 自定义,并返回 IHost 实例。对于一行代码来说,这是很多!
public IHost Build()
{
// Hosting configuration is being provided by args so that
// we can impact WebApplicationBuilder based applications.
var args = new List<string>();
// Transform the host configuration into command line arguments
foreach (var (key, value) in _hostConfiguration.AsEnumerable())
{
args.Add($"--{key}={value}");
}
// Execute the application in a spearate thread, and listen for DiagnosticSource events
var host = (IHost)_hostFactory!(args.ToArray());
// We can't return the host directly since we need to defer the call to StartAsync
return new DeferredHost(host, _hostStartTcs);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
请记住,当我们检索 IHost 实例时,应用程序不会停止在单独的 Thread 中运行,因为我们需要执行 Program.cs 中的其余代码。DeferredHostBuilder 将 IHost 保存到一个新类型 DefferedHost 中,并从 Build() 调用返回此内容。
_hostStartTcs是TaskCompletionSource,用于处理在后台运行的应用程序由于异常而退出的边缘情况。这是一个边缘案例,但如果没有它,测试可能会无限期地挂起。
DeferredHost 负责等待应用程序正确启动(而不仅仅是构建 IHost)。它需要等待您配置所有端点,以及运行任何其他启动代码。
DeferredHost 通过使用启动应用程序时在普通通用主机应用程序中引发的现有 IHostApplicationLifetime 事件来实现此目的。下图(取自上一篇分析通用主机启动过程的文章)显示,在服务器启动后,在 IHostApplicationLifetime 上调用 NotifyStarted() 方法。

对 NotifyStarted() 的调用会引发 ApplicationStarted 事件,DeferredHost 使用该事件来检测应用程序是否正在运行,并且启动测试是安全的。当 WebApplicationFactory 在 DeferredHost 上调用 Start() 时,DelayedHost 将阻塞,直到引发 ApplicationStarted 事件。
public async Task StartAsync(CancellationToken cancellationToken = default)
{
// Wait on the existing host to start running and have this call wait on that. This avoids starting the actual host too early and
// leaves the application in charge of calling start.
using var reg = cancellationToken.UnsafeRegister(_ => _hostStartedTcs.TrySetCanceled(), null);
// REVIEW: This will deadlock if the application creates the host but never calls start. This is mitigated by the cancellationToken
// but it's rarely a valid token for Start
using var reg2 = _host.Services.GetRequiredService<IHostApplicationLifetime>().ApplicationStarted.UnsafeRegister(_ => _hostStartedTcs.TrySetResult(), null);
await _hostStartedTcs.Task.ConfigureAwait(false);
}
2
3
4
5
6
7
8
9
10
11
12
13
StartAsync() 方法向我前面提到的 TaskCompletionSource 添加其他回调,然后在返回之前等待任务。这将阻止测试代码,直到发生以下三种情况之一:
- Web 应用程序引发异常,该异常在
DeferredHostBuilder上调用EntryPointCompleted()回调并取消该任务。 CancellationToken传递给StartAsync()方法,这将取消任务- 应用程序启动,调用
ApplicationStarted事件,完成任务。
如此方法的注释中所述,如果你从未在 Web 应用中调用 Start() 或 Run(),则这将死锁,但你可能没有有效的 Web 应用,所以这不是一个大问题。
就是这样!调用 Start() 后,WebApplicationFactory 将创建一个 HttpClient,就像在以前的版本中一样,您可以像以前一样进行内存中调用。值得注意的是,(与以前版本的 ASP.NET Core相比),Program.cs 中的所有内容都将在您的测试中运行。但除此之外,测试代码中的所有内容都保持不变。
# 总结
在这篇文章中,我描述了在 WebApplicationFactory 上完成的工作,以支持使用WebApplication 和 WebApplicationBuilder 的新最小托管 APIs。之所以需要进行更改,是因为 Program.cs 中不再有可以使用反射调用的"常规"方法,并且中间件和终结点的自定义也会在 Program.cs 中进行。
为了解决这个问题,WebApplicationFactory 依赖于与我上一篇文章中的 EF Core 工具相同的 DiagnosticSource 事件来自定义 IHostBuilder 并检索 IHost。但是,与 EF Core 工具不同,WebApplicationFactory 不会在生成 IHost 后停止应用程序。相反,它允许应用程序继续运行,并侦听 IHostApplicationLifetime.ApplicationStarted 事件。这允许 WebApplicationFactory 阻塞,直到 Program.cs 中的所有代码都已运行,并且应用程序已准备好开始处理请求。
作者:Gerry Ge
出处:翻译至 Supporting integration tests with WebApplicationFactory in .NET 6 (opens new window)
版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际 (opens new window)」许可协议进行许可。
转载请注明出处