In a previous post, I wrote about testing an ASP.NET Core application with an in-memory server. This is useful for running integration tests to validate that the server produces the expected response for a request. However, it doesn't allow you to test JavaScript code or validate how the page renders. If you want to test these aspects, you need to run an actual browser and load your site. The code in this post allows testing ASP.NET Core MVC / Razor Pages / Blazor Server / Blazor WebAssembly applications.
There are multiple ways to instrument browsers. The most widely used library is Selenium, which uses the WebDriver, a W3C recommendation. The WebDriver API is implemented by all major browsers, making it the best choice when you need broad browser support. Pupeteer and Playwright are more recent alternatives that offer similar automation capabilities with a simpler API and greater control over browsers, but they support fewer browsers. Playwright only supports Chromium, Firefox, and WebKit browsers.
In this post, I'll use Playwright because it comes with convenient helpers to install all dependencies and has a .NET wrapper. The .NET wrapper is developed by Dario Kondratiuk, and was recently moved to the microsoft organization.
#Starting the server in the unit tests
To test with a browser, you need to start the web server and get its URL. ASP.NET Core doesn't come with a ready-to-use API to do that in tests. You can upvote this issue if you think this could be useful. That said, the ASP.NET Core repository on GitHub contains samples in their tests that can be reused. That is one of the benefits of using an open-source product.
First, add a reference to the Microsoft.AspNetCore.Mvc.Testing NuGet package to your project:
csproj (MSBuild project file)
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.0" />
<!-- For Blazor WebAssembly -->
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="6.0.0" />
</ItemGroup>
Then, you can add these classes to your project. The idea is to start a host in a background thread and get the root URL when the server is ready. The code comes from the ASP.NET Core repository
C#
public abstract class WebHostServerFixture : IDisposable
{
private readonly Lazy<Uri> _rootUriInitializer;
public Uri RootUri => _rootUriInitializer.Value;
public IHost Host { get; set; }
public WebHostServerFixture()
{
_rootUriInitializer = new Lazy<Uri>(() => new Uri(StartAndGetRootUri()));
}
protected static void RunInBackgroundThread(Action action)
{
using var isDone = new ManualResetEvent(false);
ExceptionDispatchInfo edi = null;
new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
edi = ExceptionDispatchInfo.Capture(ex);
}
isDone.Set();
}).Start();
if (!isDone.WaitOne(TimeSpan.FromSeconds(10)))
throw new TimeoutException("Timed out waiting for: " + action);
if (edi != null)
throw edi.SourceException;
}
protected virtual string StartAndGetRootUri()
{
// As the port is generated automatically, we can use IServerAddressesFeature to get the actual server URL
Host = CreateWebHost();
RunInBackgroundThread(Host.Start);
return Host.Services.GetRequiredService<IServer>().Features
.Get<IServerAddressesFeature>()
.Addresses.Single();
}
public virtual void Dispose()
{
Host?.Dispose();
Host?.StopAsync();
}
protected abstract IHost CreateWebHost();
}
// ASP.NET Core with a Startup class (MVC / Pages / Blazor Server)
public class WebHostServerFixture<TStartup> : WebHostServerFixture
where TStartup : class
{
protected override IHost CreateWebHost()
{
return new HostBuilder()
.ConfigureHostConfiguration(config =>
{
// Make UseStaticWebAssets work
var applicationPath = typeof(TStartup).Assembly.Location;
var applicationDirectory = Path.GetDirectoryName(applicationPath);
// In ASP.NET 5, the file is named app.staticwebassets.xml
// In ASP.NET 6, the file is named app.staticwebassets.runtime.json
#if NET6_0_OR_GREATER
var name = Path.ChangeExtension(applicationPath, ".staticwebassets.runtime.json");
#else
var name = Path.ChangeExtension(applicationPath, ".StaticWebAssets.xml");
#endif
var inMemoryConfiguration = new Dictionary<string, string>
{
[WebHostDefaults.StaticWebAssetsKey] = name,
};
config.AddInMemoryCollection(inMemoryConfiguration);
})
.ConfigureWebHost(webHostBuilder => webHostBuilder
.UseKestrel()
.UseSolutionRelativeContentRoot(typeof(TStartup).Assembly.GetName().Name)
.UseStaticWebAssets()
.UseStartup<TStartup>()
.UseUrls($"http://127.0.0.1:0")) // :0 allows to choose a port automatically
.Build();
}
}
// If you are using a Blazor WebAssembly application without a server, you can use the following type.
// TProgram correspond to a type (often `Program`) from the WebAssembly application.
public class BlazorWebAssemblyWebHostFixture<TProgram> : WebHostServerFixture
{
protected override IHost CreateWebHost()
{
return new HostBuilder()
.ConfigureHostConfiguration(config =>
{
// Make UseStaticWebAssets work
var applicationPath = typeof(TProgram).Assembly.Location;
var applicationDirectory = Path.GetDirectoryName(applicationPath);
// In ASP.NET 5, the file is named app.staticwebassets.xml
// In ASP.NET 6, the file is named app.staticwebassets.runtime.json
#if NET6_0_OR_GREATER
var name = Path.ChangeExtension(applicationPath, ".staticwebassets.runtime.json");
#else
var name = Path.ChangeExtension(applicationPath, ".StaticWebAssets.xml");
#endif
var inMemoryConfiguration = new Dictionary<string, string>
{
[WebHostDefaults.StaticWebAssetsKey] = name,
};
})
.ConfigureWebHost(webHostBuilder => webHostBuilder
.UseKestrel()
.UseSolutionRelativeContentRoot(typeof(TProgram).Assembly.GetName().Name)
.UseStaticWebAssets()
.UseStartup<Startup>()
.UseUrls($"http://127.0.0.1:0")) // :0 allows to choose a port automatically
.Build();
}
private sealed class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddRouting();
}
public void Configure(IApplicationBuilder app)
{
app.UseDeveloperExceptionPage();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles(new StaticFileOptions
{
ServeUnknownFileTypes = true,
});
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapFallbackToFile("index.html");
});
}
}
}
You can now start the server from a xUnit test:
C#
// ASP.NET Core (MVC / Pages / Blazor Server)
public class UnitTest1 : IClassFixture<WebHostServerFixture<Startup>>
{
private readonly WebHostServerFixture<Startup> _server;
public UnitTest1(WebHostServerFixture<Startup> server) => _server = server;
[Fact]
public async Task Test1()
{
_ = _server.RootUri; // Start the server and get the URI
// TODO actual test
}
}
// Blazor WebAssembly (without a server)
public class UnitTest2 : IClassFixture<BlazorWebAssemblyWebHostFixture<Program>>
{
private readonly BlazorWebAssemblyWebHostFixture<Program> _server;
public UnitTest2(BlazorWebAssemblyWebHostFixture<Program> server) => _server = server;
[Fact]
public async Task Test1()
{
_ = _server.RootUri; // Start the server and get the URI
// TODO actual test
}
}
#Automating the browser to test the application
Now that the server is started, the next step is to automate the browser to open the RootUri and run tests.
First, you need to install the Playwright wrapper and the browsers:
Shell
dotnet add package Microsoft.Playwright
dotnet tool update --global Microsoft.Playwright.CLI
dotnet build
playwright install
Let's write a basic test using Playwright:
C#
public class UnitTest1 : IClassFixture<WebHostServerFixture<Startup>>
{
private readonly WebHostServerFixture<Startup> _server;
public UnitTest1(WebHostServerFixture<Startup> server) => _server = server;
[Fact]
public async Task DisplayHomePage()
{
using var playwright = await Playwright.CreateAsync();
// You can use playwright.Firefox, playwright.Chromium, or playwright.WebKit
await using var browser = await playwright.Chromium.LaunchAsync(new LaunchOptions
{
Headless = true, // Set to false to debug your tests
IgnoreHTTPSErrors = true,
});
var page = await browser.NewPageAsync();
// Navigate to the home page
await page.GoToAsync(_server.RootUri.AbsoluteUri);
// Get the first h1 element and test the text content
var header = await page.WaitForSelectorAsync("h1");
Assert.Equal("Hello, world!", await header.GetTextContentAsync());
}
[Fact]
public async Task Counter()
{
using var playwright = await Playwright.CreateAsync();
// You can use playwright.Firefox, playwright.Chromium, or playwright.WebKit
await using var browser = await playwright.Chromium.LaunchAsync(new LaunchOptions
{
Headless = true,
IgnoreHTTPSErrors = true,
});
var page = await browser.NewPageAsync();
await page.GoToAsync(_server.RootUri + "/counter", LifecycleEvent.Networkidle);
await page.ClickAsync("#IncrementBtn");
// Selectors are not only CSS selectors. You can use xpath, css, or text selectors
// By default there is a timeout of 30s. If the selector isn't found after the timeout, an exception is thrown.
// More about selectors: https://playwright.dev/#version=v1.4.2&path=docs%2Fselectors.md
await page.WaitForSelectorAsync("text=Current count: 1");
}
}
You can now run the tests and verify that everything works as expected. If you need to debug a test, you can set Headless = false and SlowMo = 250 to observe the browser behavior. You can also take screenshots using page.ScreenshotAsync(path: "test.png", fullPage: true). This can be useful for visually comparing the page against a known-good screenshot.
C#
// For debugging purpose, you can set Headless=false and SlowMo=250
await using var browser = await playwright.Chromium.LaunchAsync(new LaunchOptions
{
Headless = false,
IgnoreHTTPSErrors = true,
SlowMo = 250,
});
In the following video, I enabled both debugging options. The two tests run in 9.6s with debugging options enabled. Without them, they run in 3.6s.
#Additional resources
Do you have a question or a suggestion about this post? Contact me!