Blazor End-to-End Testing with Playwright
bUnit excels at component-level testing in isolation. But when you need to verify that your Blazor application works correctly in a real browser — handling navigation, authentication, SignalR connections, and JavaScript interop — Playwright is the right tool. This guide covers Playwright end-to-end testing for both Blazor Server and Blazor WebAssembly.
Why Playwright for Blazor E2E
Blazor has nuances that make E2E testing non-trivial:
- Blazor Server uses SignalR for UI updates — the DOM changes asynchronously in response to server events
- Blazor WebAssembly loads a .NET runtime in the browser — initial load is slower, and async initialization patterns differ
- Authentication often involves cookies, tokens, or OIDC flows that need realistic browser handling
- JavaScript interop is invisible in bUnit mocks but must work correctly in the real browser
Playwright handles all of this because it tests against a real browser process.
Setting Up Playwright for .NET
dotnet add package Microsoft.Playwright.NUnit # or .MSTest or .Xunit
dotnet build
pwsh bin/Debug/net8.0/playwright.ps1 installFor xUnit:
<PackageReference Include="Microsoft.Playwright.Xunit" Version="1.47.0" />Writing the First Playwright Test
using Microsoft.Playwright;
using Microsoft.Playwright.Xunit;
using Xunit;
public class HomePageTest : PageTest
{
[Fact]
public async Task HomePageLoads()
{
await Page.GotoAsync("https://localhost:5001");
await Expect(Page).ToHaveTitleAsync("My Blazor App");
await Expect(Page.Locator("h1")).ToContainTextAsync("Welcome");
}
}PageTest (from the Playwright xUnit package) provides an injected Page and handles browser lifecycle. The Expect methods are auto-retrying — they wait until the assertion passes or times out, which is essential for Blazor Server where DOM updates arrive asynchronously.
Testing Blazor Server Navigation
Blazor Server navigation uses SignalR, not full page reloads. Playwright handles this transparently, but you need to wait for content to appear:
public class NavigationTest : PageTest
{
[Fact]
public async Task NavigatesBetweenPages()
{
await Page.GotoAsync("https://localhost:5001");
// Click nav link — Blazor Router handles this without page reload
await Page.ClickAsync("text=Counter");
// Wait for Blazor to render the new route
await Expect(Page.Locator("h1")).ToContainTextAsync("Counter");
await Expect(Page.Locator("p")).ToContainTextAsync("Current count: 0");
}
[Fact]
public async Task BrowserBackButtonWorks()
{
await Page.GotoAsync("https://localhost:5001");
await Page.ClickAsync("text=Counter");
await Expect(Page.Locator("h1")).ToContainTextAsync("Counter");
await Page.GoBackAsync();
await Expect(Page.Locator("h1")).ToContainTextAsync("Welcome");
}
}Testing Interactive Components
public class CounterE2ETest : PageTest
{
[Fact]
public async Task IncrementButtonWorks()
{
await Page.GotoAsync("https://localhost:5001/counter");
var countText = Page.Locator("p:has-text('Current count')");
await Expect(countText).ToContainTextAsync("0");
await Page.ClickAsync("button:has-text('Click me')");
await Expect(countText).ToContainTextAsync("1");
await Page.ClickAsync("button:has-text('Click me')");
await Expect(countText).ToContainTextAsync("2");
}
}Testing Authentication Flows
Authentication is one of the most important E2E scenarios for Blazor apps. Playwright's browser context lets you save and reuse authentication state:
public class AuthFixture : IAsyncLifetime
{
public IBrowserContext AuthContext { get; private set; } = null!;
private IPlaywright _playwright = null!;
private IBrowser _browser = null!;
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync();
// Check for saved auth state
if (File.Exists("auth-state.json"))
{
AuthContext = await _browser.NewContextAsync(new BrowserNewContextOptions
{
StorageStatePath = "auth-state.json"
});
}
else
{
// Perform login and save state
AuthContext = await _browser.NewContextAsync();
var loginPage = await AuthContext.NewPageAsync();
await loginPage.GotoAsync("https://localhost:5001/login");
await loginPage.FillAsync("#email", "testuser@example.com");
await loginPage.FillAsync("#password", "TestPassword123!");
await loginPage.ClickAsync("button[type=submit]");
await loginPage.WaitForURLAsync("**/dashboard");
// Save for reuse across tests
await AuthContext.StorageStateAsync(new BrowserContextStorageStateOptions
{
Path = "auth-state.json"
});
await loginPage.CloseAsync();
}
}
public async Task DisposeAsync()
{
await _browser.CloseAsync();
_playwright.Dispose();
}
}
public class DashboardTest : IClassFixture<AuthFixture>
{
private readonly AuthFixture _fixture;
public DashboardTest(AuthFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task AuthenticatedUserSeesDashboard()
{
var page = await _fixture.AuthContext.NewPageAsync();
await page.GotoAsync("https://localhost:5001/dashboard");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await Expect(page.Locator("h1")).ToContainTextAsync("Dashboard");
// Verify user-specific content
await Expect(page.Locator(".user-greeting")).ToContainTextAsync("testuser");
}
[Fact]
public async Task UnauthenticatedRedirectsToLogin()
{
// Use a fresh context without auth
var browser = await _fixture.AuthContext.Browser!.NewContextAsync();
var page = await browser.NewPageAsync();
await page.GotoAsync("https://localhost:5001/dashboard");
await page.WaitForURLAsync("**/login**");
Assert.Contains("/login", page.Url);
}
}Testing Forms and Validation
public class ContactFormTest : PageTest
{
[Fact]
public async Task ShowsValidationErrors()
{
await Page.GotoAsync("https://localhost:5001/contact");
// Submit empty form
await Page.ClickAsync("button[type=submit]");
// Blazor validation messages appear after submission
var validationMessages = Page.Locator(".validation-message");
await Expect(validationMessages).ToHaveCountAsync(3); // name, email, message
await Expect(validationMessages.First).ToContainTextAsync("required");
}
[Fact]
public async Task SuccessfulSubmission()
{
await Page.GotoAsync("https://localhost:5001/contact");
await Page.FillAsync("#name", "Alice Smith");
await Page.FillAsync("#email", "alice@example.com");
await Page.FillAsync("#message", "This is a test message with enough content.");
await Page.ClickAsync("button[type=submit]");
// Wait for success message to appear
await Expect(Page.Locator(".alert-success")).ToBeVisibleAsync();
await Expect(Page.Locator(".alert-success"))
.ToContainTextAsync("Thank you for your message");
}
}Testing with WebApplicationFactory
For integration-style E2E tests that control the application:
public class BlazorTestServer : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Replace real services with fakes
services.AddSingleton<IEmailService, NoopEmailService>();
services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
});
builder.UseSetting("ASPNETCORE_ENVIRONMENT", "Testing");
}
}
public class CheckoutFlowTest : IClassFixture<BlazorTestServer>
{
private readonly BlazorTestServer _factory;
private readonly string _baseUrl;
public CheckoutFlowTest(BlazorTestServer factory)
{
_factory = factory;
_baseUrl = factory.Server.BaseAddress.ToString();
}
[Fact]
public async Task CompleteCheckoutFlow()
{
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync();
var page = await browser.NewPageAsync();
await page.GotoAsync($"{_baseUrl}products");
await page.ClickAsync(".product-card:first-child button:has-text('Add to Cart')");
await page.ClickAsync(".cart-icon");
await page.ClickAsync("button:has-text('Checkout')");
// Fill checkout form
await page.FillAsync("#card-number", "4242424242424242");
await page.FillAsync("#expiry", "12/26");
await page.FillAsync("#cvv", "123");
await page.ClickAsync("button:has-text('Place Order')");
await page.WaitForURLAsync("**/order-confirmation/**");
await Expect(page.Locator("h1")).ToContainTextAsync("Order Confirmed");
}
}Handling Blazor WebAssembly Loading
Blazor WASM requires waiting for the .NET runtime to load before components render:
public class BlazorWasmTest : PageTest
{
[Fact]
public async Task WasmAppLoadsAndRenders()
{
await Page.GotoAsync("https://localhost:5001");
// Wait for WASM to load — the loading screen disappears
await Page.WaitForSelectorAsync("#app:not(:has(#blazor-error-ui))",
new PageWaitForSelectorOptions { Timeout = 30000 });
// Now WASM is loaded
await Expect(Page.Locator("h1")).ToBeVisibleAsync();
}
}Running Playwright Tests in CI
# .github/workflows/e2e.yml
- name: Build
run: dotnet build
- name: Start app
run: dotnet run --project MyApp &
- name: Wait for app
run: |
timeout 60 bash -c 'until curl -sf http://localhost:5001; do sleep 1; done'
- name: Install Playwright browsers
run: pwsh MyApp.Tests/bin/Debug/net8.0/playwright.ps1 install --with-deps chromium
- name: Run E2E tests
run: dotnet test MyApp.Tests --filter "Category=E2E"Connecting Playwright to Continuous Monitoring
Playwright tests run in CI. But production regressions happen between deploys, not just during them. A spike in errors at 2 AM won't trigger your CI pipeline.
HelpMeTest runs behavioral tests against your live Blazor application continuously, alerting you when key user flows break in production — complementing your Playwright tests with 24/7 coverage.
Summary
Expect()methods are auto-retrying — prefer them over raw assertions for Blazor's async rendering- Save authentication state to
auth-state.jsonand reuse across tests — don't re-login in every test WebApplicationFactorygives you fake service injection for integration-style E2E tests- Blazor WASM needs an explicit wait for the runtime to load before assertions work
- Blazor Server navigation doesn't reload the page — wait for content changes, not page loads