Using Playwright and the WebApplicationFactory To Test Your Blazor Application

There are many posts around Playwright .NET. These cover set up and running it against hosted sites.

What I didn’t find many posts on, was running Playwright .NET using Microsofts WebApplicationFactory. Why? Because it isn’t as simple as implementing the WebApplicationFactory with Playwright .NET and hitting run.

I’ll show how you can create a CustomWebApplicationFactory that will allow you to run your Playwright .NET tests locally using the WebApplicationFactory in .NET 6.

Before getting into the post, I want to give a shout out to Martin Costello. By viewing his solution in GitHub I was able to get my solution working and to write up this post.

Solution

Before building our solution, I wanted to give you a quick look at what the architecture looks like. This should make it easier to follow along.

WebApplicationFactory

Microsoft offers a factory for bootstrapping an application in memory. This is used for integration/functional end to end tests and is called the WebApplicationFactory.

I’ve used this to create integration tests in my .NET Core and .NET applications.

The WebApplicationFactory was released with .NET Core 3.1 and takes away a lot of the test server setup code that was needed previously.

For further information on the WebApplicationFactory Adam Storr explains this well. Adam’s post covered when the WebApplicationFactory was released and what was needed to convert from the previous style, creating a TestServer to using the WebApplicationFactory.

Using the WebApplicationFactory is excellent for integration/functional tests. It hasn’t yet been made to work with Playwright out of the box. However, with a few tweaks, we can create a CustomWebApplicationFactory that inherits the WebApplicationFactory to make this work for us.

CustomWebApplicationFactory

Our first step is to create our CustomWebApplicationFactory. Our CustomWebApplicationFactory will inherit from the WebApplicationFactory.

public class CustomWebApplicationFactory : WebApplicationFactory<Program>  
{
    ...
}

You might have noticed that we add the class Program into the WebApplicationFactory. This points to the entry point for our application.

Before .NET 6 it was common to pass in the Startup class. In .NET 6, Microsoft is moving away from the Startup class and instead moving all logic that was in the Startup class into the Program class.

The Startup class can still be used, but it shows Microsoft’s intentions here for the future.

Next, we want to override the CreateHost method. Within this method code comments explain each section and why we need to override these.

I have used code from Martin Costello below. He has done a great job at explaining the changes that need to be made in order for us to use the WebApplicationFactory.

protected override IHost CreateHost(IHostBuilder builder)  
{  
    // Create the host for TestServer now before we  
    // modify the builder to use Kestrel instead.    
    var testHost = builder.Build();  

    // Modify the host builder to use Kestrel instead  
    // of TestServer so we can listen on a real address.    

    builder.ConfigureWebHost(webHostBuilder => webHostBuilder.UseKestrel());  

    // Create and start the Kestrel server before the test server,  
    // otherwise due to the way the deferred host builder works    
    // for minimal hosting, the server will not get "initialized    
    // enough" for the address it is listening on to be available.    
    // See https://github.com/dotnet/aspnetcore/issues/33846.    

    _host = builder.Build();  
    _host.Start();  

    // Extract the selected dynamic port out of the Kestrel server  
    // and assign it onto the client options for convenience so it    
    // "just works" as otherwise it'll be the default http://localhost    
    // URL, which won't route to the Kestrel-hosted HTTP server.     

    var server = _host.Services.GetRequiredService<IServer>();  
    var addresses = server.Features.Get<IServerAddressesFeature>();  

    ClientOptions.BaseAddress = addresses!.Addresses  
        .Select(x => new Uri(x))  
        .Last();  

    // Return the host that uses TestServer, rather than the real one.  
    // Otherwise the internals will complain about the host's server    
    // not being an instance of the concrete type TestServer.    
    // See https://github.com/dotnet/aspnetcore/pull/34702.   

    testHost.Start();  
    return testHost;  
}

Once we have our CreateHost method setup, we want to capture and assign the base address and port given when setting up our CreateHost.

The address is created and assigned here.

ClientOptions.BaseAddress = addresses!.Addresses  
        .Select(x => new Uri(x))  
        .Last();  

By default, the incorrect port would be assigned. We need to assign the address and port given to us when using the following services.

 var server = _host.Services.GetRequiredService<IServer>();  
 var addresses = server.Features.Get<IServerAddressesFeature>();  

In our CustomWebApplicationFactory you want to add the following.

public string ServerAddress  
{  
    get  
    {  
        EnsureServer();  
        return ClientOptions.BaseAddress.ToString();  
    }  
}

along with the EnsureServer method.

private void EnsureServer()  
{  
    if (_host is null)  
    {  
        // This forces WebApplicationFactory to bootstrap the server  
        using var _ = CreateDefaultClient();  
    }  
}

This code calls off to methods inside the WebApplicationFactory. After swapping out using the TestServer with Kestrel we then want to create a client and assign the base address to this. The EnsureServer first checks if we have a host before creating a new one.

Once we have our CustomWebApplicationFactory setup, we can setup our test class.

BlazorUiTests

Let’s create a test class named ‘BlazorUiTests’ and inherit PageTest and the IClassFixture passing in our CustomWebApplicationFactory

public class BlazorUiTests : PageTest, IClassFixture<CustomWebApplicationFactory>  
{
    ....
}

Playwright has an assertion framework that we can use. We can access the assertion framework by inheriting PageTest.

Once we have access to the PageTest class, we can use the Expect method. One of the benefits of using Expect over NUnits Assert is Expects polling nature.

Expect will poll and look for the element for 30 secs by default. Quite often, we would need to wait before actioning an assert to ensure the element we are asserting on is visible.

For more information on Playwrights test assertions https://playwright.dev/dotnet/docs/test-assertions

The IClassFixture CustomWebApplicationFactory is using the same style that we use for our integration/functional tests. This declares that a specific setup is required, in our case, the CustomWebApplicationFactory we created earlier.

We want to create a serverAddress field and initialize this. This Server address is the address set in our ClientOptions.BaseAddress that was set in our CustomerWebApplicationFactory

private readonly string _serverAddress;  

public BlazorUiTests(CustomWebApplicationFactory fixture)  
{  

    _serverAddress = fixture.ServerAddress;  
}

We are now ready to create our test.

[Fact]  
public async Task Navigate_to_counter_ensure_current_counter_increases_on_click()  
{
}

We will use the Blazor template project that Microsoft gives us inside Visual Studio. This creates a hello world Blazor application. We can click to navigate to the counter page where we can increment the counter.

For our test, we will click this counter and Expect(assert) that the counter has increased by 1.

First, we must launch Playwright and create a browser for us to use. We can use either Chromium, Firefox or WebKit. In our case, we are using chromium.

Once we have our chromium instance, we want to call the NewPageAsync method. This creates a new page in a new browser context.

[Fact]  
public async Task Navigate_to_counter_ensure_current_counter_increases_on_click()  
{  
    //Arrange  
    using var playwright = await Microsoft.Playwright.Playwright.CreateAsync();  
    await using var browser = await playwright.Chromium.LaunchAsync();  
    var page = await browser.NewPageAsync();

We navigate to our hello world Blazor application, click on our counter page and click to increment the counter. We then use Playwrights Expect to assert that the counter has been incremented by 1.

[Fact]  
public async Task Navigate_to_counter_ensure_current_counter_increases_on_click()  
{  
    //Arrange  
    using var playwright = await Microsoft.Playwright.Playwright.CreateAsync();  
    await using var browser = await playwright.Chromium.LaunchAsync();  
    var page = await browser.NewPageAsync();  

    //Act  
    await page.GotoAsync(_serverAddress);  
    await page.ClickAsync("[class='nav-link']");  
    await page.ClickAsync("[class='btn btn-primary']");  

    //Assert  
    await Expect(page.Locator("[role='status']")).ToHaveTextAsync("Current count: 1");  
}

Summary

There you have it. In future, I hope that Microsoft will bring out a version of the WebApplicationFactory that will work right out of the box for our Playwright tests. We need to do a little tweaking to make it work for us. This approach might not suit everyone’s needs, but I think it’s good to be aware that you can still use the WebApplicationFactory.

Do you see any ways we can improve this? Would you use this approach? Let me know in the comments.

Github

Source code: https://github.com/donbavand/playwright-webapplicationfactory

Further Reading

End-to-End Tests With ASP.NET Core, XUnit, and Playwright

Integration Testing ASP.NET Core 6 Minimal APIs

11 thoughts on “Using Playwright and the WebApplicationFactory To Test Your Blazor Application

  1. I’m having the same problem. The following 2 lines in CreateHost are the issue:
    var testHost = builder.Build();
    and
    _host = builder.Build();

    You can only call builder.Build once, so I have no idea how you or Martin Costello got this code to run. I’d love to figure this out because it would be very helpful for our test process.

    Like

Leave a reply to Rafał Schmidt Cancel reply