Testing Blazor Server vs Blazor WebAssembly: Key Differences
Blazor comes in two hosting models that share the same component model but behave very differently at runtime. Blazor Server runs C# on the server and pushes DOM updates over a SignalR connection. Blazor WebAssembly runs .NET in the browser via WebAssembly. These differences fundamentally affect how you test each model.
Architecture Differences That Affect Testing
Understanding the architecture tells you what can go wrong and what to test:
Blazor Server:
- C# runs on the server; UI updates travel over WebSocket (SignalR)
- State lives on the server, per-connection
- Network latency affects UI responsiveness
- Connection drops are a failure mode
- Server memory increases with concurrent users
Blazor WebAssembly:
- .NET runtime runs in the browser
- State lives in the browser; no persistent server connection
- Initial load is heavier (downloads the .NET runtime)
- Works offline (with PWA patterns)
- HTTP requests go to a separate API
These differences change what you need to test.
Component Testing: bUnit Works for Both
bUnit is hosting-model-agnostic. The same test code works for Blazor Server and Blazor WASM components because bUnit tests the component logic and rendering, not the hosting infrastructure:
// This test works unchanged for both hosting models
public class ProductListTest : TestContext
{
[Fact]
public async Task RendersProductsFromService()
{
var mockProductService = new Mock<IProductService>();
mockProductService
.Setup(s => s.GetProductsAsync())
.ReturnsAsync(new List<Product>
{
new Product("p-1", "Widget", 9.99m),
new Product("p-2", "Gadget", 29.99m)
});
Services.AddSingleton(mockProductService.Object);
var cut = RenderComponent<ProductList>();
await cut.WaitForStateAsync(() => cut.FindAll(".product-item").Count == 2);
var items = cut.FindAll(".product-item");
Assert.Equal("Widget", items[0].Find(".product-name").TextContent);
Assert.Equal("Gadget", items[1].Find(".product-name").TextContent);
}
}Where the Differences Appear: Integration Testing
The hosting model matters for integration tests that involve the full request/response cycle.
Testing Blazor Server with WebApplicationFactory
Blazor Server is an ASP.NET Core application. WebApplicationFactory works naturally:
public class BlazorServerIntegrationTest : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public BlazorServerIntegrationTest(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task HomePageReturns200()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task HomePageRendersHtml()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/");
var html = await response.Content.ReadAsStringAsync();
// Blazor Server renders initial HTML on the server
Assert.Contains("Welcome to My App", html);
Assert.Contains("blazor.server.js", html); // SignalR client
}
}Testing Blazor WebAssembly: The API Layer
Blazor WASM apps typically have a separate ASP.NET Core API backend. Test the API separately:
// The WASM frontend calls this API
public class ProductApiTest : IClassFixture<WebApplicationFactory<ApiProgram>>
{
private readonly HttpClient _client;
public ProductApiTest(WebApplicationFactory<ApiProgram> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetProductsReturns200()
{
var response = await _client.GetAsync("/api/products");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var products = await response.Content.ReadFromJsonAsync<List<Product>>();
Assert.NotNull(products);
}
}Testing State Management Differences
Blazor Server: Per-Connection State
State in Blazor Server lives in scoped services (one per SignalR connection). Test scoped service behavior:
public class CartStateTest : TestContext
{
[Fact]
public void CartStateIsScopedPerUser()
{
// Each TestContext represents a separate Blazor Server circuit
using var context1 = new TestContext();
using var context2 = new TestContext();
var cart1State = new CartState();
var cart2State = new CartState();
context1.Services.AddScoped<CartState>(_ => cart1State);
context2.Services.AddScoped<CartState>(_ => cart2State);
var cart1 = context1.RenderComponent<ShoppingCart>();
var cart2 = context2.RenderComponent<ShoppingCart>();
// Add item to first cart
cart1State.AddItem(new CartItem("widget", 1));
cart1.Render();
// Second cart should be unaffected
cart2.Render();
Assert.Equal(1, cart1State.ItemCount);
Assert.Equal(0, cart2State.ItemCount);
}
}Blazor WebAssembly: Local State and Persistence
WASM state often uses localStorage or sessionStorage for persistence. Test this with JSInterop:
public class LocalStorageServiceTest : TestContext
{
[Fact]
public async Task PersistsUserPreferences()
{
JSInterop.Setup<string?>("localStorage.getItem", "user-prefs")
.SetResult(null); // First call — nothing stored
JSInterop.SetupVoid("localStorage.setItem", "user-prefs",
"{\"theme\":\"dark\",\"fontSize\":14}");
var cut = RenderComponent<UserPreferences>();
await cut.Find("select#theme").ChangeAsync(new ChangeEventArgs { Value = "dark" });
JSInterop.VerifyInvoke("localStorage.setItem");
}
}Testing Prerendering (Blazor Server)
Blazor Server prerenders HTML before the SignalR circuit connects. This means the page has two render phases:
- Static HTML rendered on the server
- Interactive rendering after SignalR connects
Test that prerendered HTML is correct:
public class PrerenderingTest : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public PrerenderingTest(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task ProductsPagePrerendersWithData()
{
// Simulate initial request before SignalR connects
var response = await _client.GetAsync("/products");
var html = await response.Content.ReadAsStringAsync();
// Verify products are in the initial HTML (for SEO)
Assert.Contains("class=\"product-item\"", html);
// Don't check for dynamic content that only loads after SignalR
}
[Fact]
public async Task AuthRequiredPageRedirectsToLogin()
{
var response = await _client.GetAsync("/dashboard",
new HttpRequestMessage(HttpMethod.Get, "/dashboard"));
// Server should redirect or render login content
Assert.True(
response.StatusCode == HttpStatusCode.Redirect ||
(await response.Content.ReadAsStringAsync()).Contains("login"),
"Unauthenticated access to protected page"
);
}
}Testing Offline Behavior (Blazor WASM PWA)
Blazor WASM PWA apps should handle offline scenarios. Test with Playwright's network control:
public class OfflineBehaviorTest : PageTest
{
[Fact]
public async Task ShowsOfflineMessageWhenNetworkUnavailable()
{
await Page.GotoAsync("https://localhost:5001");
// Wait for app to fully load
await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Go offline
await Page.Context.SetOfflineAsync(true);
await Page.ClickAsync("button:has-text('Refresh Data')");
// Should show offline indicator
await Expect(Page.Locator(".offline-banner")).ToBeVisibleAsync();
await Expect(Page.Locator(".offline-banner"))
.ToContainTextAsync("You are offline");
// Previously cached data should still show
await Expect(Page.Locator(".product-list")).ToBeVisibleAsync();
}
}Testing Error Boundaries
Both hosting models should handle errors gracefully. Test your <ErrorBoundary> components:
public class ErrorBoundaryTest : TestContext
{
[Fact]
public void RendersErrorUIWhenChildThrows()
{
var cut = RenderComponent<ErrorBoundaryWrapper>(p => p
.Add(x => x.ChildComponent, typeof(ThrowingComponent)));
// ErrorBoundary should catch the exception
Assert.Contains("Something went wrong", cut.Markup);
Assert.DoesNotContain("System.Exception", cut.Markup); // Don't leak stack traces
}
}HTTP Client Testing: WASM-Specific
Blazor WASM apps use HttpClient with base address configured. Test that client code handles API failures:
public class ProductApiClientTest : TestContext
{
[Fact]
public async Task HandlesApiServiceUnavailable()
{
var mockHttp = new MockHttpMessageHandler();
mockHttp.When("/api/products")
.Respond(HttpStatusCode.ServiceUnavailable);
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://api.example.com")
};
Services.AddSingleton(httpClient);
Services.AddSingleton<IProductApiClient, ProductApiClient>();
var cut = RenderComponent<ProductList>();
await cut.WaitForStateAsync(
() => cut.Find(".error-message") != null,
timeout: TimeSpan.FromSeconds(3)
);
Assert.Contains("Unable to load products", cut.Find(".error-message").TextContent);
}
}Choosing the Right Test for Each Scenario
| Scenario | Blazor Server | Blazor WASM |
|---|---|---|
| Component rendering logic | bUnit | bUnit |
| Service injection | bUnit + mock | bUnit + mock |
| Page prerendering | WebApplicationFactory | N/A |
| API responses | N/A | Mock HttpClient |
| User flows | Playwright | Playwright |
| Offline behavior | N/A | Playwright + setOffline |
| State isolation | Scoped service tests | LocalStorage JSInterop |
Production Monitoring for Both Models
Blazor Server has an additional failure mode beyond Blazor WASM: SignalR circuit failures. If the connection drops, users lose their session state. Testing in CI won't catch intermittent connection issues.
HelpMeTest monitors your Blazor application in production — verifying that the full user flow works correctly around the clock. Use it alongside bUnit and Playwright for complete coverage at every layer.
Summary
- bUnit is hosting-model-agnostic — the same component tests work for Server and WASM
- Blazor Server integration testing uses
WebApplicationFactoryagainst the full ASP.NET Core app - Blazor WASM backend testing focuses on the API layer — the .NET runtime in the browser isn't testable via WebApplicationFactory
- Prerendering tests use HTTP client requests to verify static HTML before SignalR connects
- State isolation tests for Blazor Server should use separate
TestContextinstances to simulate separate circuits - WASM offline testing requires Playwright's
setOfflinenetwork control