Within the .NET community, integration testing has become a common practice thanks to advancements in the framework. ASP.NET Core has a robust integration testing suite allowing .NET developers to run in-memory versions of their web applications, with the ability to issue requests and verify the responses.

In general, the test suite is fine, but it does have its limits, mainly that responses are the literal payloads and not executed within the context of browser session. In this post, we’ll see how we can use the Playwright library in combination with XUnit to test our web applications as users might.

What Is Playwright?

Playwright is a library that enables developers to write end-to-end tests for their web applications. Developers using Playwright can automate popular browser engines Chromium, Firefox, and Webkit across all modern operating systems: Linux, macOS, and Windows. With a selection of web engines, developers can test simple HTML pages to complex single-page apps with no limits. Playwright also has excellent support regardless of a developer’s technology of choice, with APIs written for JavaScript, Typescript, Python, Java, and C#. Tests will also run during CI/CD builds, helping ensure UX bugs don’t work themselves into production.

While Playwright’s primary purpose is for end-to-end tests, developers can also use it to scrape data from existing web applications. Please use this knowledge respectfully and responsibly.

How To Use Playwright With ASP.NET Core and XUnit

First, we’ll need to install XUnit, Microsoft.NET.Test.Sdk, and PlaywrightSharp NuGet packages.

<ItemGroup>
  <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
  <PackageReference Include="PlaywrightSharp" Version="0.192.0" />
  <PackageReference Include="xunit" Version="2.4.1" />
</ItemGroup>

Sadly, we can’t use the integration library that is part of ASP.NET Core. As mentioned previously, it only runs in-memory, which means a browser can’t navigate to any endpoints we expose. The problem requires some ingenuity on our part. Luckily, we can spin up an ASP.NET Core application using the CreateHostBuilder method found generally in our apps Program.cs file.

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();
            });
}

We’ll also be using the IClassFixture interface in XUnit to keep the instances of browsers to a minimum. Playwright is fast but requires some setup and startup time we’d like to avoid in every test. We’ll also specify Chromium as the browser we’ll be using for our tests. Let’s look at the core of our tests.

Note, you may need to set the content root of your web application if you have CSS and javascript assets. Use the webBuilder to call UseContentRoot with a path.

using System;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using PlaywrightSharp;
using Xunit;

// ReSharper disable once ClassNeverInstantiated.Global
public class WebServerFixture : IAsyncLifetime, IDisposable
{
    private readonly IHost host;
    private IPlaywright Playwright { get; set; }
    public IBrowser Browser { get; private set; }
    public string BaseUrl { get; } = $"https://localhost:{GetRandomUnusedPort()}";

    public WebServerFixture()
    {
        host = Program
            .CreateHostBuilder(null)
            .ConfigureWebHostDefaults(webBuilder => {
                webBuilder.UseStartup<Startup>();
                webBuilder.UseUrls(BaseUrl);
                // optional to set path to static file assets
                // webBuilder.UseContentRoot();
            })
            .ConfigureServices(configure => {
                // override any services
            })
            .Build();
    }

    public async Task InitializeAsync()
    {
        Playwright = await PlaywrightSharp.Playwright.CreateAsync();
        Browser = await Playwright.Chromium.LaunchAsync();
        await host.StartAsync();
    }

    public async Task DisposeAsync()
    {
        await host.StopAsync();
        host?.Dispose();
        Playwright?.Dispose();
    }

    public void Dispose()
    {
        host?.Dispose();
        Playwright?.Dispose();
    }

    private static int GetRandomUnusedPort()
    {
        var listener = new TcpListener(IPAddress.Any, 0);
        listener.Start();
        var port = ((IPEndPoint)listener.LocalEndpoint).Port;
        listener.Stop();
        return port;
    }
}

The WebServerFixture class helps us create instances for our IPlaywright and IBrowser interfaces. Additionally, within the constructor, we set up our ASP.NET Core IHost instance. We need to find a random open port to listen in on as well.

private static int GetRandomUnusedPort()
{
    var listener = new TcpListener(IPAddress.Any, 0);
    listener.Start();
    var port = ((IPEndPoint)listener.LocalEndpoint).Port;
    listener.Stop();
    return port;
}

Before we start writing tests, let’s talk about the testing strategy. In my opinion, it’s best to decorate HTML elements with unique attributes that won’t change as our UI/UX experience evolves. I’ve added a pw-name attribute to elements that describe a feature on the page.

<h1 class="display-4" pw-name="Page Title">Welcome</h1>

Now, let’s write a test!

using System.Threading.Tasks;
using Xunit;

public class WebServerTests : IClassFixture<WebServerFixture>
{
    private readonly WebServerFixture fixture;

    public WebServerTests(WebServerFixture fixture)
    {
        this.fixture = fixture;
    }

    [Fact]
    public async Task Page_title_equals_Welcome()
    {
        var page = await fixture.Browser.NewPageAsync();
        await page.GoToAsync(fixture.BaseUrl);
        
        var actual = await page.GetTextContentAsync(
            Element.ByName("Page Title")
        );
        
        Assert.Equal("Welcome", actual);
    }
}

public static class Element
{
    public static string ByName(string name)
        => $"[pw-name='{name}']";
}

Wow! So straightforward. There is one downside to debugging tests, and that’s while we pause the debugger on a breakpoint, our server can’t process any requests, which makes exercising the app impossible without a few additional lines of code.

var pause = true;
while (pause)
{
    Thread.Sleep(TimeSpan.FromSeconds(3));
}

We’ll need to use the pause debugger feature, and the immediate window to change the value of pause to continue our test.

Running the test, we see that the execution takes 524ms, and that time includes starting Playwright, and our ASP.NET Core web application. Additional tests in our test suite would benefit from the shared instance of WebServerFixture.

Playwright test running and working in JetBrains Rider IDE

Conclusion

Playwright is a neat end-to-end library with benefits for ASP.NET Core developers. Playwright has additional APIs that allow us to take screenshots, exercise UI, and more. With the test fixture in this post, developers can write end-to-end tests that run inside CI/CD pipelines, giving everyone more confidence that the app’s behavior is intentional. I hope you found this post helpful, and as always, thanks for reading.