Skip to content

Decouple WebApplicationFactory and TestServer #33846

Closed
@martincostello

Description

@martincostello

Describe the bug

As part of looking into the new features in ASP.NET Core 6 (top-level statements, minimal APIs, etc.) I've been looking at how to refactor the integration test approach I've been using with previous versions of .NET Core where WebApplicationFactory is available so that it works with the new approaches.

For integration tests where a UI is required, such as for browser automation tests, I've been tackling this by creating a derived WebApplicationFactory class to piggy-back its features to bootstrap an HTTP server using Kestrel so that there's a real HTTP port being listened to so that tools like Playwright and Selenium can interact with the application to test it.

These tests work by using the CreateHostBuilder()/CreateWebHostBuilder() methods to access the build for the application and then manually creating it (rather than using the TestServer the class usually provides) (example). The reason for re-using the WebApplicationFactory code is that there's a logic embedded within it for finding the default content root, ensuring the .deps.json files are there etc., which is a fair chunk of additional code to copy and maintain to otherwise replicate the approach with only minor tweaks on top (i.e. a real server). It also gives good code coverage of the same code that runs in production, rather than having to use an alternate code path just for the purpose of tests.

Trying this out with the changes from #33462 using a preview 6 daily build however doesn't work for this scenario. This is because in the top-level statements scenario both methods return null and the deferred implementation is private to the EnsureServer() method:

[MemberNotNull(nameof(_server))]
private void EnsureServer()
{
if (_server != null)
{
return;
}
EnsureDepsFile();
var hostBuilder = CreateHostBuilder();
if (hostBuilder is not null)
{
ConfigureHostBuilder(hostBuilder);
return;
}
var builder = CreateWebHostBuilder();
if (builder is null)
{
var deferredHostBuilder = new DeferredHostBuilder();
// 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;
}

If the implementation were to be refactored in a way that supported the existing scenarios by allowing the consuming class to get access to the DeferredHostBuilder as an IHostBuilder, then I presume that the use case I have today would work if that builder was used to bootstrap an application with it instead.

Off the top of my head, maybe something like this could be a possible approach:

protected virtual IHostBuilder? CreateHostBuilder()
{
    var hostBuilder = HostFactoryResolver.ResolveHostBuilderFactory<IHostBuilder>(typeof(TEntryPoint).Assembly)?.Invoke(Array.Empty<string>());

    if (hostBuilder is null)
    {
        var deferredHostBuilder = new DeferredHostBuilder();
        var factory = HostFactoryResolver.ResolveHostFactory(
            typeof(TEntryPoint).Assembly,
            stopApplication: false,
            configureHostBuilder: deferredHostBuilder.ConfigureHostBuilder,
            entrypointCompleted: deferredHostBuilder.EntryPointCompleted);

        if (factory is not null)
        {
            deferredHostBuilder.SetHostFactory(factory);
            hostBuilder = deferredHostBuilder;
        }
    }

    hostBuilder?.UseEnvironment(Environments.Development);
    return hostBuilder;
}

The EnsureServer() method would then just consume the deferred implementation without actually having any knowledge of it, and derived classes would be able to use the deferred implementation without being aware of the actual implementation details.

I've got a GitHub repo here with a sample TodoApp using this test approach using ASP.NET Core 6 preview 5 here (it doesn't use minimal APIs yet, mainly due to this issue), and there's a branch using a preview 6 daily build with this approach that fails to run the tests due to the lack of access to a host builder.

While maybe this isn't an intended use case of WebApplicationFactory, it's been working well since ASP.NET Core 2.1 and would require a fair bit of work to move away from to leverage the new functionalities in various application codebases in order to adopt ASP.NET Core 6.

If some minimal refactoring could be done that doesn't break the intended design, which I would be happy to contribute to, that could get this sort of use case working again with ASP.NET Core 6 using top-level statements that would be appreciated.

/cc @davidfowl

To Reproduce

To reproduce, clone the preview-6 branch of my work-in-progress sample application.

Further technical details

  • .NET SDK 6.0.100-preview.6.21324.1 (a daily build)
  • Microsoft.AspNetCore.Mvc.Testing version 6.0.0-preview.6.21323.4
  • Visual Studio 2022 17.0.0 Preview 1.1

Metadata

Metadata

Assignees

Labels

Priority:3Work that is nice to havearea-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etcarea-mvcIncludes: MVC, Actions and Controllers, Localization, CORS, most templatesbugThis issue describes a behavior which is not expected - a bug.feature-mvc-testingMVC testing package

Projects

Relationships

None yet

Development

No branches or pull requests

Issue actions