โ† All guides

Mock a REST API for Go โ€” net/http, retries and go test against a real URL

Go has solid tools for faking HTTP inside one process: httptest.Server in the standard library spins up a real server on loopback, gock and httpmock intercept at the transport level, and go-vcr records real responses into replayable cassettes. 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 go test run:

The boring fix is a hosted mock API. Every snippet below was run verbatim against the live demo project before publishing (Go 1.24) โ€” you can paste and run them too. No dependencies except ยง5'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":"go-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 snippets below use the public demo project so they run with zero setup.

2. net/http quickstart โ€” real pagination headers

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

const BASE = "https://mockbird.mockbird.workers.dev/m/demo"

func main() {
	r, err := http.Get(BASE + "/products?page=1&limit=5&sortBy=price&order=desc")
	if err != nil {
		panic(err)
	}
	defer r.Body.Close()

	var products []map[string]any
	json.NewDecoder(r.Body).Decode(&products)
	total := r.Header.Get("X-Total-Count") // real header, not a fixture
	fmt.Printf("%s products total; page of %d\n", total, len(products))
	fmt.Println("first:", products[0]["name"], products[0]["price"])
}

Output when we ran it: 30 products total; page of 5. 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 decode into your structs

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
)

const BASE = "https://mockbird.mockbird.workers.dev/m/demo"

type Product struct {
	ID    int     `json:"id"`
	Name  string  `json:"name"`
	Price float64 `json:"price"`
}

func main() {
	body, _ := json.Marshal(Product{Name: "Test Widget", Price: 9.99})
	r, _ := http.Post(BASE+"/products", "application/json", bytes.NewReader(body))
	var created Product
	json.NewDecoder(r.Body).Decode(&created) // 201 + the stored record
	fmt.Println("created:", r.StatusCode, created.ID)

	r2, _ := http.Get(fmt.Sprintf("%s/products/%d", BASE, created.ID))
	var back Product
	json.NewDecoder(r2.Body).Decode(&back)
	fmt.Println("persisted:", back.Name == "Test Widget") // it persisted

	req, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/products/%d", BASE, created.ID), nil)
	r3, _ := http.DefaultClient.Do(req)
	fmt.Println("deleted:", r3.StatusCode) // 404s afterwards
}

When we ran it: created: 201 31 โ†’ persisted: true โ†’ deleted: 200. 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

package main

import (
	"fmt"
	"net/http"
	"os"
	"time"
)

const BASE = "https://mockbird.mockbird.workers.dev/m/demo"

func main() {
	// any status code on demand
	r, _ := http.Get(BASE + "/products?mock_status=503")
	fmt.Println("forced status:", r.StatusCode) // 503

	// a genuinely slow response vs your client timeout
	client := &http.Client{Timeout: 1 * time.Second}
	_, err := client.Get(BASE + "/products?mock_delay=3000")
	fmt.Println("timeout branch actually ran:", os.IsTimeout(err))
	// โ†’ true; err wraps "context deadline exceeded"
}

That second one matters: an interceptor answers instantly, so your http.Client{Timeout: ...} handling and os.IsTimeout branches are never truly exercised. Here the server really holds the response for 3 seconds and the deadline really fires โ€” when we ran it, err was the genuine context deadline exceeded (Client.Timeout exceeded while awaiting headers).

5. Exercise real retry logic with injected chaos

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

package main

import (
	"context"
	"fmt"
	"net/http"

	"github.com/hashicorp/go-retryablehttp"
)

func main() {
	client := retryablehttp.NewClient()
	client.RetryMax = 5
	client.Logger = nil
	// default policy retries 429 + 5xx; make the 429 case explicit anyway
	client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
		if err == nil && resp != nil && (resp.StatusCode == 429 || resp.StatusCode >= 500) {
			return true, nil
		}
		return retryablehttp.DefaultRetryPolicy(ctx, resp, err)
	}

	for i := 0; i < 5; i++ {
		r, err := client.Get("https://mockbird.mockbird.workers.dev/m/demo/products?mock_chaos=0.5&limit=1")
		if err != nil {
			fmt.Println(i, "gave up:", err)
			continue
		}
		fmt.Println(i, r.StatusCode) // all 200 โ€” the retries absorbed the injected failures
		r.Body.Close()
	}
}

When we ran it: five 200s โ€” the retries silently absorbed every injected failure. 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

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
)

const BASE = "https://mockbird.mockbird.workers.dev/m/demo"

func allRecords(resource string, limit int) ([]map[string]any, error) {
	var out []map[string]any
	total := -1
	for page := 1; total < 0 || len(out) < total; page++ {
		r, err := http.Get(fmt.Sprintf("%s/%s?page=%d&limit=%d", BASE, resource, page, limit))
		if err != nil {
			return nil, err
		}
		if r.StatusCode != 200 {
			return nil, fmt.Errorf("HTTP %d", r.StatusCode)
		}
		total, _ = strconv.Atoi(r.Header.Get("X-Total-Count"))
		var batch []map[string]any
		json.NewDecoder(r.Body).Decode(&batch)
		r.Body.Close()
		out = append(out, batch...)
	}
	return out, nil
}

func main() {
	all, err := allRecords("products", 10)
	fmt.Println(len(all), err) // 30 <nil> โ€” 3 real round-trips of 10
}

Three real round-trips of 10, 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. go test: pin each test to a frozen snapshot

Shared mutable test data is how suites go flaky. 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/users -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 pins the scenario it wants. No TestMain resets, no ordering constraints โ€” and t.Parallel() is safe because nobody mutates anything:

package goguide

import (
	"encoding/json"
	"net/http"
	"testing"
)

const testBASE = "https://mockbird.mockbird.workers.dev/m/abc123"

func get(t *testing.T, path, snapshot string) (*http.Response, []map[string]any) {
	t.Helper()
	req, _ := http.NewRequest(http.MethodGet, testBASE+path, nil)
	req.Header.Set("X-Mockbird-Snapshot", snapshot)
	r, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	var recs []map[string]any
	json.NewDecoder(r.Body).Decode(&recs)
	r.Body.Close()
	return r, recs
}

func TestUserListBaseline(t *testing.T) {
	t.Parallel()
	_, users := get(t, "/users", "baseline") // always 5, forever
	if len(users) != 5 {
		t.Fatalf("want 5 users, got %d", len(users))
	}
}

func TestEmptyState(t *testing.T) {
	t.Parallel()
	r, users := get(t, "/users", "empty")
	if len(users) != 0 || r.Header.Get("X-Total-Count") != "0" {
		t.Fatalf("want empty list, got %d", len(users))
	}
}

We ran exactly this file before publishing: both tests PASS, in parallel, ok 0.154s. 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 straight to oapi-codegen and you get a compile-time-safe client for the exact shapes your mock serves:

curl -o openapi.json https://mockbird.mockbird.workers.dev/m/demo/openapi.json
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest \
  -generate types,client -package demoapi -o demoapi.gen.go openapi.json

We ran that too (oapi-codegen v2.8.0): it generated a 229 KB typed client from the demo project's export and it compiles clean. Contract-first without writing the contract by hand โ€” and when the real backend ships, point the generated client's server argument 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:

httptest.Servergock / httpmockgo-vcrMockbird
What it isReal server on loopback (stdlib)Transport-level interceptorsRecords real responses into cassettes, replays themHosted mock API
Works offline / zero latencyโœ” (loopback)โœ”โœ” on replayโœ– (real network)
Handlers / stubs you must write & maintainEvery route, by handEvery stub, by handNone, but needs the real API once to recordNone โ€” CRUD, filters, search, pagination built in
Reachable by teammates, frontend, CI, other servicesโœ– one processโœ– one processโœ– one processโœ” one https URL
Real network timeouts / slow responses / rate limitsPartial โ€” you code the sleepSimulatedReplayed timings onlyโœ” mock_delay / mock_chaos / mock_ratelimit
State persists across processes/runsโœ–โœ–Cassette filesโœ” (+ snapshots)
Verify "was this called?"โœ” your handler saw itโœ” its superpowerโœ” cassette diffPartial: request inspector
Free tierfree (stdlib)free (library)free (library)free: 20 projects, 10k req/project/day

Use both: keep httptest for millisecond-fast unit tests, and point integration tests, scripts, teaching materials and not-yet-built-backend work at a hosted URL.

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, Java, Ruby, PHP, Rust, Elixir or C#/.NET 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.