← All posts
Guide

End-to-end testing Blazor apps, Server and WASM

Blazor lets you write a rich browser UI in C# and never touch a JavaScript test framework — until it's time to test it end to end, and the advice you find is all waitForTimeout and CSS selectors that break on the first render tweak. Two common execution modes have different timing: Blazor Server runs your components on the server and streams DOM diffs over a SignalR circuit, while Blazor WebAssembly downloads a .NET runtime and runs in the browser. They fail differently, and a suite that ignores the difference flakes on both.

This is a tour of the Blazor behaviors that produce false failures, with a Playwright pattern for each. Modern Blazor Web Apps can use render modes per component, including Server, WebAssembly and Auto. Confirm the mode of the surface you are testing.

Blazor Server: every interaction is a server round trip

In server-interactive Blazor, C# component event handling runs server-side; client JavaScript and interop can still exist. You click a button, the browser sends the event over the SignalR WebSocket, the component re-renders on the server, and a DOM diff comes back. That round trip is fast on localhost and not-so-fast over real network latency, a loaded server, or a reconnecting circuit. The gap between "I clicked" and "the DOM changed" is a variable you don't control.

The wrong fix is the one everyone reaches for first:

@* Razor: a counter that increments server-side *@
<button @onclick="Increment">Add item</button>
<p>Items: @count</p>
// Bad: bets that 500ms is enough for the round trip
await page.getByRole('button', { name: 'Add item' }).click();
await page.waitForTimeout(500);
await expect(page.getByText('Items: 1')).toBeVisible();

That sleep is simultaneously too long on a fast circuit and too short on a slow one. The fix is to wait for the condition — the updated DOM — and let the assertion retry until the diff lands:

// Better: retry until the server-rendered value arrives
await page.getByRole('button', { name: 'Add item' }).click();
await expect(page.getByText('Items: 1')).toBeVisible();

Playwright's web-first assertions poll until they pass or time out, which is exactly the shape of a SignalR round trip: you don't know when the diff arrives, only that it will. If a circuit is genuinely slow under load, raise the expect timeout for that project rather than sprinkling sleeps. It's the same discipline that kills most test flake in any server-round-trip UI; Blazor Server just makes every interaction one.

One Server-specific trap: because the whole UI is a stateful circuit, a test that leaves the previous scenario's state behind asserts against a dirty page. Start each scenario in a fresh browser context so the circuit and component tree start clean.

Blazor WASM: the boot moment is real

WebAssembly flips the problem. There's no circuit, but there is a genuine download-and-start moment: the browser fetches the .NET runtime and your assemblies before the app becomes interactive. Navigate straight into a test action and you're racing that boot — the button is still painted by the loading template, or not yet there at all, and your click lands on nothing.

Auto-waiting absorbs a lot of this. getByRole(...).click() waits for the element to exist, be visible, and be actionable before it acts, so a button that appears a beat late is usually handled without special code. Be honest about the limit, though: auto-waiting waits for the element, not for your app's initialization logic to finish wiring up. It's the same caveat as SSR hydration on the JavaScript side — the visible DOM can arrive before the thing behind it is ready.

So key on an app-specific signal that boot is done. Most WASM apps ship a loading indicator in index.html; wait for it to disappear, and assert an app-specific ready landmark; hidden alone can also mean the marker never existed:

<!-- index.html: the default loading UI -->
<div id="app">
    <div class="loading-screen">Loading...</div>
</div>
// Wait for the boot screen to clear, then interact
await page.goto('/');
await expect(page.getByText('Loading...')).toBeHidden();
await expect(page.getByRole('heading', { name: 'Orders' })).toBeVisible(); // app-ready marker
await page.getByRole('button', { name: 'New order' }).click();

If your app has no such marker, wait for the first real piece of interactive UI instead — a heading, a populated field, a nav item that only exists post-boot. Don't reach for waitForLoadState('networkidle'); a WASM app can keep connections open, and network idle doesn't mean runtime started.

Virtualize: the row you want isn't in the DOM

Blazor's Virtualize component is the .NET answer to rendering a ten-thousand-row grid without dying. It mounts only the rows currently in the viewport and swaps them as you scroll. Great for performance, a landmine for a test that assumes the row it wants is in the DOM.

<Virtualize Items="@orders" Context="order">
    <div class="order-row">@order.Reference — @order.Customer</div>
</Virtualize>

A naive getByText('Order 4200') times out even though the order exists — it's just not mounted. Two consequences follow: never assert on the count of rendered rows, since it reflects the viewport window, not the dataset; and to reach an off-screen row you have to scroll the container until the virtualizer mounts it, then act:

const list = page.getByTestId('order-list');
const target = page.getByText('Order 4200');

await expect(async () => {
  await list.evaluate((el) => el.scrollBy(0, 800));
  await expect(target).toBeVisible({ timeout: 500 });
}).toPass({ timeout: 10_000 });

await target.click();

toPass() retries the whole block, so each pass scrolls a little further and re-checks until the row appears. For "how many orders are there" assertions, read a count label or the data behind the grid — counting mounted DOM nodes does not establish the complete dataset count. With Server-hosted Virtualize, each scroll also triggers a round trip to fetch the next window, one more reason the retry-until-visible loop beats a fixed scroll-and-sleep.

ErrorBoundary and circuit-drop banners: the conditional dialog

Blazor has two failure surfaces a test needs to expect without depending on. An ErrorBoundary catches an unhandled exception in a component subtree and swaps in fallback content. In Blazor Server, when the circuit drops, the framework shows a reconnection UI — the default #components-reconnect-modal overlay — and tries to re-establish it.

Both belong to the conditional-dialog class: they might appear, and when they do they can quietly intercept the click you were about to make. On a flaky network the reconnect modal can flash up mid-scenario and swallow a pointer event, producing a failure that looks like your app broke when the circuit just hiccuped. Assert the landmark you actually expect, so a fallback surface fails loudly instead of silently passing:

// The reconnect overlay stealing this click surfaces as a clear failure
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toContainText('Saved');
await expect(page.locator('#components-reconnect-modal')).toBeHidden();

And when you want to test the failure path — that your ErrorBoundary shows the right message, or that reconnection recovers — arm the waiter before the trigger, because these banners can auto-dismiss the instant the circuit comes back. A fresh browser context per scenario matters here too: a recorded dismissal replays every run only when your isolation and setup establish that state.

Where bUnit stops and E2E begins

bUnit is excellent, and if you're not using it for Blazor component tests you're missing the fastest feedback loop you'll get. It renders a component in isolation, fires events, and asserts on the markup — all in-process, no browser. Use it heavily.

What it can't tell you is whether the whole thing works together: that the SignalR circuit carries your events, that auth and cookies flow through a real browser, that the WASM runtime boots and routes resolve, that the Virtualize grid scrolls under a real layout engine. bUnit runs a simulated renderer, not Chromium against your deployed app — and that integration is where the bugs your users hit actually live. The two aren't competitors; bUnit proves the component, E2E proves the system.

Testing a C# app without a JS test stack

Here's the part that stings for a Blazor team: the app may be C#, while TestVibe generates JavaScript specs. Hand-written Playwright also has .NET bindings if that fits your team. That's a real tax, and it's why a lot of Blazor E2E coverage never gets written.

TestVibe closes that gap by generating the Playwright specs from a plain-language description of the behavior — "add an order, scroll to it in the grid, open it, confirm the total" — then verifying each one by actually running it against your live app in an isolated cloud browser before generated status is reported; drafts may be visible earlier. Generation defaults to the same fixes covered above — web-first assertions instead of sleeps, real recorded scroll steps for a virtualized grid — but a weak assertion can still pass, so inspect the intended outcome. You review the resulting .spec.js, keep it, edit it, or regenerate — your team owns real Playwright code without hand-writing the async plumbing. It runs the same whether your components render over a circuit or in WASM, because it drives the browser, not the framework.

Blazor isn't harder to test end to end than any other stack — it's differently hard. Stop sleeping through server round trips, stop racing the WASM boot, stop assuming the virtualized row is in the DOM, and you reduce several timing and state-related failure modes. Anchor every assertion to what the user would actually see, and let the retries do the waiting.

Want Playwright coverage for your Blazor app without adopting a JS test stack? Get early access, or start with your first generated test.

Coming soon

Turn a described flow into a reviewable test.

Describe a flow in plain English. Generate a Playwright test, run it on your schedule, and review proposed fixes when your UI changes. Join the waitlist for early access.

  • Plain English in, real Playwright out
  • Runs in your CI, on your schedule
  • No credit card, unsubscribe anytime