Blazor & Playwright
In my earlier blog post, I discussed my insights gained from utilizing BlazingStory. However, it’s equally vital to delve into the testing aspect of the component. In this article, I’ll be detailing my implementation of Playwright as the end-to-end testing framework in a Blazor project.
This project is inspired by the insights shared during one of Microsoft’s community stand-ups. This is the repo that Mackinnon Buck,Microsoft developer, used during the session.
What is Playwright
Playwright is a versatile framework designed for web testing, offering cross-browser web automation capabilities. It empowers automated testing on browsers such as Chromium, Firefox, and WebKit, making it an invaluable tool for ensuring web application compatibility. Developed as an open-source library by Microsoft, Playwright extends its support to multiple programming languages, including Java, Python, C#, and Node.js.
Getting Started
To successfully create and execute our end-to-end tests with Playwright, we must follow a series of implementation steps. It’s worth noting that for this process, I used NUnit. It supports MSTests too.
Here is my repo for reference.
- Install playwright package, here is a CLI command :
dotnet add package Microsoft.Playwright.NUnit
2. ⚠️ It is important to build the project after installing the package. This will create the playwright.ps1
which will install playwright CLI. By default Playwright does not install browsers but by building the project it creates the playwright.ps1
script in the bin/debug/netX
folder (the x
represents the .net version)
dotnet build
3. By running playwright.ps1
it will install the required browsers, it is located in the netx
folder (the x
represents the .net version):
./bin/Debug/net8.0/playwright.ps1 install
Blazor integration
The referenced project is a WebAssembly project. To enable end-to-end testing, it’s essential to have a server that starts up and hosts the BlazingStory project.
💡 It is important to note that BlazorServer follows a different implementation.
Blazor WebAssembly
Server
As this is just a Proof of Concept (POC), I’ll create a fake Server project, which will only include Program.cs
. This minimal setup is necessary for the test to function.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();
app.MapRazorPages();
app.MapFallbackToFile("index.html");
app.Run();
// Make the implicit Program class public so test projects can access it
public abstract partial class Program
{
}
- ❗If you are following along, dont forget to add a reference to the WebAssembly Project.
Playwright Infrastructure
The class below is copied from Mackinnon Buck repo .
public class BlazorTest : PageTest
{
protected static readonly Uri RootUri = new("<http://127.0.0.1>");
private readonly WebApplicationFactory<Program> _webApplicationFactory = new();
private HttpClient? _httpClient;
[SetUp]
public async Task BlazorSetup()
{
_httpClient = _webApplicationFactory.CreateClient();
await Context.RouteAsync(
$"{RootUri.AbsoluteUri}**", async route =>
{
var request = route.Request;
var content = request.PostDataBuffer is { } postDataBuffer
? new ByteArrayContent(postDataBuffer)
: null;
var requestMessage = new HttpRequestMessage(new(request.Method), request.Url)
{
Content = content,
};
foreach (var header in request.Headers)
{
requestMessage.Headers.Add(header.Key, header.Value);
}
var response = await _httpClient.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsByteArrayAsync();
var responseHeaders =
response.Content.Headers.Select(h => KeyValuePair.Create(h.Key, string.Join(",", h.Value)));
await route.FulfillAsync(
new()
{
BodyBytes = responseBody,
Headers = responseHeaders,
Status = (int)response.StatusCode,
}
);
}
);
}
[TearDown]
public void BlazorTearDown()
{
_httpClient?.Dispose();
}
}
Code Review
In the youtube (21:21) he explains that :
- It extends the
PageTest
class fixture class in playwright and will startBlazorSetup()
& closeBlazorTearDown()
the app once it completes. - The
WebApplicationFactory
class which is referenced fromMicrosoft.AspNetCore.Mvc.Testing.
It spins up instance of our web application in memory.
private readonly WebApplicationFactory<Program> _webApplicationFactory = new();
...
_httpClient = _webApplicationFactory.CreateClient();
Now we can give it requests without going through the network 🎉 !
Playwright allows to intercepts requests to specific routes. We want to capture any requests going to our base URI
await Context.RouteAsync($"{RootUri.AbsoluteUri}
- The information is then translated into a format that the
_httpClient
can understand. The request is then send:
var requestMessage = new HttpRequestMessage(new(request.Method), request.Url) { Content = content, };
- The response is received
var response = await _httpClient.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsByteArrayAsync();
var responseHeaders =
response.Content.Headers.Select(h => KeyValuePair.Create(h.Key, string.Join(",", h.Value)));
- Calling
route.FulfillAsync()
sends the response back to the browser thru C# code rather than going through the server.
Blazor Server
In the youtube video roughly around 31:24 Mackinnon explains that Blazor Server relies on a WebSocket connection, which is not easily imitated. In this class, it spins up the server as we normally would.
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Playwright.NUnit;
namespace BlazorServerPlaywright.Test.Infrastructure;
public class BlazorTest : PageTest
{
private IHost? _host;
protected Uri RootUri { get; private set; } = default!;
[SetUp]
public async Task SetUpWebApplication()
{
_host = Program.BuildWebHost();
await _host.StartAsync();
RootUri = new(_host.Services.GetRequiredService<IServer>().Features
.GetRequiredFeature<IServerAddressesFeature>()
.Addresses.Single());
}
[TearDown]
public async Task TearDownWebApplication()
{
if (_host is not null)
{
await _host.StopAsync();
_host.Dispose();
}
}
}
Setting up tests
We are now ready to start writing tests, or leverage the codegen
feature of playwright and quickly write our tests!
To start, we’ll need to extend our test class with the previously added ‘BlazorTest’ class.
public class QuickgridTest : BlazorTest
Playwright — Codegen
We can utilize a handy code generation feature provided by Playwright — codegen
. Make sure you are in your test project folder.
- Start by running our web application.
- Next, execute the code generation command, directing it to the host where your app is currently running. (⚠️ Be sure to replace ‘localhost’ and the port number with the appropriate values for your setup!)
./bin/Debug/net8.0/playwright.ps1 codegen https://localhost:7297
This action launches the Playwright Inspector that sets out code depending on the clicks we make.
- First we need to select NUnit as the library in the inspector window
- Now we then simulate the user interaction that we want to test and playwright records all the locations for us in the inspector window.
- ⚠️ It’s crucial to remember to copy the code from the Inspector window to avoid losing this valuable information
In our test, lets paste the code we copied from the inspector window :
[Test]
public async Task MyTest()
{
await Page.GotoAsync("https://localhost:7297/");
await Page.GotoAsync("https://localhost:7297/?path=/docs/buttons--docs");
await Page.GetByRole(AriaRole.Button, new() { Name = "PlaceHolder" }).ClickAsync();
await Page.GetByRole(AriaRole.Link, new() { Name = "Default" }).Nth(1).ClickAsync();
await Page.GetByRole(AriaRole.Row, new() { Name = "NumberOfColumns Set number" }).GetByRole(AriaRole.Button).ClickAsync();
await Page.GetByPlaceholder("Edit number...").ClickAsync();
await Page.GetByPlaceholder("Edit number...").FillAsync("9");
await Page.FrameLocator("iframe").Locator("div").Filter(new() { HasText = "Loading... Heading 0Heading 1Heading 2Heading 3Heading 4Heading 5Heading 6Headin" }).Nth(1).ClickAsync();
}
Lets clean some lines up and also add assertions.
- We need to navigate to the infrastructure URI thats setup in the
BlazorTest.cs
class and not hardcode localhost and port number.
// delete - await Page.GotoAsync("https://localhost:7297/");
// delete - await Page.GotoAsync("https://localhost:7297/?path=/docs/buttons--docs");
await Page.GotoAsync(RootUri.AbsoluteUri);
- Our objective is to ensure that there are precisely 9 columns after the modification. To achieve this, our first step is to locate the elements accurately, rather than relying on the randomly recorded heading text filter.
// delete - await Page.FrameLocator("iframe").Locator("div").Filter(new() { HasText = "Loading... Heading 0Heading 1Heading 2Heading 3Heading 4Heading 5Heading 6Headin" }).Nth(1).ClickAsync();
var app = Page.FrameLocator("iframe").Locator("#app");
var placeHolder = app.Locator("table>thead>tr>th");
- Now, we can count the number of columns and verify that the correct amount has been added!
var count = await placeHolder.CountAsync();
Assert.That(count, Is.EqualTo(9));
Here is a copy of the complete test :
[Parallelizable(ParallelScope.Self)]
[TestFixture]
public class PlaceholderTest: BlazorTest
{
[Test]
public async Task NumberOfColumns_ShouldIncreaseTo9()
{
await Page.GotoAsync(RootUri.AbsoluteUri);
await Page.GetByRole(AriaRole.Button, new() { Name = "PlaceHolder" }).ClickAsync();
await Page.GetByRole(AriaRole.Link, new() { Name = "Default" }).Nth(1).ClickAsync();
await Page.GetByRole(AriaRole.Row, new() { Name = "NumberOfColumns Set number" }).GetByRole(AriaRole.Button)
.ClickAsync();
await Page.GetByPlaceholder("Edit number...").ClickAsync();
await Page.GetByPlaceholder("Edit number...").FillAsync("9");
var app = Page.FrameLocator("iframe").Locator("#app");
var placeHolder = app.Locator("table>thead>tr>th");
var count = await placeHolder.CountAsync();
Assert.That(count, Is.EqualTo(9));
}
}
The following command will execute the test in Headless mode, opening the browser for us to observe how it replicates user interactions. I included a ‘SlowMo’ parameter to slow down the process slightly, as it would otherwise be too fast to discern the simulation.
dotnet test -- Playwright.LaunchOptions.Headless=false Playwright.LaunchOptions.SlowMo=2000
To debug the test, the following command can be executed. It will launch the inspector and enable you to debug step by step:
PWDEBUG=1 dotnet test --filter "NumberOfColumns_ShouldIncreaseTo9"
Summary
Playwright is a very powerful automation test framework, it makes absolute sense that Microsoft is supporting it. There are many more features available in playwright, i am very grateful for this library as it is making our lives as developers so much easier! Thank you Playwright!