Writing automated UI tests for an ASP.NET Core application using Playwright and xUnit

  • Gérald Barré

In a previous post, I wrote about testing an ASP.NET Core application with an in-memory server. This is useful to run integration tests to validate the server produces the expected response for a request. However, this doesn't allow to test JavaScript code nor to validate how the page displays. If you want to test these components, you need to run an actual browser and load your site. The code in this post allows testing ASP.NET Core MVC application and ASP.NET Core Blazor Server application.

There are multiple ways to instrument browsers. The most used library is Selenium. This library uses the WebDriver, a W3C recommendation. The WebDriver API is implemented by all major browsers, so this is the way to use when you need to support many browsers. Pupeteer and Playwright came out more recently and provide similar automation capabilities with a simpler API and more control on the browsers, but they support fewer browsers. Playwright only supports Chromium, Firefox and WebKit browsers.

In this post, I'll use Playwright as it comes with nice helpers to install all dependencies and there is a .NET wrapper. The .NET wrapper is developed by Dario Kondratiuk. The repository recently moved to the microsoft organization.

#Starting the server in the unit tests

To test with a browser, you need to start the web server and get its URL. ASP.NET Core doesn't come with a ready to use API to do that in your tests. You can upvote this issue if you think this is could be useful. That's being said, the ASP.NET Core repository on GitHub contains samples in their tests that can be reused. That's what is nice when you use an OSS product 😃

First, add a reference to the Microsoft.AspNetCore.Mvc.Testing NuGet package to your project:

<ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="3.1.8" />
</ItemGroup>

Then, you can add these classes to your project. The idea is to start a host in a background thread and get the root URL when the server is ready. The code comes from the ASP.NET Core repository

public abstract class WebHostServerFixture : IDisposable
{
    private readonly Lazy<Uri> _rootUriInitializer;

    public Uri RootUri => _rootUriInitializer.Value;
    public IHost Host { get; set; }

    public WebHostServerFixture()
    {
        _rootUriInitializer = new Lazy<Uri>(() => new Uri(StartAndGetRootUri()));
    }

    protected static void RunInBackgroundThread(Action action)
    {
        using var isDone = new ManualResetEvent(false);

        ExceptionDispatchInfo edi = null;
        new Thread(() =>
        {
            try
            {
                action();
            }
            catch (Exception ex)
            {
                edi = ExceptionDispatchInfo.Capture(ex);
            }

            isDone.Set();
        }).Start();

        if (!isDone.WaitOne(TimeSpan.FromSeconds(10)))
            throw new TimeoutException("Timed out waiting for: " + action);

        if (edi != null)
            throw edi.SourceException;
    }

    protected virtual string StartAndGetRootUri()
    {
        // As the port is generated automatically, we can use IServerAddressesFeature to get the actual server URL
        Host = CreateWebHost();
        RunInBackgroundThread(Host.Start);
        return Host.Services.GetRequiredService<IServer>().Features
            .Get<IServerAddressesFeature>()
            .Addresses.Single();
    }

    public virtual void Dispose()
    {
        Host?.Dispose();
        Host?.StopAsync();
    }

    protected abstract IHost CreateWebHost();
}

public class WebHostServerFixture<TStartup> : WebHostServerFixture
    where TStartup : class
{
    protected override IHost CreateWebHost()
    {
        return new HostBuilder()
            .ConfigureWebHost(webHostBuilder => webHostBuilder
                .UseKestrel()
                .UseSolutionRelativeContentRoot(typeof(TStartup).Assembly.GetName().Name)
                .UseStaticWebAssets()
                .UseStartup<TStartup>()
                .UseUrls($"http://127.0.0.1:0")) // :0 allows to choose a port automatically
            .Build();
    }
}

You can now start the server from a xUnit test:

public class UnitTest1 : IClassFixture<WebHostServerFixture<Startup>>
{
    private readonly WebHostServerFixture<Startup> _server;

    public UnitTest1(WebHostServerFixture<Startup> server) => _server = server;

    [Fact]
    public async Task Test1()
    {
        _ = _server.RootUri; // Start the server and get the URI

        // TODO actual test
    }
}

#Automating the browser to test the application

Now that the server is started, the next step is to automate the browser to open the RootUri and run tests.

First, you need to install the Playwright wrapper:

<ItemGroup>
    <PackageReference Include="PlaywrightSharp" Version="0.111.2" />
</ItemGroup>

Then, you need to install the tools. This means installing Chrome, Firefox, and WebKit. This needs to be done once, so let's create a helper to ensure this is done once even if tests run in parallel.

internal static class PlaywrightHelpers
{
    private static readonly Lazy<Task> _install = new Lazy<Task>(() => Playwright.InstallAsync());
    public static Task InstallAsync() => _install.Value;
}

You can call this method before using Playwright. Let's write a basic test using Playwright:

public class UnitTest1 : IClassFixture<WebHostServerFixture<Startup>>
{
    private readonly WebHostServerFixture<Startup> _server;

    public UnitTest1(WebHostServerFixture<Startup> server) => _server = server;

    [Fact]
    public async Task DisplayHomePage()
    {
        await PlaywrightHelpers.InstallAsync();
        using var playwright = await Playwright.CreateAsync();

        // You can use playwright.Firefox, playwright.Chromium, or playwright.WebKit
        await using var browser = await playwright.Chromium.LaunchAsync(new LaunchOptions
        {
            Headless = true,
            IgnoreHTTPSErrors = true,
        });

        var page = await browser.NewPageAsync();

        // Navigate to the home page
        await page.GoToAsync(_server.RootUri.AbsoluteUri);

        // Get the first h1 element and test the text content
        var header = await page.WaitForSelectorAsync("h1");
        Assert.Equal("Hello, world!", await header.GetTextContentAsync());
    }

    [Fact]
    public async Task Counter()
    {
        await PlaywrightHelpers.InstallAsync();
        using var playwright = await Playwright.CreateAsync();
        // You can use playwright.Firefox, playwright.Chromium, or playwright.WebKit
        await using var browser = await playwright.Chromium.LaunchAsync(new LaunchOptions
        {
            Headless = true,
            IgnoreHTTPSErrors = true,
        });

        var page = await browser.NewPageAsync();
        await page.GoToAsync(_server.RootUri + "/counter", LifecycleEvent.Networkidle);
        await page.ClickAsync("#IncrementBtn");

        // Selectors are not only CSS selectors. You can use xpath, css, or text selectors
        // By default there is a timeout of 30s. If the selector isn't found after the timeout, an exception is thrown.
        // More about selectors: https://playwright.dev/#version=v1.4.2&path=docs%2Fselectors.md
        await page.WaitForSelectorAsync("text=Current count: 1");
    }
}

You can now run the test and check that everything's ok with your site. If you need to debug your test, you can set Headless = false and SlowMo = 250, so you can see the browser and understand what's going on. Also, you can take screenshots using page.ScreenshotAsync(path: "test.png", fullPage: true). This could also be used to compare the page with an existing screenshot.

// For debugging purpose, you can set Headless=false and SlowMo=250
await using var browser = await playwright.Chromium.LaunchAsync(new LaunchOptions
{
    Headless = false,
    IgnoreHTTPSErrors = true,
    SlowMo = 250,
});

In the following video, I enabled both debugging options. With debugging options the 2 tests run in 9.6s. Without them, it runs in 3.6s.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee