← 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. The two hosting models make it worse: 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. Whether your app is Server, WASM, or Auto mode, the browser-level behavior is what you're testing.

Blazor Server: every interaction is a server round trip

In Blazor Server there is no client-side logic. 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, which by construction means the root component has rendered:

<!-- 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 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 will always undercount. 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 because each scenario starts clean.

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 is C#, the developers are C# developers, and end-to-end testing hands them a Node toolchain and a pile of async browser idioms to learn and maintain. 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 you ever see it. Generation defaults to the same fixes covered above — web-first assertions instead of sleeps, real recorded scroll steps for a virtualized grid — and a spec that only caught the loading state doesn't survive that verification run. You review the resulting .spec.ts, 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 most of the flake evaporates. 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.

Early access

Ready for tests that write themselves?