← All guides

Mock a REST API for Blazor β€” GetFromJsonAsync, every UI state, and bUnit on a real wire

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:

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.

1. Try it in 10 seconds (no signup)

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.

⚑ Want your own instead of the shared demo? This link creates a live, seeded e-commerce backend in the dashboard β€” one click, no signup. Or one curl: curl -X POST https://mockbird.mockbird.workers.dev/api/projects -H "content-type: application/json" -d '{"preset":"ecommerce"}'

2. Wire up HttpClient in Program.cs

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.

3. A page with all four UI branches

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

4. See every UI state without touching the component

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:

BranchURLWhat happens
loadingproducts?limit=10&mock_delay=20002s of injected latency β€” your skeleton is on screen long enough to actually style it
errorproducts?limit=10&mock_status=500real 500 over the wire β†’ HttpRequestException β†’ error branch
emptyproducts?category=no-such-categoryfilter matches nothing β†’ [] β†’ empty branch
flakyproducts?mock_seq=503,503,200deterministic 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).

5. bUnit tests that exercise the real fetch path

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.

6. Pagination with X-Total-Count

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.

7. Writes are real

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.

8. Login flows without an auth server

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.

9. Honest comparison: the .NET-native options

ToolWhat it isPick it when
bUnit + mocked handler (e.g. RichardSzalay.MockHttp)canned HttpClient responses in-processpure unit tests that must run offline and in microseconds β€” no wire, no serializer surprises caught
WireMock.Netreal HTTP server started inside your test processintegration tests in CI with no network egress; you write C# stub setup per scenario
template sample-data/*.jsonstatic file in wwwroota five-minute prototype β€” no POST, no pagination, no errors, no shared URL
real backend on localhostthe actual APIit exists, it's cheap to run, and you don't need failure drills
Mockbirdhosted mock with CORS, CRUD, auth and failure injectionthe 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

10. Useful extras

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.