This guide uses the .NET Aspire Dashboard (Blazor Server) as a concrete example, but the patterns apply to any Blazor WebAssembly or Server app.
var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync(
new() { Headless = true });
var page = await browser.NewPageAsync();For deterministic CI runs, pin the browser channel:
Chromium.LaunchAsync(new() { Channel = "msedge", Headless=true });Blazor renders one HTML document; subsequent page changes happen via JavaScript.
So the first navigation is classic:
await page.GotoAsync("https://localhost:5001");| Goal | Code | Notes |
|---|---|---|
| Click an internal nav link | await page.GetByRole(AriaRole.Link,new(){Name="Settings"}).ClickAsync(); |
Wait for render rather than network idle, because no page reload occurs. |
| Programmatic navigation (via JS) | await page.EvaluateAsync("Blazor.navigateTo('/settings')"); |
Works in Blazor ≥ 8.0 (Blazor object exposed globally). |
| Hard refresh to deep URL | await page.GotoAsync(baseUrl + "/settings"); |
Blazor supports deep linking, so full load is fine. |
After a Blazor internal navigation the URL changes instantly, but DOM may take a frame to update.
Prefer:
await page.WaitForSelectorAsync("h1:has-text('Settings')");over WaitForLoadStateAsync, because the network remains idle.
await page.GotoAsync(baseUrl + "/login");
await page.FillAsync("input[name='username']", "alice");
await page.FillAsync("input[name='password']", "P@ssw0rd");
await page.ClickAsync("button[type='submit']");
// Wait for redirect to dashboard
await page.WaitForURLAsync("**/dashboard");await page.Context.AddCookiesAsync(new[]
{
new Cookie
{
Name = ".AspNetCore.Cookies",
Value = "encryptedCookieValue",
Url = baseUrl
}
});
await page.GotoAsync(baseUrl); // Already authenticatedGenerate encryptedCookieValue via API endpoint or an in-memory auth handler in the test server.
- Stub the identity provider locally with WireMock or Duende IdentityServer.
- Use Playwright route interception to short-circuit the redirect and return a valid token.
// Standard click — pointer
await page.GetByText("Add Widget").ClickAsync();
// Touchscreen tap (mobile emulation)
await page.EmulateMediaAsync(new() { Media = Media.Screen });
await page.Touchscreen.TapAsync(150, 300);Extra patterns:
- Right-click
await page.ClickAsync(selector, new() { Button = MouseButton.Right }); - Hover->Click inside dropdown
var menu = page.Locator("#profileMenu"); await menu.HoverAsync(); await menu.GetByText("Sign out").ClickAsync();
Blazor generates predictable DOM IDs; but testability improves if you:
<button data-test="add-widget">Add widget</button>Then in Playwright:
await page.GetByTestId("add-widget").ClickAsync();GetByTestId is built into Playwright selectors (maps to [data-testid="xxx"] and [data-test="xxx"]).
Blazor shows an overlay when unhandled exceptions occur.
Add a universal test assertion to fail fast:
var errorUi = page.Locator("#blazor-error-ui");
if (await errorUi.IsVisibleAsync())
Assert.Fail(await errorUi.InnerTextAsync());- Export the
localhostdev-cert (dotnet dev-certs https --export-path cert.pfx -p pass). - Tell Playwright to trust it:
Chromium.LaunchAsync(new()
{
Args = new[] { "--ignore-certificate-errors" },
Headless = true
});For stricter setups, inject the certificate into the browser context instead.
Blazor (Server) uses SignalR websockets; multiple simultaneous Playwright pages that hit the same server can saturate connections.
Set the following to minimize flaky tests:
[Collection("Blazor dashboard")]
public class MyTests { /* … */ }
[assembly: CollectionBehavior(MaxParallelThreads = 1)]