โ† All guides

Mock API for Android โ€” Retrofit against a real URL (no 10.0.2.2, no cleartext config)

Mocking an API for an Android app usually means one of three things: OkHttp's MockWebServer inside a test, a hand-rolled fake behind a debug build flavor, or json-server on your laptop. The first two are genuinely good for unit tests โ€” the comparison table at the bottom says so plainly. The third is where Android makes you suffer:

The boring fix is a hosted mock with a real https URL: emulator, physical device, teammate and CI all hit the same address with zero network config. Every snippet below was run verbatim against the live demo project before publishing โ€” on JVM 17 with the exact same Retrofit 2.11 / OkHttp 4.12 / coroutines artifacts your Android app uses, so they paste straight into a ViewModel or repository.

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":"android-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":"products","template":"products","seed":30}'

That's 30 realistic products with full CRUD, filtering, sorting, search and pagination โ€” over https, so there is no usesCleartextTraffic, no security-config XML, nothing to strip before release. The create response includes a dashboard link that opens the project in a web UI. The snippets below use the public demo project so they run with zero setup.

Gradle deps used by this guide (you likely have all of them already):

implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")

2. Retrofit quickstart โ€” suspend functions, real pagination headers

import kotlinx.coroutines.runBlocking
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query

data class Product(
    val id: Int,
    val name: String,
    val price: Double,
    val category: String,
    val inStock: Boolean
)

interface ProductApi {
    @GET("products")
    suspend fun list(
        @Query("page") page: Int = 1,
        @Query("limit") limit: Int = 5
    ): Response<List<Product>>
}

val retrofit: Retrofit = Retrofit.Builder()
    .baseUrl("https://mockbird.mockbird.workers.dev/m/demo/")
    .addConverterFactory(GsonConverterFactory.create())
    .build()

fun main() { runBlocking {
    val api = retrofit.create(ProductApi::class.java)
    val res = api.list(page = 1, limit = 5)
    val total = res.headers()["X-Total-Count"]
    println("total=$total got=${res.body()?.size}")
    res.body()?.forEach { println("${it.id}  ${it.name}  $${it.price}  ${it.category}") }
} }
total=30 got=5
1  Friend Story Power Year  $442.39  home
2  Friend Day Place Life Point  $434.12  clothing
3  Question Window Service  $996.02  sports
4  Part Friend Case  $24.55  food
5  Life Water  $717.84  home

X-Total-Count is a real response header, so your paging logic (or Paging 3 PagingSource) computes total pages from the mock exactly as it will from production. Filters, search and sorting are query params too: ?category=clothing, ?q=water, ?sortBy=price&order=desc, ?price_gte=100 โ€” full list.

3. Writes are real โ€” and persist

import kotlinx.coroutines.runBlocking
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path

data class NewProduct(val name: String, val price: Double, val category: String, val inStock: Boolean)

interface ProductWriteApi {
    @POST("products")
    suspend fun create(@Body p: NewProduct): Response<Product>

    @GET("products/{id}")
    suspend fun get(@Path("id") id: Int): Response<Product>

    @DELETE("products/{id}")
    suspend fun delete(@Path("id") id: Int): Response<Unit>
}

fun main() { runBlocking {
    val api = retrofit.create(ProductWriteApi::class.java)

    val created = api.create(NewProduct("Kotlin Hoodie", 49.99, "clothing", true))
    println("POST -> ${created.code()} id=${created.body()?.id}")   // 201

    val id = created.body()!!.id
    val fetched = api.get(id)                                        // it persisted
    println("GET  -> ${fetched.code()} ${fetched.body()?.name} $${fetched.body()?.price}")

    println("DELETE -> ${api.delete(id).code()}")                    // clean up
} }
POST -> 201 id=31
GET  -> 200 Kotlin Hoodie $49.99
DELETE -> 200

The record you POST is really stored: pull-to-refresh sees it, another device sees it, a teammate's emulator sees it. That's the difference between a mock API and a mock response โ€” optimistic-update and cache-invalidation code paths behave like they will in production.

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

Error UI on Android is where mocking pays for itself โ€” you can't ask production to fail on cue, and hardcoded fakes never exercise the actual network stack.

import java.net.SocketTimeoutException
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.QueryMap

interface SadApi {
    @GET("products")
    suspend fun list(@QueryMap opts: Map<String, String> = emptyMap()): Response<List<Product>>
}

fun main() { runBlocking {
    // 1) Force any status code the server side could ever return
    val api = retrofit.create(SadApi::class.java)
    val res = api.list(mapOf("mock_status" to "503"))
    println("forced: ${res.code()} success=${res.isSuccessful} err=${res.errorBody()?.string()?.take(60)}")

    // 2) A real timeout: client allows 2s, server takes 3s
    val impatient = OkHttpClient.Builder()
        .readTimeout(2, TimeUnit.SECONDS)
        .build()
    val slowApi = Retrofit.Builder()
        .baseUrl("https://mockbird.mockbird.workers.dev/m/demo/")
        .client(impatient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(SadApi::class.java)
    try {
        slowApi.list(mapOf("mock_delay" to "3000"))
        println("no timeout?!")
    } catch (e: SocketTimeoutException) {
        println("timeout path exercised: ${e.javaClass.simpleName}")
    }
} }
forced: 503 success=false err={
  "error": "simulated 503 error (mock_status)"
}
timeout path exercised: SocketTimeoutException

Because the parameters ride on the URL, QA can reproduce any state by editing a query string โ€” no debug menu required. ?mock_status=401 for the session-expired flow, ?mock_delay=3000 to watch your loading skeleton, both at once for slow failures. More recipes.

5. Exercise your real retry interceptor with injected chaos

Most apps ship a retry/backoff Interceptor. Almost none of them are ever tested against a server that actually flakes. ?mock_chaos=0.5 makes half of all requests fail with a random 500/502/503/504/429 โ€” over the real network, with real latency:

import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response as OkResponse
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query

/** Retries 429/5xx up to [max] times with linear backoff โ€” the kind of
 *  interceptor most Android apps ship. Now you can actually test it. */
class RetryInterceptor(private val max: Int = 4) : Interceptor {
    var retries = 0                     // exposed so the demo can prove work happened
    override fun intercept(chain: Interceptor.Chain): OkResponse {
        var response = chain.proceed(chain.request())
        var attempt = 0
        while (attempt < max && (response.code == 429 || response.code >= 500)) {
            response.close()
            attempt++
            retries++
            Thread.sleep(250L * attempt)
            response = chain.proceed(chain.request())
        }
        return response
    }
}

interface ChaosApi {
    @GET("products")
    suspend fun list(
        @Query("mock_chaos") chaos: Double,
        @Query("limit") limit: Int = 3
    ): retrofit2.Response<List<Product>>
}

fun main() { runBlocking {
    val retrying = RetryInterceptor()
    val client = OkHttpClient.Builder().addInterceptor(retrying).build()
    val api = Retrofit.Builder()
        .baseUrl("https://mockbird.mockbird.workers.dev/m/demo/")
        .client(client)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(ChaosApi::class.java)

    // Half of all requests will fail with a random 500/502/503/504/429 โ€”
    // but the interceptor should absorb every one of them.
    repeat(5) { i ->
        val res = api.list(chaos = 0.5)
        println("call ${i + 1}: ${res.code()} records=${res.body()?.size}")
    }
    println("failures absorbed by the interceptor: ${retrying.retries}")
} }
call 1: 200 records=3
call 2: 200 records=3
call 3: 200 records=3
call 4: 200 records=3
call 5: 200 records=3
failures absorbed by the interceptor: 7

Five clean results โ€” and the counter proves the interceptor earned them: seven injected failures were retried away. Chaos-failed writes are not applied (the server "died" before processing), so it's also safe to point idempotency/retry logic at POSTs. Add ?mock_jitter=800 for random latency, or ?mock_ratelimit=10 to rehearse 429-with-Retry-After handling (guide).

6. Pin tests to frozen snapshots โ€” empty states included

Data snapshots freeze a named copy of the whole dataset. Any request carrying X-Mockbird-Snapshot: name (or ?mock_snapshot=name) is answered read-only from that copy โ€” so parallel tests can't race each other, and the "empty state" your designer keeps asking about is one query param away: ?mock_snapshot=empty โ†’ [].

import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.Execution
import org.junit.jupiter.api.parallel.ExecutionMode
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

/** Build an API client whose every request is pinned to a named snapshot.
 *  Pinned reads are served from the frozen copy โ€” live data never changes. */
fun pinnedApi(snapshot: String?): ProductApi {
    val client = OkHttpClient.Builder().apply {
        if (snapshot != null) addInterceptor { chain ->
            chain.proceed(
                chain.request().newBuilder()
                    .header("X-Mockbird-Snapshot", snapshot)
                    .build()
            )
        }
    }.build()
    return Retrofit.Builder()
        .baseUrl("https://mockbird.mockbird.workers.dev/m/demo/")
        .client(client)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(ProductApi::class.java)
}

@Execution(ExecutionMode.CONCURRENT)
class SnapshotPinningTest {

    @Test
    fun `empty state renders when there is no data`() = runBlocking<Unit> {
        val res = pinnedApi("empty").list(limit = 50)
        assertEquals(0, res.body()?.size)          // frozen empty dataset
    }

    @Test
    fun `list renders against the full dataset`() = runBlocking<Unit> {
        val res = pinnedApi(null).list(limit = 50)
        assertTrue(res.body()!!.size >= 30)        // live seeded data
    }
}
[INFO] Running SnapshotPinningTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.508 s -- in SnapshotPinningTest

Both tests run concurrently against the same project and can't interfere: one sees a frozen empty dataset, the other the live data. On your own project you'd POST /api/projects/:id/snapshots {"name":"baseline"} once, then pin each test class to the fixture it needs. (JVM unit tests, no emulator needed โ€” Retrofit runs fine off-device.)

7. Build the login screen before the auth backend exists

Every project ships mock auth endpoints: any email + password returns a real signed, expiring JWT.

import kotlinx.coroutines.runBlocking
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST

data class LoginRequest(val email: String, val password: String)
data class LoginResponse(val token: String, val user: Map<String, Any?>)

interface AuthApi {
    @POST("auth/login")
    suspend fun login(@Body body: LoginRequest): Response<LoginResponse>

    @GET("auth/me")
    suspend fun me(@Header("Authorization") bearer: String): Response<Map<String, Any?>>
}

fun main() { runBlocking {
    val api = retrofit.create(AuthApi::class.java)

    // Any email + password works โ€” the JWT that comes back is real (signed, expiring)
    val login = api.login(LoginRequest("ada@example.com", "hunter2"))
    val token = login.body()!!.token
    println("login -> ${login.code()} token=${token.take(20)}...")

    val me = api.me("Bearer $token")
    println("me    -> ${me.code()} ${me.body()}")
} }
login -> 200 token=eyJhbGciOiJIUzI1NiIs...
me    -> 200 {user={id=0.0, email=ada@example.com, name=ada}, token={sub=0, iat=1.785521064E9, exp=1.785524664E9}}

Wire the token into your Authorization interceptor and your whole auth plumbing โ€” storage, refresh-on-401 (expiresIn: 5 gives you a token that dies in five seconds), logout โ€” works before the real identity provider is picked. If the project has a users resource, logins with a seeded user's email return that record as the user. Full auth guide.

8. Honest comparison

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

MockWebServerMockRetrofit / fakes behind a flavorjson-server on your laptopMockbird
What it isReal loopback server in your test (OkHttp project)Your own fake implementations, compiled inLocal Node process serving db.jsonHosted mock API
Works offline / zero latencyโœ”โœ”โœ” (on that machine)โœ– (real network)
Reachable from emulator without configTest-process onlyn/a (in-app)โœ– 10.0.2.2 alias + cleartext XMLโœ” plain https URL
Reachable from a physical device / teammate / CIโœ–n/aโœ– LAN IP jugglingโœ” same URL everywhere
Stubs you must write & maintainEvery enqueued response, by handEvery fake, by handdb.json by hand (CRUD free)None โ€” CRUD, filters, search, pagination, auth built in
Real timeouts / chaos / rate limitsPartial โ€” throttle APIโœ– nothing is realโœ– (middleware DIY)โœ” mock_delay / mock_chaos / mock_ratelimit
Verify "was this called?"โœ” takeRequest()โœ” your codeโœ–Partial: request inspector
Freeโœ” (library)โœ” (your time)โœ” (local only)โœ” 20 projects, 10k req/project/day

Use both: keep MockWebServer for unit tests with request verification, and point emulator sessions, device demos, designer builds and CI at a hosted URL. Already have a db.json? POST it to us and it's hosted โ€” your exact records, same json-server query conventions.

Building the other half in React Native or Flutter? Same project, same URL. Backend service in Java, Node, Python, Go, Ruby, PHP, Rust, Elixir or C#? 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.