End-to-End UI Testing with Playwright for .NET

Selenium dominated browser automation for years, but Playwright has rapidly become the modern alternative. Built by Microsoft, Playwright offers auto-waiting, reliable selectors, and first-class support for Chromium, Firefox, and WebKit — all from a clean .NET API. If you've ever battled flaky Selenium tests, Playwright's architecture addresses those pain points directly.

Setup

Install the NuGet package and the Playwright CLI:

Example.cs
dotnet add package Microsoft.Playwright
dotnet build
pwsh bin/Debug/net8.0/playwright.ps1 install

The playwright.ps1 install command downloads browser binaries. This only needs to run once per machine.

For xUnit integration:

Example.cs
dotnet add package Microsoft.Playwright.Xunit

Your First Test

Example.cs
public class HomePageTests : PageTest
{
    [Fact]
    public async Task HomePage_DisplaysWelcomeMessage()
    {
        await Page.GotoAsync("https://localhost:5001");

        await Expect(Page.GetByRole(AriaRole.Heading, new() { Level = 1 }))
            .ToHaveTextAsync("Welcome to My App");
    }
}

The PageTest base class provides a Page property — a fresh browser page for each test. Playwright handles browser launch, context creation, and cleanup.

Locators and Auto-Waiting

Playwright's locator API is its greatest strength. Instead of brittle CSS selectors, you use role-based and text-based locators:

Example.cs
// By role (accessible name)
var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Submit" });

// By label text
var emailInput = Page.GetByLabel("Email address");

// By placeholder
var searchBox = Page.GetByPlaceholder("Search products...");

// By test ID (for elements without accessible names)
var sidebar = Page.GetByTestId("navigation-sidebar");

// By text content
var link = Page.GetByText("View all products");

Every action on a locator automatically waits for the element to be actionable — visible, enabled, and stable. No explicit waits, no Thread.Sleep, no retry loops.

A Complete Login Flow

Example.cs
public class LoginTests : PageTest
{
    [Fact]
    public async Task Login_ValidCredentials_RedirectsToDashboard()
    {
        await Page.GotoAsync("https://localhost:5001/login");

        await Page.GetByLabel("Email").FillAsync("[email protected]");
        await Page.GetByLabel("Password").FillAsync("SecurePassword123");
        await Page.GetByRole(AriaRole.Button, new() { Name = "Sign in" }).ClickAsync();

        await Expect(Page).ToHaveURLAsync("**/dashboard");
        await Expect(Page.GetByText("Welcome, Alice")).ToBeVisibleAsync();
    }

    [Fact]
    public async Task Login_InvalidCredentials_ShowsError()
    {
        await Page.GotoAsync("https://localhost:5001/login");

        await Page.GetByLabel("Email").FillAsync("[email protected]");
        await Page.GetByLabel("Password").FillAsync("wrong-password");
        await Page.GetByRole(AriaRole.Button, new() { Name = "Sign in" }).ClickAsync();

        await Expect(Page.GetByRole(AriaRole.Alert))
            .ToHaveTextAsync("Invalid email or password");
    }
}

Intercepting Network Requests

Playwright can intercept and mock API calls, useful for testing front-end behaviour without a real backend:

Example.cs
[Fact]
public async Task ProductList_DisplaysProductsFromApi()
{
    await Page.RouteAsync("**/api/products", async route =>
    {
        var json = JsonSerializer.Serialize(new[]
        {
            new { Id = 1, Name = "Widget", Price = 9.99 },
            new { Id = 2, Name = "Gadget", Price = 19.99 }
        });

        await route.FulfillAsync(new()
        {
            ContentType = "application/json",
            Body = json
        });
    });

    await Page.GotoAsync("https://localhost:5001/products");

    await Expect(Page.GetByText("Widget")).ToBeVisibleAsync();
    await Expect(Page.GetByText("Gadget")).ToBeVisibleAsync();
}

Screenshots and Tracing

When tests fail, you need to see what happened. Playwright provides screenshots and full trace recording:

Example.cs
[Fact]
public async Task Checkout_CompletesSuccessfully()
{
    // Start tracing
    await Context.Tracing.StartAsync(new()
    {
        Screenshots = true,
        Snapshots = true,
        Sources = true
    });

    try
    {
        await Page.GotoAsync("https://localhost:5001/checkout");
        // ... test steps ...
    }
    finally
    {
        await Context.Tracing.StopAsync(new()
        {
            Path = "traces/checkout-trace.zip"
        });
    }
}

Open the trace file with pwsh playwright.ps1 show-trace traces/checkout-trace.zip to get a visual timeline of every action, network request, and DOM snapshot.

Running Against Your ASP.NET Core App

Combine Playwright with WebApplicationFactory to start your app in-process:

Example.cs
public class AppFixture : IAsyncLifetime
{
    private WebApplicationFactory<Program> _factory;
    public string BaseUrl { get; private set; }

    public async Task InitializeAsync()
    {
        _factory = new WebApplicationFactory<Program>();
        var client = _factory.CreateClient();
        BaseUrl = client.BaseAddress!.ToString();
    }

    public async Task DisposeAsync()
    {
        await _factory.DisposeAsync();
    }
}

CI Configuration

Playwright in CI requires the browser binaries. In your CI pipeline:

config.yaml
- name: Install Playwright browsers
  run: pwsh bin/Release/net8.0/playwright.ps1 install --with-deps

The --with-deps flag installs system-level dependencies (fonts, libraries) needed by the browsers.

Run tests in headless mode (the default) for CI. For local development, you can run headed:

Example.cs
// In your test
await using var browser = await Playwright.Chromium.LaunchAsync(new()
{
    Headless = false,
    SlowMo = 500 // Slow down actions by 500ms for debugging
});

Playwright vs Selenium

Playwright's key advantages over Selenium: auto-waiting eliminates most flaky tests, the locator API encourages accessible selectors, network interception is built in, and multi-browser support works from a single API. If you're starting a new UI test suite, Playwright is the clear choice for .NET projects.