Writing automated UI tests for ASP.NET Core applications 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 / Razor Pages / Blazor Server / Blazor WebAssembly applications.

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, and was 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:

csproj (MSBuild project file)
<ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.0" />

    <!-- For Blazor WebAssembly -->
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="6.0.0" />
</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

C#
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();
}

// ASP.NET Core with a Startup class (MVC / Pages / Blazor Server)
public class WebHostServerFixture<TStartup> : WebHostServerFixture
    where TStartup : class
{
    protected override IHost CreateWebHost()
    {
        return new HostBuilder()
            .ConfigureHostConfiguration(config =>
            {
                // Make UseStaticWebAssets work
                var applicationPath = typeof(TStartup).Assembly.Location;
                var applicationDirectory = Path.GetDirectoryName(applicationPath);

                // In ASP.NET 5, the file is named app.staticwebassets.xml
                // In ASP.NET 6, the file is named app.staticwebassets.runtime.json
#if NET6_0_OR_GREATER
                var name = Path.ChangeExtension(applicationPath, ".staticwebassets.runtime.json");
#else
                var name = Path.ChangeExtension(applicationPath, ".StaticWebAssets.xml");
#endif
                var inMemoryConfiguration = new Dictionary<string, string>
                {
                    [WebHostDefaults.StaticWebAssetsKey] = name,
                };
                config.AddInMemoryCollection(inMemoryConfiguration);
            })
            .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();
    }
}

// If you are using a Blazor WebAssembly application without a server, you can use the following type.
// TProgram correspond to a type (often `Program`) from the WebAssembly application.
public class BlazorWebAssemblyWebHostFixture<TProgram> : WebHostServerFixture
{
    protected override IHost CreateWebHost()
    {
        return new HostBuilder()
            .ConfigureHostConfiguration(config =>
            {
                // Make UseStaticWebAssets work
                var applicationPath = typeof(TProgram).Assembly.Location;
                var applicationDirectory = Path.GetDirectoryName(applicationPath);

                // In ASP.NET 5, the file is named app.staticwebassets.xml
                // In ASP.NET 6, the file is named app.staticwebassets.runtime.json
#if NET6_0_OR_GREATER
                var name = Path.ChangeExtension(applicationPath, ".staticwebassets.runtime.json");
#else
                var name = Path.ChangeExtension(applicationPath, ".StaticWebAssets.xml");
#endif
                var inMemoryConfiguration = new Dictionary<string, string>
                {
                    [WebHostDefaults.StaticWebAssetsKey] = name,
                };
            })
            .ConfigureWebHost(webHostBuilder => webHostBuilder
                .UseKestrel()
                .UseSolutionRelativeContentRoot(typeof(TProgram).Assembly.GetName().Name)
                .UseStaticWebAssets()
                .UseStartup<Startup>()
                .UseUrls($"http://127.0.0.1:0")) // :0 allows to choose a port automatically
            .Build();
    }

    private sealed class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddRouting();
        }

        public void Configure(IApplicationBuilder app)
        {
            app.UseDeveloperExceptionPage();
            app.UseBlazorFrameworkFiles();
            app.UseStaticFiles(new StaticFileOptions
            {
                ServeUnknownFileTypes = true,
            });

            app.UseRouting();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapFallbackToFile("index.html");
            });
        }
    }
}

You can now start the server from a xUnit test:

C#
// ASP.NET Core (MVC / Pages / Blazor Server)
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
    }
}

// Blazor WebAssembly (without a server)
public class UnitTest2 : IClassFixture<BlazorWebAssemblyWebHostFixture<Program>>
{
    private readonly BlazorWebAssemblyWebHostFixture<Program> _server;

    public UnitTest2(BlazorWebAssemblyWebHostFixture<Program> 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 and the browsers:

Shell
dotnet add package Microsoft.Playwright
dotnet tool update --global Microsoft.Playwright.CLI
dotnet build
playwright install

Let's write a basic test using Playwright:

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

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

    [Fact]
    public async Task DisplayHomePage()
    {
        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, // Set to false to debug your tests
            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()
    {
        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.

C#
// 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. The 2 tests run in 9.6s with debugging options. 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💖 Sponsor on GitHub