I have been doing not only unit testing for my sites, but also integration testing and browser automation testing since 2007 with Selenium. However, lately I have been using the faster and generally more compatible version PlaywrightIt has an API and can test on Windows, Linux, Mac, locally, in a container (headless), in my CI/CD pipeline, in Azure DevOps, or in GitHub Actions.
For me, it’s that final moment of truth to make sure the site is fully functional from start to finish.
I can write those Playwright tests in something like TypeScript, and I could run them using Node, but I like to run final unit tests and use that test runner and test harness as a starting point for my .NET applications. I’m used to right-clicking and “run unit tests” or better yet right-clicking and “debug unit tests” in Visual Studio or VS Code. This gives me the benefit of all the assertions of a full unit testing framework, and all the benefits of using something like Playwright to automate my browser.
In 2018 I was using WebApplicationFactory and some complicated hacks to basically get ASP.NET up and running inside .NET (at the time) Core 2.1 inside unit tests and then start Selenium. This was a bit complicated and required manually starting a separate process and managing its lifecycle. However, I kept at this hack for several years, basically trying to get the Kestrel web server up and running inside my unit tests.
I recently upgraded my main site and podcast site to .NET 8. Note that I have been moving my websites from early versions of .NET to the most recent versions. The blog runs just fine on Linux in a container on .NET 8, but its original code started back in 2002 on .NET 1.1.
Now that I’m on .NET 8, I shockingly discovered (when my unit tests stopped working) that the rest of the world had moved from IWebHostBuilder to IHostBuilder five versions of .NET ago. Gulp. Say what you will, but backwards compatibility is impressive.
As such, my code for Program.cs changed from this
public static void Main(string() args)
{
CreateWebHostBuilder(args).Build().Run();
}public static IWebHostBuilder CreateWebHostBuilder(string() args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup();
To this:
public static void Main(string() args)
{
CreateHostBuilder(args).Build().Run();
}public static IHostBuilder CreateHostBuilder(string() args) =>
Host.CreateDefaultBuilder(args).
ConfigureWebHostDefaults(WebHostBuilder => WebHostBuilder.UseStartup());
It’s not a major change on the outside, but it tidies things up on the inside and prepares me for… A more flexible generic host for my web application.
My unit tests stopped working because my Kestral Web Server hack was no longer bringing up my server.
Below is an example of my goal from a playwright’s perspective within a .NET NUnit test.
(Test)
public async Task DoesSearchWork()
{
await Page.GotoAsync(Url);await Page.Locator("#topbar").GetByRole(AriaRole.Link, new() { Name = "episodes" }).ClickAsync();
await Page.GetByPlaceholder("search and filter").ClickAsync();
await Page.GetByPlaceholder("search and filter").TypeAsync("wife");
const string visibleCards = ".showCard:visible";
var waiting = await Page.WaitForSelectorAsync(visibleCards, new PageWaitForSelectorOptions() { Timeout = 500 });
await Expect(Page.Locator(visibleCards).First).ToBeVisibleAsync();
await Expect(Page.Locator(visibleCards)).ToHaveCountAsync(5);
}
I love this. It’s nice and clear. Here we’re assuming that we have a URL on that first line, which will be localhost or something, and then we’re assuming that our web app has started on its own.
Here is the setup code that starts my new “web application test generator factory”, yes the name is stupid but it is descriptive. Note the OneTimeSetUp and OneTimeTearDown. This starts my web application within the context of my TestHost. Note that :0 causes the application to find a port which I then unfortunately have to look up and put into the private URL to use within my unit tests. Note that
private string Url;
private WebApplication? _app = null;(OneTimeSetUp)
public void Setup()
{
var builder = WebApplicationTestBuilderFactory.CreateBuilder(); var startup = new Startup(builder.Environment);
builder.WebHost.ConfigureKestrel(o => o.Listen(IPAddress.Loopback, 0));
startup.ConfigureServices(builder.Services);
_app = builder.Build();// listen on any local port (hence the 0)
startup.Configure(_app, _app.Configuration);
_app.Start();//you are kidding me
Url = _app.Services.GetRequiredService().Features.GetRequiredFeature ().Addresses.Last();
}(OneTimeTearDown)
public async Task TearDown()
{
await _app.DisposeAsync();
}
What horrors does WebApplicationTestBuilderFactory hide? The first part is bad and we should fix it for .NET 9. The rest is really good, with special thanks to David Fowler for his help and guidance. This is magic and yuck in one little helper class.
public class WebApplicationTestBuilderFactory
{
public static WebApplicationBuilder CreateBuilder() where T : class
{
//This ungodly code requires an unused reference to the MvcTesting package that hooks up
// MSBuild to create the manifest file that is read here.
var testLocation = Path.Combine(AppContext.BaseDirectory, "MvcTestingAppManifest.json");
var json = JsonObject.Parse(File.ReadAllText(testLocation));
var asmFullName = typeof(T).Assembly.FullName ?? throw new InvalidOperationException("Assembly Full Name is null");
var contentRootPath = json?(asmFullName)?.GetValue(); //spin up a real live web application inside TestHost.exe
var builder = WebApplication.CreateBuilder(
new WebApplicationOptions()
{
ContentRootPath = contentRootPath,
ApplicationName = asmFullName
});
return builder;
}
}
The first 4 lines are ugly. Because the test is running in the context of a different directory and my website needs to run within the context of its own content root path, I have to force the content root path to be correct and the only way to do that is to get the applications base directory from a file generated within MSBuild from the (old) MvcTesting package. The package is not used but referencing it brings it into the build and creates that file which I then use to extract the directory.
If we can get rid of that “trick” and extract the directory from the context somewhere else, then this helper function becomes a one-liner and .NET 9 becomes MUCH MUCH more testable.
I can now run my Playwright browser unit tests and integration tests on all operating systems, with or without a GUI, in Docker or in the OS. The site is updated to .NET 8 and everything is fine with my code. Well, at least it works. 😉
About Scott
Scott Hanselman is a former professor, former chief financial architect, now speaker, consultant, father, diabetic, and Microsoft employee. He is a failed stand-up comedian, hair braiding expert, and author of books.