asp.net core E2E Tests with xUnit and Playwright

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.

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

Create a new Project with the Blazor WebAssembly App template.
Create a new Project with the Blazor WebAssembly App template.
Choose .net 6, No Authentication and us the HTTPS feature. The Application should be asp.net core hosted and use the PWA template.
Choose .net 6, No Authentication and us the HTTPS feature. The Application should be asp.net core hosted and use the PWA template.
I'll call the Application BlazorE2E.
I'll call the Application BlazorE2E.

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.

Add a new Project to the Solution.
Add a new Project to the Solution.
Choose the xUnit template.
Choose the xUnit template.
Also use .net 6.
Also use .net 6.
If you pay much attention, you can see, that the project is in another directory. I did this only to be able to make nice screenshots. The real project is inside the existing solution.
If you pay much attention, you can see, that the project is in another directory. I did this only to be able to make nice screenshots. The real project is inside the existing solution.

Pretty straight forward and your solution should now look like this

The Solution structure
The Solution structure

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

Rebuild the structure of the application in the Test Project.
Rebuild the structure of the application in the Test Project.

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

Adding a Counter_Should.cs class.
Adding a Counter_Should.cs class.

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()
		{

		}
	}
}

Making the new class a xUnit Test class.

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.

Adding a NuGet Package.
Adding a NuGet Package.
Choosing the Microsoft.Playwright package.
Choosing the Microsoft.Playwright package.

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

Build only the Test Project.
Build only the Test Project.

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 
Installing the Browsers for Playwright.

Now you have everything you need to generate the test code!

First start the Server Application with Visual Studio

Start the Server Application.
Start the Server Application.

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 
Start the Code Generator.

This will start a chromium browser with a Playwright inspector next to it.

The Playwright Inspector.
The Playwright Inspector.

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.

The new line of code in the Playwright Inspector.
The new line of code 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"); 
Playwright code to navigate to the counter page and checking that the counter is 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"); 
Playwright code of clicking the counter and checking that it is then 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 whole Playwright code.

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");
		}
	}
} 
The xUnit Test Class.

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/ 
Starting the Server Application from Terminal.

I can run the test from Visual Studio

Opening the Test Explorer in Visual Studio 2022 for Mac.
Opening the Test Explorer in Visual Studio 2022 for Mac.
The Test Structure.
The Test Structure.
Run a Test from the Test Explorer.
Run a Test from the Test Explorer.

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

Succeeded Test.
Succeeded Test.

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
}); 
Slowing the Browser down.

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 
Failed Test when the Server Application is not running.

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 { } 
Adding a Program class to the Servers Program.cs

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 { } 
The new Program.cs of the Server Application

Then I need to add a reference to the BlazorE2E.Server Project to the BlazorE2E.Tests Project.

Adding a Project Reference to the Test Project.
Adding a Project Reference to the Test Project.
Reference the Server Project.
Reference the Server 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.

Adding the Microsoft.AspNetCore.Mvc.Testing NuGet Package.
Adding the Microsoft.AspNetCore.Mvc.Testing 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()
        {
        }
    }
} 
Creating the WebApplicationFactoryFixture.

I need to override ConfigureWebHost like this

protected override void ConfigureWebHost(IWebHostBuilder builder)
{

} 
Override the ConfigureWebHost Method.

I need to tell the Server on what Urls to listen

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
	builder.UseUrls("https://localhost:7048");
} 
Specify the URL for the Server.

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;
} 
Overriding the CreateHost Function.

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;
        }
    }
} 
The Finished WebApplicationFactoryFixture.

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> 
Inheriting the WebApplicationFactoryFixture in Counter_Should.

Then I have the `WebApplicationFactoryFixture in the Constructor and can start the Server by calling CreateDefaultClient().

public Counter_Should(WebApplicationFactoryFixture factoryFixture)
{
	factoryFixture.CreateDefaultClient();
} 
Start the Server in the Constructor of Counter_Should.

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");
        }
	}
} 
The Finished Test Class.

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.

Loading comments...
You've successfully subscribed to steinbach.io
Great! Next, complete checkout to get full access to all premium content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.