You're building a Blazor WebAssembly front end before the backend exists (or without wanting to run it locally). The usual answers all hurt a little:
sample-data/*.json file β static: no pagination headers, no POST, and your error branch never runs;HttpClient with a mock handler β fine for unit tests, useless for actually clicking around your app in the browser;The boring fix is a hosted mock API with permissive CORS: a real https URL your WASM app can fetch from localhost:5000, from a deployed preview on GitHub Pages or Azure Static Web Apps, and from bUnit tests alike. Every C# snippet below was run verbatim with .NET 8 + bUnit 2.11 against the live endpoints on this page before publishing, and the component was loaded in a real browser to confirm the CORS story.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
30 seeded products with id, name, price, category, inStock, rating β plus orders, customers and reviews collections, full CRUD, filtering, sorting and pagination.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects -H "content-type: application/json" -d '{"preset":"ecommerce"}'Point the app's scoped HttpClient at the mock instead of builder.HostEnvironment.BaseAddress:
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri("https://mockbird.mockbird.workers.dev/m/demo/")
});
The trailing slash matters. .NET resolves relative URIs against the base RFC-style: with β¦/m/demo/, "products" becomes β¦/m/demo/products; with β¦/m/demo (no slash) the last segment is replaced and you silently fetch β¦/products β a 404 that looks like a CORS problem. This bites everyone once.
Pages/Products.razor β fetch typed records in OnInitializedAsync; GetFromJsonAsync uses web defaults, so camelCase JSON binds to PascalCase records with no attributes:
@page "/products"
@inject HttpClient Http
<h1>Products</h1>
@if (error is not null)
{
<p class="error">Something went wrong: @error</p>
}
else if (products is null)
{
<p class="loading">Loading productsβ¦</p>
}
else if (products.Length == 0)
{
<p class="empty">No products found.</p>
}
else
{
<ul>
@foreach (var p in products)
{
<li>@p.Name β $@p.Price (@p.Category)</li>
}
</ul>
}
@code {
private Product[]? products;
private string? error;
protected override async Task OnInitializedAsync()
{
try
{
products = await Http.GetFromJsonAsync<Product[]>("products?limit=10");
}
catch (HttpRequestException e)
{
error = $"HTTP {(int?)e.StatusCode}";
}
}
public record Product(int Id, string Name, decimal Price, string Category, bool InStock);
}
dotnet run, open /products, and the list renders from the live mock β cross-origin, no proxy, because every Mockbird endpoint sends Access-Control-Allow-Origin: * and exposes the pagination headers to browser code. (GetFromJsonAsync throws HttpRequestException with a populated StatusCode on any non-2xx, which is what feeds the error branch.)
Three of those four branches are normally hard to look at. On a mock they're just query params β append them to the component's URL string during development:
| Branch | URL | What happens |
|---|---|---|
| loading | products?limit=10&mock_delay=2000 | 2s of injected latency β your skeleton is on screen long enough to actually style it |
| error | products?limit=10&mock_status=500 | real 500 over the wire β HttpRequestException β error branch |
| empty | products?category=no-such-category | filter matches nothing β [] β empty branch |
| flaky | products?mock_seq=503,503,200 | deterministic fail-fail-succeed sequence for retry logic |
Nothing in your component changes β the states live in the request. See testing loading and error states for the full parameter list (chaos injection, jitter, rate-limit drills).
The standard bUnit recipe replaces HttpClient with a canned stub β quick, but your serializer options, URL building and error handling never run. Against a hosted mock you can hand the test a real HttpClient, and switch scenarios with a 10-line DelegatingHandler that appends the simulation params to whatever the component requests:
// ScenarioHandler.cs β the component under test never changes.
public class ScenarioHandler : DelegatingHandler
{
private readonly string _qs;
public ScenarioHandler(string qs) : base(new HttpClientHandler()) => _qs = qs;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage req, CancellationToken ct)
{
var u = req.RequestUri!;
req.RequestUri = new Uri(
u + (string.IsNullOrEmpty(u.Query) ? "?" : "&") + _qs);
return base.SendAsync(req, ct);
}
}
public class ProductsPageTests : BunitContext
{
const string Demo = "https://mockbird.mockbird.workers.dev/m/demo/";
static HttpClient Client(string? scenario = null) =>
new(scenario is null ? new HttpClientHandler() : new ScenarioHandler(scenario))
{ BaseAddress = new Uri(Demo) };
[Fact]
public void Shows_products_from_the_real_wire()
{
Services.AddScoped(sp => Client());
var cut = Render<Products>();
cut.WaitForAssertion(
() => Assert.Equal(10, cut.FindAll("li").Count),
TimeSpan.FromSeconds(10));
}
[Fact]
public void Shows_error_ui_when_the_api_returns_500()
{
Services.AddScoped(sp => Client("mock_status=500"));
var cut = Render<Products>();
cut.WaitForAssertion(
() => Assert.Contains("HTTP 500", cut.Find(".error").TextContent),
TimeSpan.FromSeconds(10));
}
[Fact]
public void Shows_loading_state_while_the_api_is_slow()
{
Services.AddScoped(sp => Client("mock_delay=2000"));
var cut = Render<Products>();
// 2s of injected latency holds the component in its loading branch.
Assert.Contains("Loading", cut.Find(".loading").TextContent);
cut.WaitForAssertion(
() => Assert.NotEmpty(cut.FindAll("li")),
TimeSpan.FromSeconds(10));
}
[Fact]
public void Shows_empty_state_for_a_filter_with_no_matches()
{
Services.AddScoped(sp => Client("category=no-such-category"));
var cut = Render<Products>();
cut.WaitForAssertion(
() => Assert.Contains("No products", cut.Find(".empty").TextContent),
TimeSpan.FromSeconds(10));
}
}
All four pass in about 4 seconds total (bUnit 2.x API: BunitContext / Render<T>(); on bUnit 1.x use TestContext / RenderComponent<T>()). For parallel test classes that mutate data, pin each class to a frozen dataset with new ScenarioHandler("mock_snapshot=baseline") on your own project β see deterministic test data.
GetFromJsonAsync hides response headers, so for a pager drop to GetAsync:
var res = await Http.GetAsync($"products?page={page}&limit=10");
var total = int.Parse(res.Headers.GetValues("X-Total-Count").First());
var items = await res.Content.ReadFromJsonAsync<Product[]>();
var pages = (int)Math.Ceiling(total / 10.0);
Verified against the demo: page=2&limit=10 returns items 11β20 and X-Total-Count: 30 β the header is CORS-exposed, so this works from WASM, not just from tests. Sorting and filtering compose: ?sortBy=price&order=desc, ?category=toys, ?price_gte=100, ?q=chair.
Unlike JSONPlaceholder or FakeStoreAPI, a POST here persists β your EditForm submit handler round-trips:
var res = await Http.PostAsJsonAsync("products",
new { name = "Blazor Guide Test", price = 9.99, category = "test", inStock = true });
var created = await res.Content.ReadFromJsonAsync<Product>();
var back = await Http.GetFromJsonAsync<Product>($"products/{created!.Id}");
// back.Name == "Blazor Guide Test" β it's really there. PUT/PATCH/DELETE work too.
Every project ships mock auth endpoints that issue real signed JWTs: POST auth/login with any email/password returns a token, and flipping the project to protected mode makes every endpoint demand it β so your AuthenticationStateProvider and 401-handling logic run against genuine behavior. Recipe with curl-level detail in the mock JWT auth guide; for Polly retry policies and generating a typed client from the project's openapi.json with NSwag, see the C# / .NET guide.
| Tool | What it is | Pick it when |
|---|---|---|
| bUnit + mocked handler (e.g. RichardSzalay.MockHttp) | canned HttpClient responses in-process | pure unit tests that must run offline and in microseconds β no wire, no serializer surprises caught |
| WireMock.Net | real HTTP server started inside your test process | integration tests in CI with no network egress; you write C# stub setup per scenario |
template sample-data/*.json | static file in wwwroot | a five-minute prototype β no POST, no pagination, no errors, no shared URL |
| real backend on localhost | the actual API | it exists, it's cheap to run, and you don't need failure drills |
| Mockbird | hosted mock with CORS, CRUD, auth and failure injection | the backend doesn't exist yet; deployed WASM previews need a reachable API; you want loading/error/empty states on demand with zero per-test server code |
curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H "content-type: application/json" -d '{"preset":"ecommerce"}'GET /m/YOUR_ID/db.json exports everything in json-server format β no lock-in.Related: mock APIs for C# / .NET, mock APIs for Unity, testing loading and error states, mock JWT auth, deterministic test data with snapshots, and free mock-API tools compared.
Verification: every C# snippet on this page was run verbatim on 22 Sep 2026 with .NET SDK 8.0 + bUnit 2.11.3 + xUnit (Linux) against production β all six tests (four bUnit renders, pagination, POST round-trip) passed in one run, and the Products.razor page was additionally loaded as real WebAssembly in a Chrome browser to confirm the cross-origin fetch renders with no proxy. The write-test record was deleted afterwards. Demo data reseeds daily, so names, prices and per-category counts will differ when you run it. If a snippet here doesn't work in your project, that's a bug: tell us.