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:
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:
dotnet add package Microsoft.Playwright.Xunit
Your First Test
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:
// 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
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:
[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:
[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:
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:
- 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:
// 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.