Run your full web server inside integration tests
How to launch your fully functional dotnet web application from your integration test.
TL;DR
Set ApplicationName
in the WebApplicationOptions
sent to WebApplication.CreateBuilder
WebApplication.CreateBuilder
(
new WebApplicationOptions
{
ApplicationName = typeof(Web_Server_Assembly).Assembly.GetName().Name // <==
}
);
Video Overview
The Story
I have a dotnet Blazor project (Web.Server.csproj
) that runs fine when launched from VS or dotnet run
, against which I want to run integration test.
When attempting to launch my BlazorWeb.Server
from my integration tests. I received the following:
System.InvalidOperationException: Cannot find the fallback endpoint specified by route values: { page: /_Host, area: }.
...
My first thought was. "I must be creating the WebApplication
differently in my test than I am when running directly."
The code used to create the WebApplicationBuilder
in Web.Server.Program
is as follows
public static Task<int> Main(string[] args)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
...
}
The code I was using in the test was:
WebApplicationBuilder builder = WebApplication.CreateBuilder();
I was passing no args
when running directly so the code was effectively the same.
I deduced that the problem was caused from the context in which I was running. I mean, the code is the same, so what else could it be? But what was different about the context?
One Of These Things Is Not Like The Other
I set a breakpoint after creating the builder, in both the application and the test case. And went about using the inspector to see if I could tell the difference between them. And yes there was something different.
Inside builder.WebHost._environment.WebRootFileProvider
I noticed the working one was a CompositeFileProvider
that contained 2 FileProviders
and when running from the test it was NullFileProvider
Found the difference but why?
Given my code was the same, to determine why dotnet was creating a different WebRootFileProvider
, I needed to debug into the dotnet code to see where it created this WebRootFileProvider
.
I probably followed the trace 100x (seriously this whole process took me 2 days) until finally the culprit was found.
Microsoft.AspNetCore.Hosting.dll!Microsoft.AspNetCore.Hosting.StaticWebAssets.StaticWebAssetsLoader.ResolveRelativeToAssembly(Microsoft.AspNetCore.Hosting.IWebHostEnvironment environment) Line 70 C#
Microsoft.AspNetCore.Hosting.dll!Microsoft.AspNetCore.Hosting.StaticWebAssets.StaticWebAssetsLoader.ResolveManifest(Microsoft.AspNetCore.Hosting.IWebHostEnvironment environment, Microsoft.Extensions.Configuration.IConfiguration configuration) Line 51 C#
Microsoft.AspNetCore.Hosting.dll!Microsoft.AspNetCore.Hosting.StaticWebAssets.StaticWebAssetsLoader.UseStaticWebAssets(Microsoft.AspNetCore.Hosting.IWebHostEnvironment environment, Microsoft.Extensions.Configuration.IConfiguration configuration) Line 27 C#
Microsoft.AspNetCore.dll!Microsoft.AspNetCore.WebHost.ConfigureWebDefaults.AnonymousMethod__9_0(Microsoft.AspNetCore.Hosting.WebHostBuilderContext ctx, Microsoft.Extensions.Configuration.IConfigurationBuilder cb) Line 223 C#
Microsoft.AspNetCore.Hosting.dll!Microsoft.AspNetCore.Hosting.GenericWebHostBuilder.ConfigureAppConfiguration.AnonymousMethod__0(Microsoft.Extensions.Hosting.HostBuilderContext context, Microsoft.Extensions.Configuration.IConfigurationBuilder builder) Line 182 C#
Microsoft.AspNetCore.dll!Microsoft.AspNetCore.Hosting.BootstrapHostBuilder.RunDefaultCallbacks(Microsoft.Extensions.Configuration.ConfigurationManager configuration, Microsoft.Extensions.Hosting.HostBuilder innerBuilder) Line 133 C#
Microsoft.AspNetCore.dll!Microsoft.AspNetCore.Builder.WebApplicationBuilder.WebApplicationBuilder(Microsoft.AspNetCore.Builder.WebApplicationOptions options, System.Action<Microsoft.Extensions.Hosting.IHostBuilder> configureDefaults) Line 87 C#
Microsoft.AspNetCore.dll!Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder(Microsoft.AspNetCore.Builder.WebApplicationOptions options) Line 115 C#
Testing.Common.dll!TimeWarp.Architecture.Testing.WebApplicationHost<TimeWarp.Architecture.Web.Server.Program>.WebApplicationHost(string[] aUrls, Microsoft.AspNetCore.Builder.WebApplicationOptions aWebApplicationOptions, System.Action<Microsoft.Extensions.DependencyInjection.IServiceCollection> aConfigureServicesDelegate) Line 46 C#
The Problem
When running from a test, the running assembly is your test assembly (Web.Server.Integration.Tests
) not the web application assembly (Web.Server
).
"So what?" you might ask. "Why would that matter?" I didn't think it would, obviously. But yet, it does!
When dotnet builds my Web.Server
application it creates a json file named Web.Server.staticwebassets.runtime.json
This file contains information as to where the csproj file is located and where your web application content should be found. Dotnet tries to resolve the manifest ResolveManifest
, relative to an assembly named environment.ApplicationName
.
See the dotnet code snippet from Microsoft.AspNetCore.Hosting.StaticWebAssets.StaticWebAssetsLoader class:
private static string ResolveRelativeToAssembly(IWebHostEnvironment environment)
{
var assembly = Assembly.Load(environment.ApplicationName);
var basePath = string.IsNullOrEmpty(assembly.Location) ? AppContext.BaseDirectory : Path.GetDirectoryName(assembly.Location);
return Path.Combine(basePath!, $"{environment.ApplicationName}.staticwebassets.runtime.json");
}
environment.ApplicationName
defaults to the running assembly. And there is no Web.Server.Integration.Tests.staticwebassets.runtime.json
Thus the dotnet code returns null for the ResolveManifest
when running from the test.
Snippet of the ResolveManifest source.
internal static Stream? ResolveManifest(IWebHostEnvironment environment, IConfiguration configuration)
{
try
{
var candidate = configuration.GetValue<string>(WebHostDefaults.StaticWebAssetsKey) ?? ResolveRelativeToAssembly(environment);
if (candidate != null && File.Exists(candidate))
{
return File.OpenRead(candidate);
}
// A missing manifest might simply mean that the feature is not enabled, so we simply
// return early. Misconfigurations will be uncommon given that the entire process is automated
// at build time.
return default;
}
catch
{
return default;
}
}
The comment indicates that this is unexpected scenario, and I wonder if throwing an exception here would make sense, but that isn't my call.
Where is environment.ApplicationName
configured?
After finally finding what "context" was different, I needed to figure out to make them the same. Where is this environment.ApplicationName
configured?
It turns out to be very simple to set. WebApplication.CreateBuilder
has an overload that takes a WebApplicationOptions
parameter, and inside that class you can configure the ApplicationName
. Once I set that to Web.Server
, everything worked.
WebApplication.CreateBuilder
(
new WebApplicationOptions
{
ApplicationName = typeof(Web_Server_Assembly).Assembly.GetName().Name // <==
}
);
Parting words
Don't feel bad if you didn't see this solution, you are not alone. See the references for others that struggled with us. But now we know. With this blog and search engines hopefully we can find the solution again next time we need it.
References
https://stackoverflow.com/questions/72928110/why-wont-unit-tests-connect-to-a-websocket https://github.com/dotnet/aspnetcore/issues/42657