โ† All guides

Mock a REST API for C# / .NET โ€” HttpClient, Polly retries and xUnit against a real URL

.NET has excellent tools for faking HTTP inside one process: WireMock.Net spins up a stub server in your test host, MockHttpMessageHandler fakes responses at the HttpMessageHandler layer without any network at all, and Moq can stub your own typed client interface away entirely. For fast unit tests they're usually the right choice โ€” this guide's comparison table says so plainly.

But an in-process fake can't help when you need a URL that outlives one test run:

The boring fix is a hosted mock API. Every snippet below was run verbatim against the live demo project before publishing (.NET 8, Polly 8.7, xUnit 2, NSwag 14.7) โ€” you can paste and run them too. Plain HttpClient + System.Text.Json from the BCL; no NuGet packages are needed at all until ยง5the only NuGet package before ยง5 is nothing at all#39;s retry library.

1. Get an API (10 seconds, no signup)

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"dotnet-demo"}'
# โ†’ {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123", ...}

curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123/resources \
  -H 'content-type: application/json' -H 'x-admin-key: YOUR_KEY' \
  -d '{"name":"users","template":"users","seed":50}'

That's 50 realistic users (names, emails, avatars, cities) with full CRUD, filtering, sorting, search and pagination. The create response also includes a dashboard link that opens the project in a web UI โ€” handy when you're done with the terminal. The snippets below use the public demo project so they run with zero setup: dotnet new console, paste, dotnet run.

2. HttpClient quickstart โ€” real pagination headers

// Program.cs โ€” .NET 8, no NuGet packages needed
using System.Net.Http.Json;
using System.Text.Json;

var baseUrl = "https://mockbird.mockbird.workers.dev/m/demo"; // or your own project
using var http = new HttpClient();

// Page 1, five records per page โ€” real query params, real headers
var res = await http.GetAsync($"{baseUrl}/products?_page=1&_limit=5");
res.EnsureSuccessStatusCode();

var total = res.Headers.GetValues("X-Total-Count").First();
var products = await res.Content.ReadFromJsonAsync<List<JsonElement>>();

Console.WriteLine($"{total} total, got {products!.Count} on this page:");
foreach (var p in products)
    Console.WriteLine($"  #{p.GetProperty("id")} {p.GetProperty("name")} โ€” {p.GetProperty("price")}");

Output when we ran it: 30 total, got 5 on this page. Exact-match filters (?category=tools), range operators (?price_gte=100), substring match (?name_like=river) and full-text ?q= all compose โ€” see the parameter reference.

3. Writes are real โ€” and bind to your records

// Program.cs โ€” writes are real: POST persists, GET reads it back, DELETE removes it
using System.Net.Http.Json;
using System.Text.Json.Serialization;

var baseUrl = "https://mockbird.mockbird.workers.dev/m/demo";
using var http = new HttpClient();

var create = await http.PostAsJsonAsync($"{baseUrl}/products",
    new Product(0, "Mechanical Keyboard", 129.99));
Console.WriteLine($"POST โ†’ {(int)create.StatusCode}");            // 201

var created = await create.Content.ReadFromJsonAsync<Product>();
Console.WriteLine($"created id {created!.Id}");

// It's actually there โ€” a second, separate GET sees it
var back = await http.GetFromJsonAsync<Product>($"{baseUrl}/products/{created.Id}");
Console.WriteLine($"GET back: {back!.Name} at {back.Price}");

// Clean up
var del = await http.DeleteAsync($"{baseUrl}/products/{created.Id}");
Console.WriteLine($"DELETE โ†’ {(int)del.StatusCode}");             // 200

record Product(
    [property: JsonPropertyName("id")] int Id,
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("price")] double Price);

When we ran it: POST โ†’ 201, created id 31, GET back: Mechanical Keyboard at 129.99, DELETE โ†’ 200. System.Text.Json binds straight into a C# record, and PUT/PATCH/DELETE behave like a real backend. (This is the part JSONPlaceholder, FakeStoreAPI and DummyJSON fake โ€” details with receipts in their respective guides.) Add ?mock_validate=1 to get real 422s with per-field errors when the body doesn't match the schema.

4. Sad paths: force any status, hit a real client timeout

// Program.cs โ€” force any status; hit a real client timeout, not a mocked one
var baseUrl = "https://mockbird.mockbird.workers.dev/m/demo";
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };

// 1. Any endpoint can return any status on demand
var res = await http.GetAsync($"{baseUrl}/products?mock_status=503");
Console.WriteLine($"forced status: {(int)res.StatusCode}");        // 503

// 2. The server really holds the response for 3s โ€” your 2s Timeout actually fires
try
{
    await http.GetAsync($"{baseUrl}/products?mock_delay=3000");
    Console.WriteLine("no timeout?!");
}
catch (TaskCanceledException)
{
    Console.WriteLine("real timeout: TaskCanceledException after 2s");
}

That second one matters: a stubbed handler answers instantly, so your Timeout handling is never truly exercised. Here the server really holds the response for 3 seconds and the client's 2-second HttpClient.Timeout really fires โ€” when we ran it, the catch block caught a genuine TaskCanceledException.

5. Exercise real retry logic with injected chaos

Retry code is subtle (which statuses, backoff curve, giving up). Point a Polly v8 resilience pipeline at an endpoint where ~half the requests fail with a random 5xx/429 and watch it earn its keep:

// Program.cs โ€” exercise REAL retry logic against injected chaos
// dotnet add package Polly.Core   (Polly v8)
using Polly;
using Polly.Retry;

var baseUrl = "https://mockbird.mockbird.workers.dev/m/demo";
using var http = new HttpClient();

int attempts = 0;
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .HandleResult(r => (int)r.StatusCode >= 500 || (int)r.StatusCode == 429),
        MaxRetryAttempts = 5,
        Delay = TimeSpan.FromMilliseconds(200),
        BackoffType = DelayBackoffType.Exponential,
        OnRetry = _ => { attempts++; return default; }
    })
    .Build();

// mock_chaos=0.5 โ†’ ~half of all requests fail with a random 500/502/503/504/429.
// Polly absorbs them. Five green results, and you watched real backoff happen.
for (int i = 1; i <= 5; i++)
{
    var res = await pipeline.ExecuteAsync(async ct =>
        await http.GetAsync($"{baseUrl}/products?mock_chaos=0.5&_limit=1", ct));
    Console.WriteLine($"request {i}: {(int)res.StatusCode}");
}
Console.WriteLine($"retries Polly had to absorb: {attempts}");

When we ran it: five 200s, and retries Polly had to absorb: 4 โ€” the injected failures were real, and the pipeline silently absorbed every one. mock_chaos=0.5 fails that fraction of requests with a random status from {500, 502, 503, 504, 429}; injected failures carry an x-mockbird-chaos: injected header, and chaos-failed writes are not applied โ€” so it's safe to point retrying write logic at it too. For deterministic 429 testing (fixed window, real Retry-After and x-ratelimit-* headers), use ?mock_ratelimit=N.

6. A pagination helper you can actually test

// Program.cs โ€” a pagination helper you can actually assert against
using System.Net.Http.Json;
using System.Text.Json;

var baseUrl = "https://mockbird.mockbird.workers.dev/m/demo";
using var http = new HttpClient();

var all = await FetchAll(http, $"{baseUrl}/products", pageSize: 12);
Console.WriteLine($"fetched {all.Count} records across pages");

static async Task<List<JsonElement>> FetchAll(HttpClient http, string url, int pageSize)
{
    var all = new List<JsonElement>();
    for (int page = 1; ; page++)
    {
        var res = await http.GetAsync($"{url}?_page={page}&_limit={pageSize}");
        res.EnsureSuccessStatusCode();
        var batch = await res.Content.ReadFromJsonAsync<List<JsonElement>>();
        all.AddRange(batch!);
        var total = int.Parse(res.Headers.GetValues("X-Total-Count").First());
        if (all.Count >= total || batch!.Count == 0) return all;
    }
}

Three real round-trips (12 + 12 + 6), driven by the server's X-Total-Count. There's also opaque cursor pagination (?cursor=) if the API you're mimicking paginates that way.

7. xUnit: pin each parallel test class to a frozen snapshot

Shared mutable test data is how suites go flaky โ€” and xUnit runs test classes in parallel by default, which makes it worse. Mockbird lets you save named snapshots of a project's entire dataset, then answer any GET from a snapshot โ€” read-only, live data untouched โ€” by sending one header. Set the scenarios up once:

ADMIN='x-admin-key: YOUR_KEY'
P=https://mockbird.mockbird.workers.dev/api/projects/abc123

curl -X POST $P/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"baseline"}'
curl -X POST $P/resources/products -H "$ADMIN" -H 'content-type: application/json' -d '{"seed":0}'   # wipe
curl -X POST $P/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"empty"}'
curl -X POST $P/snapshots/baseline/restore -H "$ADMIN"    # put live data back

Then each test class pins the scenario it wants via DefaultRequestHeaders. No fixture resets, no ordering constraints โ€” xUnit's default parallelism is safe because nobody mutates anything:

// SnapshotPinningTests.cs โ€” xUnit runs these two classes IN PARALLEL.
// Each class pins every request to a different frozen snapshot of the SAME
// project, so they can't race: nobody mutates anything, both stay green.
using System.Net.Http.Json;
using System.Text.Json;
using Xunit;

public abstract class PinnedClient
{
    protected readonly HttpClient Http;
    protected const string BaseUrl = "https://mockbird.mockbird.workers.dev/m/abc123";

    protected PinnedClient(string snapshot)
    {
        Http = new HttpClient();
        // every request from this client reads from the named snapshot
        Http.DefaultRequestHeaders.Add("X-Mockbird-Snapshot", snapshot);
    }
}

public class BaselineTests : PinnedClient
{
    public BaselineTests() : base("baseline") { }

    [Fact]
    public async Task Baseline_has_thirty_products()
    {
        var res = await Http.GetAsync($"{BaseUrl}/products?_limit=1");
        Assert.Equal(200, (int)res.StatusCode);
        Assert.Equal("30", res.Headers.GetValues("X-Total-Count").First());
    }
}

public class EmptyStateTests : PinnedClient
{
    public EmptyStateTests() : base("empty") { }

    [Fact]
    public async Task Empty_snapshot_serves_an_empty_list()
    {
        var products = await Http.GetFromJsonAsync<List<JsonElement>>(
            $"{BaseUrl}/products");
        Assert.Empty(products!);   // exercise your real empty-state handling
    }
}

We ran exactly this file before publishing (dotnet test): Passed! Failed: 0, Passed: 2. Want to try pinning with zero setup? The public demo ships with built-in empty and edge-cases snapshots (100-char names, unicode, price 0, HTML-ish strings):

curl 'https://mockbird.mockbird.workers.dev/m/demo/products?mock_snapshot=empty'
# โ†’ []

The same trick scales per-scenario โ€” edge-cases, bug-repro-1234 โ€” see the deterministic test data guide.

8. Ship day: generate a typed client from the same mock

Every project serves a live OpenAPI 3.0 document at /m/<project>/openapi.json that always matches the current schema. Feed it to NSwag and you get a full typed client in one file:

dotnet tool install --global NSwag.ConsoleCore
nswag openapi2csclient \
  /input:https://mockbird.mockbird.workers.dev/m/demo/openapi.json \
  /classname:DemoApiClient /namespace:Demo.Api /output:DemoApiClient.cs

We ran that too (NSwag 14.7.1): it generated a 217 KB DemoApiClient.cs โ€” typed models and one method per operation โ€” and it compiles clean in a fresh classlib (add Newtonsoft.Json). Contract-first without writing the contract by hand; when the real backend ships, point the client's BaseUrl at it. There are also TypeScript types and a Postman collection for the rest of the team, generated from the same schema.

9. Honest comparison

The in-process tools are genuinely good, and for pure unit tests they're usually the better choice. The split:

WireMock.NetMockHttpMessageHandlerMoq stubsMockbird
What it isStub server in your test host, rich matchingFake at the HttpMessageHandler layer โ€” no networkMocks your own client interface โ€” no HTTP at allHosted mock API
Works offline / zero latencyโœ” (loopback)โœ”โœ”โœ– (real network)
Stubs you must write & maintainEvery stub, in their DSLEvery When(...).Respond(...), by handEvery Setup(...), by handNone โ€” CRUD, filters, search, pagination built in
Reachable by teammates, frontend, CI, other servicesโœ– one process (standalone mode helps, you host it)โœ– one processโœ– one processโœ” one https URL
Real network timeouts / slow responses / rate limitsโœ” fixed delays, fault injectionโœ– nothing crosses a socketโœ– nothing is realโœ” mock_delay / mock_chaos / mock_ratelimit
State persists across processes/runsโœ–โœ–โœ–โœ” (+ snapshots)
Verify "was this called?"โœ” request matching & loggingโœ” VerifyNoOutstandingExpectationโœ” Verify(...)Partial: request inspector
Free tierfree (library)free (library)free (library)free: 20 projects, 10k req/project/day

Use both: keep MockHttpMessageHandler or WireMock.Net for millisecond-fast unit tests with request verification, and point integration tests, scripts, teaching materials and not-yet-built-backend work at a hosted URL. (Evaluating WireMock Cloud? We compared it honestly here.)

Also mocking for a frontend? The same project feeds React, Vue, Angular, Next.js, SvelteKit, React Native, Flutter and Android at once โ€” one dataset for the whole team. Python, Node, Go, Ruby, PHP, Rust, Elixir or Java service in the mix too? Same drill. Docs โ†’
โšก Skip the terminal: this link creates a live, seeded backend (products, orders, customers, reviews) in the dashboard โ€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.