Skip to content

javiercn/blazor-e2e

Repository files navigation

Blazor.E2E.Testing

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");
}

Why

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
  • WaitForInteractiveAsync knows 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

Getting Started

1. Install

dotnet add package Blazor.E2E.Testing

2. Create a test project

<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>

3. Add fixture boilerplate

// Fixtures/E2ETestAssembly.cs
public class E2ETestAssembly;

// Fixtures/E2ECollection.cs
[CollectionDefinition(nameof(E2ECollection))]
public class E2ECollection : ICollectionFixture<ServerFixture<E2ETestAssembly>>;

4. Install browsers

dotnet build
pwsh bin/Debug/net10.0/playwright.ps1 install --with-deps chromium firefox webkit

5. Write a test

[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");
    }
}

6. Run

dotnet test                                  # Chromium (default)
dotnet test -- Playwright.BrowserName=firefox # Firefox
dotnet test -- Playwright.BrowserName=webkit  # WebKit

Features

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

API Guide

Interactivity Helpers

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();
});

Cross-Process Service Overrides

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.

Streaming & Loading State Control

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.

Prerendering Verification

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");
}

Tracing & Observability

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 (when PLAYWRIGHT_RECORD_VIDEO=1)

View traces with:

pwsh bin/Debug/net10.0/playwright.ps1 show-trace test-artifacts/MyTest/trace.zip

Multi-Browser CI

The 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.

Architecture

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
Loading

For details, see docs/architecture.md.

Configuration

Environment Variables

Variable Default Description
PLAYWRIGHT_RECORD_VIDEO (unset) Set to 1 to record video. Saved on failure, discarded on pass.

MSBuild Properties

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.

ServerStartOptions

Property Default Description
ReadinessTimeoutMs 60000 Max wait (ms) for the app to signal readiness.
EnvironmentVariables {} Additional env vars for the app process.

Troubleshooting

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 webkit
App 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.

Requirements

  • .NET 10 SDK or later
  • xUnit V3 (3.2.2+)
  • Microsoft.Playwright.Xunit.v3 (1.58.0+)

License

MIT

About

Blazor.E2E.Testing — Cross-render-mode E2E testing library for Blazor using Playwright, xUnit V3, and YARP

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors