In the last days, I was looking for a way to create E2E Tests for a Blazor Application.
For me it was kind of clear that I want to use xUnit for the tests and after some research i chose Playwright for the browser automation.
In this article I will cover the setup of the Solution with a Blazor Project and a xUnit Project. Then I create the Test code and finally add some automation so I the Test takes care of starting the server.
The whole code can be found on my GitLab repository.
Project Setup



That application has a page with a counter, that will increase on each click. In the test, the browser should navigate to that page, verify that the count is 0, click and then verify that the count increased to 1.
First I will add a xUnit project for the tests.




Pretty straight forward and your solution should now look like this

I'll delete the UnitTest1.cs
class, as I don't need it here.
Create the TestClass
To have a nice structure, I am going to create a Folder 'Tests'. Inside that, it's a good practice to follow the structure of your actual project. I am testing the Client Project and the Counter.razor
Page in there. Therefore the Test Project should have a folder structure like this

Inside the new Pages folder, I'll create the Test Class Counter_Should.cs
.

To make that Class a Test Class, I add a Test Method and add the [Fact]
attribute.
using System;
using System.Threading.Tasks;
using Xunit;
namespace BlazorE2E.Tests.Tests.Client.Pages
{
public class Counter_Should
{
public Counter_Should()
{
}
[Fact]
public async Task Increase_On_Click()
{
}
}
}
After building the Solution, You can already see that the Test is discovered in the Test Explorer. But since it contains no code, it's useless for now.
Create the Test Code
Playwright comes with a very cool feature called 'Test Generator'. It lets you record your interactions in the browser and outputs them as code that you can use in your tests.
To do that, you first have to install Playwright and the launch it in the 'codegen' mode.
First you need to add the Microsoft.Playwright
NuGet Package.


then build the project once, so the files we need will be copied to the application folder.

After that you'll have a PowerShell file in the build output folder that we can use to install the browsers. Open a Terminal in the BlazorE2E.Tests
folder and execute this command
m@WhackBook BlazorE2E.Tests % pwsh bin\\Debug\\net6.0\\playwright.ps1 install
Now you have everything you need to generate the test code!
First start the Server Application with Visual Studio

This should start a browser where you see the Blazor template.
To record my interactions, I need to start a special browser. To do that, I run the following command back in my Terminal
m@WhackBook BlazorE2E.Tests % pwsh bin\\Debug\\net6.0\\playwright.ps1 codegen
This will start a chromium browser with a Playwright inspector next to it.

To generate the code, I have to do exactly what I want the Test to do. First I enter the URL to my application. In my case its https://localhost:7048/. After pressing enter, you can see a new line appeared in the Playwright Inspector.

Next I will navigate to the counter page and click on the Counter Display, so I can Identify it. This generates the next few lines in the Playwright inspector.
// Click text=Counter
await page.ClickAsync("text=Counter");
// Assert.AreEqual("https://localhost:7048/counter", page.Url);
// Click text=Current count: 0
await page.ClickAsync("text=Current count: 0");
Then I click on the Button and again on the Counter Display.
// Click text=Click me
await page.ClickAsync("text=Click me");
// Click text=Current count: 1
await page.ClickAsync("text=Current count: 1");
OK. If that all works in our tests, everything is as expected. Before I close the Playwright inspector and stop the server, I copy this code into the Test Method from before.
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = false,
});
var context = await browser.NewContextAsync();
// Open new page
var page = await context.NewPageAsync();
// Go to https://localhost:7048/
await page.GotoAsync("https://localhost:7048/");
// Click text=Counter
await page.ClickAsync("text=Counter");
// Assert.AreEqual("https://localhost:7048/counter", page.Url);
// Click text=Current count: 0
await page.ClickAsync("text=Current count: 0");
// Click text=Click me
await page.ClickAsync("text=Click me");
// Click text=Current count: 1
await page.ClickAsync("text=Current count: 1");
The Test Class now looks like this
using System;
using System.Threading.Tasks;
using Microsoft.Playwright;
using Xunit;
namespace BlazorE2E.Tests.Tests.Client.Pages
{
public class Counter_Should
{
public Counter_Should()
{
}
[Fact]
public async Task Increase_On_Click()
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = false,
});
var context = await browser.NewContextAsync();
// Open new page
var page = await context.NewPageAsync();
// Go to https://localhost:7048/
await page.GotoAsync("https://localhost:7048/");
// Click text=Counter
await page.ClickAsync("text=Counter");
// Assert.AreEqual("https://localhost:7048/counter", page.Url);
// Click text=Current count: 0
await page.ClickAsync("text=Current count: 0");
// Click text=Click me
await page.ClickAsync("text=Click me");
// Click text=Current count: 1
await page.ClickAsync("text=Current count: 1");
}
}
}
This already works! If I start the Server from Terminal
m@WhackBook Server % dotnet run
Building...
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:7048
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5254
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: /Users/m/Projects/Samples/BlazorE2E/BlazorE2E/Server/
I can run the test from Visual Studio



Wow. That was fast. But, it worked as the green Indicators next to the tests say.

To make it a little slower, You can run the Browser in SloMo
. Set SloMo
to 1000
when launching the Browser.
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = false,
SlowMo = 1000
});
If you run the Test again, you can see that it's acting like we want it to.
Adding automation
So far, I have a running Application and I can run the tests against it manually. To use the test in a CI/CD pipeline, the Server hast to be started by the Tests. Otherwise the following Exception appears
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.4.3+1b45f5407b (64-bit .NET 6.0.1)
[xUnit.net 00:00:00.35] Starting: BlazorE2E.Tests
[xUnit.net 00:00:02.25] BlazorE2E.Tests.Tests.Client.Pages.Counter_Should.Increase_On_Click [FAIL]
[xUnit.net 00:00:02.25] Microsoft.Playwright.PlaywrightException : net::ERR_CONNECTION_REFUSED at https://localhost:7048/
[xUnit.net 00:00:02.25] =========================== logs ===========================
[xUnit.net 00:00:02.25] navigating to "https://localhost:7048/", waiting until "load"
[xUnit.net 00:00:02.25] ============================================================
[xUnit.net 00:00:02.25] Stack Trace:
[xUnit.net 00:00:02.25] at Microsoft.Playwright.Transport.Connection.SendMessageToServerAsync[T](String guid, String method, Object args)
[xUnit.net 00:00:02.25] at Microsoft.Playwright.Core.Frame.GotoAsync(String url, FrameGotoOptions options)
[xUnit.net 00:00:02.25] /Users/m/Projects/Samples/BlazorE2E/BlazorE2E.Tests/Tests/Client/Pages/Counter_Should.cs(27,0): at BlazorE2E.Tests.Tests.Client.Pages.Counter_Should.Increase_On_Click()
[xUnit.net 00:00:02.25] /Users/m/Projects/Samples/BlazorE2E/BlazorE2E.Tests/Tests/Client/Pages/Counter_Should.cs(36,0): at BlazorE2E.Tests.Tests.Client.Pages.Counter_Should.Increase_On_Click()
[xUnit.net 00:00:02.25] --- End of stack trace from previous location ---
[xUnit.net 00:00:02.26] Finished: BlazorE2E.Tests
It says ERR_CONNECTION_REFUSED
because there is no Server running, if I didn't start it manually from the Terminal.
To start the Server from the BlazorE2E.Tests
Project, I need access to BlazorE2E.Server.Program
. In .net 6 with Minimal API, that requires some workaround. I need to make the Program
Class accessible. To do that, I add the following line at the bottom of Program.cs
public partial class Program { }
Now Program.cs
looks like this
using Microsoft.AspNetCore.ResponseCompression;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();
app.MapRazorPages();
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();
public partial class Program { }
Then I need to add a reference to the BlazorE2E.Server
Project to the BlazorE2E.Tests
Project.


To make the Server run, Microsoft offers a handy class WebApplicationFactory
. This class is inside Microsoft.AspNetCore.Mvc.Testing
so I add the NuGet Package.

WebApplicationFactory
will start the Server in a 'limited' mode. HTTP will only be available in memory with an HttpClient
that is created by the WebApplicationFactory
itself. That is cool, if you want to run integration tests as its very fast and saves unnecessary overhead. In our case we need a browser to be able to connect to it with real HTTP. Therefore we have to modify WebApplicationFactory
a little.
As I am going to add some Infrastructure to the Test Project, I create a folder called Infrastructure in it. Inside the Infrastructure folder, I create a WebApplicationFactoryFixture
class that will cover the needed modifications.
That class inherits from WebApplicationFixture<Program>
.
using System;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BlazorE2E.Tests.Infrastructure
{
public class WebApplicationFactoryFixture : WebApplicationFactory<Program>
{
public WebApplicationFactoryFixture()
{
}
}
}
I need to override ConfigureWebHost
like this
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
}
I need to tell the Server on what Urls to listen
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseUrls("https://localhost:7048");
}
Next I need to override the CreateHost
function. I am going to create a dummyHost
, that I can return because we can't return the running host. After creating the dummyHost
I am configuring and starting the actual host.
protected override IHost CreateHost(IHostBuilder builder)
{
// need to create a plain host that we can return.
var dummyHost = builder.Build();
// configure and start the actual host.
builder.ConfigureWebHost(webHostBuilder => webHostBuilder.UseKestrel());
var host = builder.Build();
host.Start();
return dummyHost;
}
With the WebApplicationFactoryFixture
looking like this
using System;
using System.IO;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Hosting;
namespace BlazorE2E.Tests.Infrastructure
{
public class WebApplicationFactoryFixture : WebApplicationFactory<Program>
{
public WebApplicationFactoryFixture()
{
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseUrls("https://localhost:7048");
}
protected override IHost CreateHost(IHostBuilder builder)
{
// need to create a plain host that we can return.
var dummyHost = builder.Build();
// configure and start the actual host.
builder.ConfigureWebHost(webHostBuilder => webHostBuilder.UseKestrel());
var host = builder.Build();
host.Start();
return dummyHost;
}
}
}
I can use it in our Counter_Should
class.
First I need Counter_Should
to derive from IClassFixture<WebApplicationFactoryFixture>
.
public class Counter_Should : IClassFixture<WebApplicationFactoryFixture>
Then I have the `WebApplicationFactoryFixture in the Constructor and can start the Server by calling CreateDefaultClient()
.
public Counter_Should(WebApplicationFactoryFixture factoryFixture)
{
factoryFixture.CreateDefaultClient();
}
Now Counter_Should
should look like this
using System;
using System.Threading.Tasks;
using BlazorE2E.Tests.Infrastructure;
using Microsoft.Playwright;
using Xunit;
namespace BlazorE2E.Tests.Tests.Client.Pages
{
public class Counter_Should : IClassFixture<WebApplicationFactoryFixture>
{
public Counter_Should(WebApplicationFactoryFixture factoryFixture)
{
factoryFixture.CreateDefaultClient();
}
[Fact]
public async Task Increase_On_Click()
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = false,
SlowMo = 1000
});
var context = await browser.NewContextAsync();
// Open new page
var page = await context.NewPageAsync();
// Go to https://localhost:7048/
await page.GotoAsync("https://localhost:7048/");
// Click text=Counter
await page.ClickAsync("text=Counter");
// Assert.AreEqual("https://localhost:7048/counter", page.Url);
// Click text=Current count: 0
await page.ClickAsync("text=Current count: 0");
// Click text=Click me
await page.ClickAsync("text=Click me");
// Click text=Current count: 1
await page.ClickAsync("text=Current count: 1");
}
}
}
And if we start the Test in the Test Explorer, the WebServer automatically spins up for us!
Conclusion
Writing Tests with xUnit and Playwright is easy and fast forward. Starting the Server from the Test requires some more work but is also possible.
I bound myself to the bare minimum regarding the WebApplicationFactoryFixture
. There are many ways to further manipulate the application e.g. you could replace a database for EF with an In-Memory Database or fill it with Test Data.