โ† All guides

Mock a REST API for Swift โ€” URLSession, async/await and Swift Testing against a real URL

Swift's house style for faking HTTP is elegant: subclass URLProtocol, register it on a URLSessionConfiguration, and every request that session makes is answered by your code โ€” no server at all. Mocker and OHHTTPStubs wrap that trick in a nice API, and for millisecond-fast unit tests it's usually the right choice โ€” this guide's comparison table says so plainly.

But a URLProtocol stub only exists inside one process, and it falls short when you need a URL that's real:

The boring fix is a hosted mock API. Every snippet below uses only the standard library โ€” URLSession, Codable, Swift Testing; zero dependencies โ€” and every one was compiled and run verbatim against the live demo project before publishing (Swift 6.3, on Linux even: the FoundationNetworking guard makes each file compile unchanged on macOS, iOS playgrounds and server-side Swift).

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":"swift-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 โ€” each is a complete main.swift you can drop into a swift package init --type executable and swift run, or paste into an Xcode playground.

2. URLSession quickstart โ€” real pagination headers

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking   // URLSession on Linux; no-op on Apple platforms
#endif

let base = "https://mockbird.mockbird.workers.dev/m/demo"

struct Product: Codable {
    let id: Int
    let name: String
    let price: Double
}

let url = URL(string: "\(base)/products?page=1&limit=5&sortBy=price&order=desc")!
let (data, response) = try await URLSession.shared.data(from: url)

let http = response as! HTTPURLResponse
let total = http.value(forHTTPHeaderField: "X-Total-Count") ?? "?"  // real header, not a fixture

let products = try JSONDecoder().decode([Product].self, from: data)
print("\(total) products total; page of \(products.count)")
print("most expensive: \(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 Codable structs

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

let base = "https://mockbird.mockbird.workers.dev/m/demo"

struct Product: Codable {
    var id: Int?
    var name: String
    var price: Double
}

let session = URLSession.shared

// CREATE โ€” the record actually persists
var req = URLRequest(url: URL(string: "\(base)/products")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(Product(id: nil, name: "Swift Guide Widget", price: 19.99))

let (createData, createResp) = try await session.data(for: req)
let created = try JSONDecoder().decode(Product.self, from: createData)
print("POST โ†’ \((createResp as! HTTPURLResponse).statusCode), id \(created.id!)")

// READ IT BACK โ€” not response theater: a fresh GET returns the stored record
let (getData, _) = try await session.data(from: URL(string: "\(base)/products/\(created.id!)")!)
let fetched = try JSONDecoder().decode(Product.self, from: getData)
print("GET back โ†’ \(fetched.name) at $\(fetched.price)")

// CLEAN UP
var del = URLRequest(url: URL(string: "\(base)/products/\(created.id!)")!)
del.httpMethod = "DELETE"
let (_, delResp) = try await session.data(for: del)
print("DELETE โ†’ \((delResp as! HTTPURLResponse).statusCode)")

When we ran it: POST โ†’ 201, id 31 โ†’ GET back โ†’ Swift Guide Widget at $19.99 โ†’ DELETE โ†’ 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

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

let base = "https://mockbird.mockbird.workers.dev/m/demo"

// 1. Force any status code โ€” test your error mapping against a real 503
let (body, resp) = try await URLSession.shared.data(
    from: URL(string: "\(base)/products?mock_status=503")!)
print("forced: \((resp as! HTTPURLResponse).statusCode) \(String(data: body, encoding: .utf8)!)")

// 2. Hit a REAL client timeout: server delays 3s, session gives up after 1s
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 1
let impatient = URLSession(configuration: config)

do {
    _ = try await impatient.data(from: URL(string: "\(base)/products?mock_delay=3000")!)
    print("unexpected success")
} catch let err as URLError where err.code == .timedOut {
    print("timed out after 1s โ€” exactly the path your app must survive")
}

That second one matters: a URLProtocol stub answers in microseconds, so the branch where URLError.code == .timedOut fires after a real deadline is rarely exercised. Here the server genuinely holds the response for 3 seconds, the 1-second timeoutIntervalForRequest really trips, and when we ran it both lines printed exactly what the comments promise.

5. Exercise real retry logic with injected chaos

Retry code is subtle (which statuses, backoff curve, jitter, giving up). Point it at an endpoint where ~half the requests fail with a random 5xx/429 โ€” then watch it earn its keep:

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

let base = "https://mockbird.mockbird.workers.dev/m/demo"

struct ServerError: Error { let status: Int }

// Small generic retry helper โ€” exponential backoff with jitter
func withRetry<T>(attempts: Int = 6, baseDelay: Double = 0.25,
                  _ op: () async throws -> T) async throws -> T {
    var absorbed = 0
    for attempt in 0..<attempts {
        do {
            let out = try await op()
            if absorbed > 0 { print("  (\(absorbed) failure(s) absorbed)") }
            return out
        } catch {
            absorbed += 1
            if attempt == attempts - 1 { throw error }
            let delay = baseDelay * pow(2, Double(attempt)) * Double.random(in: 0.5...1.0)
            try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
        }
    }
    fatalError("unreachable")
}

struct Product: Codable { let id: Int; let name: String; let price: Double }

func fetchProducts() async throws -> [Product] {
    // mock_chaos=0.5 โ†’ the server RANDOMLY fails half the requests with real 5xx
    let (data, resp) = try await URLSession.shared.data(
        from: URL(string: "\(base)/products?limit=3&mock_chaos=0.5")!)
    let status = (resp as! HTTPURLResponse).statusCode
    guard status == 200 else { throw ServerError(status: status) }
    return try JSONDecoder().decode([Product].self, from: data)
}

for i in 1...5 {
    let page = try await withRetry { try await fetchProducts() }
    print("call \(i): 200 OK, \(page.count) records")
}

When we ran it: five 200 OKs, seven injected failures silently absorbed along the way. 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

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

let base = "https://mockbird.mockbird.workers.dev/m/demo"

struct Product: Codable { let id: Int; let name: String; let price: Double }

// Follows the real X-Total-Count header until every page is in โ€”
// the exact loop you'd ship, tested against a server that actually paginates.
func fetchAll(limit: Int = 8) async throws -> [Product] {
    var all: [Product] = []
    var page = 1
    while true {
        let url = URL(string: "\(base)/products?page=\(page)&limit=\(limit)")!
        let (data, resp) = try await URLSession.shared.data(from: url)
        let total = Int((resp as! HTTPURLResponse).value(forHTTPHeaderField: "X-Total-Count") ?? "0") ?? 0
        all += try JSONDecoder().decode([Product].self, from: data)
        print("page \(page): have \(all.count)/\(total)")
        if all.count >= total { return all }
        page += 1
    }
}

let all = try await fetchAll()
print("done: \(all.count) products, ids \(all.first!.id)โ€ฆ\(all.last!.id)")

Output: four real round-trips of 8, driven by the server's X-Total-Count, ending at done: 30 products, ids 1โ€ฆ30. There's also opaque cursor pagination (?cursor=) if the API you're mimicking paginates that way.

7. Swift Testing: pin each parallel @Test to a frozen snapshot

Swift Testing runs @Test functions in parallel by default โ€” which is exactly how shared mutable test data turns into flaky suites. 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 builds a URLSession whose every request is pinned to the scenario it wants โ€” no fixtures, no ordering constraints, and parallelism is safe because nobody mutates anything:

import Foundation
import Testing
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

let base = "https://mockbird.mockbird.workers.dev/m/abc123"

struct User: Codable { let id: Int; let firstName: String; let email: String }

// One session per scenario: every request it makes is answered read-only
// from that frozen snapshot โ€” live data untouched, no restore races.
func session(pinnedTo snapshot: String) -> URLSession {
    let config = URLSessionConfiguration.ephemeral
    config.httpAdditionalHeaders = ["X-Mockbird-Snapshot": snapshot]
    return URLSession(configuration: config)
}

// Swift Testing runs these IN PARALLEL by default โ€” safe, because each
// test reads its own frozen snapshot instead of mutating shared state.
@Test func baselineHasFiveUsers() async throws {
    let (data, _) = try await session(pinnedTo: "baseline")
        .data(from: URL(string: "\(base)/users")!)
    let users = try JSONDecoder().decode([User].self, from: data)
    #expect(users.count == 5)
    #expect(users.allSatisfy { $0.email.contains("@") })
}

@Test func emptyStateRendersZeroUsers() async throws {
    let (data, _) = try await session(pinnedTo: "empty")
        .data(from: URL(string: "\(base)/users")!)
    let users = try JSONDecoder().decode([User].self, from: data)
    #expect(users.isEmpty)
}

We ran exactly this file before publishing: โœ” Test run with 2 tests in 0 suites passed after 0.183 seconds โ€” in parallel. 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 with Apple's swift-openapi-generator

Every project serves a live OpenAPI 3.0 document at /m/<project>/openapi.json that always matches the current schema โ€” including an operationId on every operation (listProducts, createProduct, โ€ฆ), which is exactly what Apple's swift-openapi-generator turns into method names. Package it as a build plugin:

// swift-tools-version:6.0
import PackageDescription

let package = Package(
    name: "demo-client",
    platforms: [.macOS(.v10_15)],
    dependencies: [
        .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.0.0"),
        .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"),
        .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"),
    ],
    targets: [
        .executableTarget(
            name: "DemoClient",
            dependencies: [
                .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
                .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
            ],
            plugins: [.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")]
        )
    ]
)

Drop the mock's spec and a two-line config next to your target's sources:

curl -o Sources/DemoClient/openapi.json https://mockbird.mockbird.workers.dev/m/demo/openapi.json
printf 'generate:\n  - types\n  - client\n' > Sources/DemoClient/openapi-generator-config.yaml

And your app code gets a fully typed client for the exact shapes the mock serves:

import Foundation
import OpenAPIRuntime
import OpenAPIURLSession

let client = Client(
    serverURL: URL(string: "https://mockbird.mockbird.workers.dev")!,
    transport: URLSessionTransport()
)

let resp = try await client.listProducts(query: .init(limit: 3, sortBy: "price", order: .desc))
let products = try resp.ok.body.json
for p in products {
    print("\(p.name ?? "?") โ€” $\(p.price ?? 0)")
}

We ran that too: the plugin generated Types.swift and Client.swift at build time, and the program printed the three most expensive demo products. Contract-first without writing the contract by hand โ€” when the real backend ships, change serverURL and nothing else. (We also tried the multi-language openapi-generator's swift6 target on the same spec: its output assumes Apple-only frameworks and didn't build on our Linux box, so if you need Linux, Apple's generator is the one that worked everywhere we tried.) 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 approach is genuinely good โ€” and on Apple platforms it intercepts the real production URL transparently, which a hosted mock can't do. For pure unit tests it's usually the better choice. The split:

DIY URLProtocol stubMockerOHHTTPStubsMockbird
What it isYour own URLProtocol subclassURLProtocol-based mocking lib (WeTransfer)URLProtocol-based stubbing libHosted mock API
Works offline / zero latencyโœ”โœ”โœ”โœ– (real network)
Intercepts your real production URLโœ” โ€” the superpowerโœ”โœ”โœ– (you point a base URL at it)
Stubs you must write & maintainEverything, by handEvery Mock, by handEvery stub, by handNone โ€” CRUD, filters, search, pagination built in
Reachable by simulator + device + SwiftUI previews + teammates + CIโœ– one processโœ– one processโœ– one processโœ” one https URL
State persists across runsโœ–โœ–โœ–โœ” (+ snapshots)
Real timeouts / slow responses / rate limitsDIYPartial (delay)โœ” responseTimeโœ” mock_delay / mock_chaos / mock_ratelimit
Maintenanceyoursactivelast release 2020actively developed
Free tierfree (your code)free (lib)free (lib)free: 20 projects, 10k req/project/day

Use both: keep a URLProtocol stub (or Mocker) for millisecond-fast unit tests that intercept real URLs, and point integration tests, SwiftUI previews, device builds, 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, C#/.NET, Rust or Elixir or Bun, Deno 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.