在 .NET 6 中使用 WebApplicationFactory 支持集成测试

3/11/2022 .NET6

这是该系列的第六篇文章:探索 .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
    }
}
1
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;
    }
    // ...
}
1
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 的方法,而无需以前版本的约定。

The EF Core tools run your Program.Main() and the diagnostic listener retrieves the IHost

WebApplicationFactory 也受益于这种新机制,但还需要进行一些额外的更改。EF Core 工具只需要访问构建的 IHost,以便它们可以检索 IServiceProvider。一旦您构建了 IHostIServiceProvider就会固定,因此上图中所示的"中止应用程序"方法运行良好。

但是,这不适用于WebApplicationFactoryWebApplicationFactory 需要能够在调用 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()
1
2
3
4
5
6
7
8
9
10
11

这使得 WebApplicationFactory 变得更加棘手,因为它需要在 Program.cs 中运行所有代码,直到调用 app.Run(),因此它不能仅依赖于添加到 HostBuilderDiagnosticSource 事件。在本文的其余部分,我们将看看 WebApplicationFactory 如何实现这一目标。

# .NET 6 中的 WebApplicationFactory

从表面上看,您使用 WebApplicationFactory 的方式在 .NET 6 中没有改变。我之前演示的完全相同的测试代码将在 .NET 6 中工作,即使您将新的最小托管 APIs 与 WebApplicationWebApplicationBuilder 配合使用也是如此。

一个轻微的烦恼是,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>();
    }
}
1
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();
1
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),因此它们可以在构建这些应用程序后停止。

下图显示了与 WebApplicationFactoryHostFactoryResolverDeferredHostBuilder 的交互,以及我们将在途中看到的其他类型的交互。现在不要担心完全理解这一点,但我认为现在将其视为我们前进方向的路标是有帮助的!

The WebApplicationFactory sequence diagram for starting your application

您可能想知道为什么我们在这里需要一种新类型,即 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;
    }
    // ...
}
1
2
3
4
5
6
7
8
9
10
11

当 DiagnosticSource HostBuilding 事件触发时,这些委托都将应用于IHostBuilder`:

public void ConfigureHostBuilder(object hostBuilder)
{
    _configure(((IHostBuilder)hostBuilder));
}
1
2
3
4

为了让这一切顺利进行,WebApplicationFactoryDeferredHostBuilder 上调用 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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

请记住,当我们检索 IHost 实例时,应用程序不会停止在单独的 Thread 中运行,因为我们需要执行 Program.cs 中的其余代码。DeferredHostBuilderIHost 保存到一个新类型 DefferedHost 中,并从 Build() 调用返回此内容。

_hostStartTcsTaskCompletionSource,用于处理在后台运行的应用程序由于异常而退出的边缘情况。这是一个边缘案例,但如果没有它,测试可能会无限期地挂起。

DeferredHost 负责等待应用程序正确启动(而不仅仅是构建 IHost)。它需要等待您配置所有端点,以及运行任何其他启动代码。

DeferredHost 通过使用启动应用程序时在普通通用主机应用程序中引发的现有 IHostApplicationLifetime 事件来实现此目的。下图(取自上一篇分析通用主机启动过程的文章)显示,在服务器启动后,在 IHostApplicationLifetime 上调用 NotifyStarted() 方法。

Sequence diagram for Host.StartAsync()

NotifyStarted() 的调用会引发 ApplicationStarted 事件,DeferredHost 使用该事件来检测应用程序是否正在运行,并且启动测试是安全的。当 WebApplicationFactoryDeferredHost 上调用 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);
}
1
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 上完成的工作,以支持使用WebApplicationWebApplicationBuilder 的新最小托管 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)」许可协议进行许可。

转载请注明出处

Last Updated: 3/26/2022, 3:33:46 PM