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:
httptest you write every handler: CRUD, filtering, pagination, persistence. That's a small backend you now maintain.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.
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.
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.
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.
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).
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.
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.
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.
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.
The in-process tools are genuinely good, and for pure unit tests they're usually the better choice. The split:
| httptest.Server | gock / httpmock | go-vcr | Mockbird | |
|---|---|---|---|---|
| What it is | Real server on loopback (stdlib) | Transport-level interceptors | Records real responses into cassettes, replays them | Hosted mock API |
| Works offline / zero latency | โ (loopback) | โ | โ on replay | โ (real network) |
| Handlers / stubs you must write & maintain | Every route, by hand | Every stub, by hand | None, but needs the real API once to record | None โ 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 limits | Partial โ you code the sleep | Simulated | Replayed 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 diff | Partial: request inspector |
| Free tier | free (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.