Real browser testing for Blazor apps. Every render mode. Zero app changes.
Add a NuGet package, write a test, and run it against Chromium, Firefox, or WebKit — your Blazor app launches automatically, Playwright drives the browser, and traces are captured on failure. Works with SSR, Interactive Server, WebAssembly, and Auto render modes out of the box.
[Fact]
public async Task Counter_IncrementsOnClick()
{
await using var ctx = await this.NewTracedContextAsync(_server);
var page = await ctx.NewPageAsync();
await page.GotoAsync($"{_server.TestUrl}/counter");
await page.WaitForInteractiveAsync("button.btn-primary");
await page.GetByRole(AriaRole.Button, new() { Name = "Click me" }).ClickAsync();
await Expect(page.Locator("p[role='status']")).ToHaveTextAsync("Current count: 1");
}Testing Blazor apps end-to-end requires a real browser, a running server, and a lot of plumbing: process management, readiness detection, proxy routing, interactivity awareness, tracing, and service overrides across process boundaries. This library handles all of it so you can focus on writing tests.
What you get:
- Your app launches as a real process — no test host, no mocking the runtime
- Playwright drives a real browser — Chromium, Firefox, or WebKit
WaitForInteractiveAsyncknows when Blazor components are actually interactive- Replace DI services across process boundaries with compile-time validation
- Test against published output — the same artifacts you ship to production
- Traces and videos captured automatically, saved on failure, discarded on pass
- Works in CI with a three-browser matrix out of the box
dotnet add package Blazor.E2E.Testing<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Blazor.E2E.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Microsoft.Playwright.Xunit.v3" Version="1.58.0" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<!-- Mark the app you want to test -->
<ItemGroup>
<ProjectReference Include="../MyApp/MyApp.csproj">
<E2EApp>true</E2EApp>
</ProjectReference>
</ItemGroup>
</Project>// Fixtures/E2ETestAssembly.cs
public class E2ETestAssembly;
// Fixtures/E2ECollection.cs
[CollectionDefinition(nameof(E2ECollection))]
public class E2ECollection : ICollectionFixture<ServerFixture<E2ETestAssembly>>;dotnet build
pwsh bin/Debug/net10.0/playwright.ps1 install --with-deps chromium firefox webkit[Collection(nameof(E2ECollection))]
public class HomePageTests : BrowserTest
{
private readonly ServerFixture<E2ETestAssembly> _fixture;
private ServerInstance _server = null!;
public HomePageTests(ServerFixture<E2ETestAssembly> fixture)
=> _fixture = fixture;
public override async ValueTask InitializeAsync()
{
await base.InitializeAsync();
_server = await _fixture.StartServerAsync<App>();
}
[Fact]
public async Task HomePage_DisplaysTitle()
{
await using var ctx = await this.NewTracedContextAsync(_server);
var page = await ctx.NewPageAsync();
await page.GotoAsync(_server.TestUrl);
await Expect(page).ToHaveTitleAsync("Home");
}
}dotnet test # Chromium (default)
dotnet test -- Playwright.BrowserName=firefox # Firefox
dotnet test -- Playwright.BrowserName=webkit # WebKit| Feature | What it does | Sample |
|---|---|---|
| Every render mode | SSR, Interactive Server, WebAssembly, and Auto — same test API | RenderModesApp |
| Zero app changes | Apps launch as external processes; no test-specific startup code | MinimalApp |
| Service overrides | Replace DI services across process boundaries with compile-time validation | ServiceOverridesApp |
| Interactivity detection | WaitForInteractiveAsync knows when Blazor has attached event handlers |
All samples |
| Streaming & loading states | TestLockClient holds server data so you can assert loading UI |
AsyncStateApp |
| Prerendering verification | ResourceLock holds scripts so you can assert SSR content before Blazor boots |
AsyncStateApp |
| Enhanced navigation | Detect Blazor's enhancedload DOM patching events |
AsyncStateApp |
| Always-on tracing | Playwright traces saved on failure, discarded on pass — zero config | ObservabilityApp |
| Multi-browser CI | Chromium, Firefox, and WebKit with a ready-made matrix strategy | All samples |
| Published app testing | Test against the same published output you ship to production | PublishedApp |
Blazor components render via SSR first, then become interactive after the runtime connects. These helpers wait for the right moment:
// Wait for Blazor's runtime to load
await page.WaitForBlazorAsync();
// Wait for event handlers on a specific element (works across all render modes)
await page.WaitForInteractiveAsync("button.btn-primary");
// Wait for enhanced navigation to complete
await page.WaitForEnhancedNavigationAsync(async () =>
{
await page.GetByRole(AriaRole.Link, new() { Name = "Counter" }).ClickAsync();
});Replace DI services in the app from your test — validated at compile time by a Roslyn analyzer:
class TestOverrides
{
public static void FakeWeather(IServiceCollection services)
{
services.AddSingleton<IWeatherService, FakeWeatherService>();
}
}
_server = await _fixture.StartServerAsync<App>(options =>
{
options.ConfigureServices<TestOverrides>(nameof(TestOverrides.FakeWeather));
});The analyzer catches mistakes before you run:
- E2E001 — Method not found or wrong signature
- E2E002 — Non-constant method name
- E2E003 — Method must be static
Different override configurations launch separate app instances automatically.
Test streaming rendering deterministically by holding server-side data with TestLockClient:
[Fact]
public async Task WeatherPage_ShowsLoadingThenData()
{
var context = await NewContext(new BrowserNewContextOptions().WithServerRouting(_server));
var locks = await TestLockClient.CreateAsync(_server, context);
var page = await context.NewPageAsync();
var navigationTask = page.GotoAsync($"{_server.TestUrl}/weather",
new() { WaitUntil = WaitUntilState.Load });
await using (locks.Lock("weather-data"))
{
// Data is held — assert loading state
await Expect(page.Locator("p em", new() { HasText = "Loading..." })).ToBeVisibleAsync();
await Expect(page.Locator("table.table")).Not.ToBeVisibleAsync();
}
// Lock disposed — data flows, streaming completes
await navigationTask;
await Expect(page.Locator("table.table")).ToBeVisibleAsync();
}The app-side service calls TestLockProvider.WaitOn(key) to block until the test releases the lock. See the AsyncStateApp sample for the full pattern.
Verify SSR content before Blazor boots by holding the Blazor script with ResourceLock:
[Fact]
public async Task Prerendered_Content_VisibleBeforeBlazorBoots()
{
var page = await context.NewPageAsync();
await using var blazorScript = await ResourceLock.CreateAsync(
page, new Regex(@"blazor\.web.*\.js"));
await page.GotoAsync(url, new() { WaitUntil = WaitUntilState.Commit });
await blazorScript.WaitForRequestAsync();
// Blazor hasn't started — only SSR HTML is present
await Expect(page.Locator("h1")).ToHaveTextAsync("Counter");
// Release — Blazor boots and components become interactive
await blazorScript.ReleaseAsync();
await page.WaitForInteractiveAsync("button.btn-primary");
}The recommended API handles routing, tracing, and artifact retention in one call:
await using var ctx = await this.NewTracedContextAsync(_server);
var page = await ctx.NewPageAsync();For custom control, use the lower-level API:
var context = await NewContext(
new BrowserNewContextOptions()
.WithServerRouting(_server)
.WithArtifacts(artifactDir));
await using var tracing = await context.TraceAsync(artifactDir);Artifacts saved to test-artifacts/<TestDisplayName>/:
trace.zip— Playwright trace (saved on failure)video-*.webm— Video (whenPLAYWRIGHT_RECORD_VIDEO=1)
View traces with:
pwsh bin/Debug/net10.0/playwright.ps1 show-trace test-artifacts/MyTest/trace.zipThe CI workflow runs all tests against three browsers in parallel:
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]Each browser runs in a separate job with per-browser caching and artifact upload.
graph TD
subgraph test["Test Process"]
XT["xUnit V3"] --> PW["Playwright"]
PW --> SF["ServerFixture<br/>(YARP Proxy)"]
end
SF -->|"launch + route by<br/>X-Test-Backend header"| app1
SF -->|"launch + route by<br/>X-Test-Backend header"| app2
app1 -.->|"POST /_ready"| SF
app2 -.->|"POST /_ready"| SF
subgraph app1["App Process (default)"]
K1["Kestrel"]
INJ1["Injected Infrastructure"]
end
subgraph app2["App Process (overrides)"]
K2["Kestrel"]
INJ2["Injected Infrastructure"]
end
For details, see docs/architecture.md.
| Variable | Default | Description |
|---|---|---|
PLAYWRIGHT_RECORD_VIDEO |
(unset) | Set to 1 to record video. Saved on failure, discarded on pass. |
| Property | Default | Description |
|---|---|---|
UsePublishedApp |
false |
Publish apps at build time and test against the published output. |
CompressionEnabled |
false |
Auto-set by the library. Workaround for .NET 10 SDK compression build bug. |
| Property | Default | Description |
|---|---|---|
ReadinessTimeoutMs |
60000 |
Max wait (ms) for the app to signal readiness. |
EnvironmentVariables |
{} |
Additional env vars for the app process. |
Playwright browser not installed
Error: Executable doesn't exist at /path/to/chromium
pwsh bin/Debug/net10.0/playwright.ps1 install --with-deps chromium firefox webkitApp readiness timeout
TimeoutException: App 'MyApp' did not signal readiness within 60000ms.
Check console output (prefixed [AppName:Id]). For slow CI, increase the timeout:
_server = await _fixture.StartServerAsync<App>(options =>
{
options.ReadinessTimeoutMs = 120_000;
});Button click does nothing (interactivity delay)
Blazor components render via SSR immediately but become interactive only after the runtime connects. Wait for interactivity:
await page.WaitForInteractiveAsync("button.btn-primary");
await page.GetByRole(AriaRole.Button, new() { Name = "Click me" }).ClickAsync();.NET 10 SDK compression build failure
error MSB4018: "ApplyCompressionNegotiation" task failed
The library's MSBuild props handle this automatically. If importing manually, ensure props are imported before any <PropertyGroup> that might re-enable compression.
- .NET 10 SDK or later
- xUnit V3 (3.2.2+)
- Microsoft.Playwright.Xunit.v3 (1.58.0+)
MIT