Retrofit Testing Guide: MockWebServer, Custom Call Adapters & Error Handling Tests

Retrofit Testing Guide: MockWebServer, Custom Call Adapters & Error Handling Tests

Retrofit is Android's de facto HTTP client library. Testing it properly means validating that your API service interfaces produce correct requests, handle error responses gracefully, and work with your custom call adapters and interceptors. OkHttp's MockWebServer is the tool that makes all of this possible.

Why MockWebServer Over Mocking

You could mock Retrofit interfaces directly with MockK:

coEvery { apiService.getUser("123") } returns User("123", "Alice")

But this misses everything between the interface and the wire:

  • Request header construction (auth tokens, content-type, user-agent)
  • URL building and query parameter encoding
  • JSON serialization/deserialization with Gson/Moshi/kotlinx.serialization
  • Interceptor behavior (logging, retry, auth refresh)
  • Error response parsing

MockWebServer gives you a real HTTP server that your real Retrofit client talks to.

Setting Up MockWebServer

// build.gradle.kts
dependencies {
    testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}
class RetrofitApiTest {

    private lateinit var mockWebServer: MockWebServer
    private lateinit var apiService: ProductApiService

    @Before
    fun setUp() {
        mockWebServer = MockWebServer()
        mockWebServer.start()

        val okHttpClient = OkHttpClient.Builder()
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            })
            .build()

        apiService = Retrofit.Builder()
            .baseUrl(mockWebServer.url("/"))
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ProductApiService::class.java)
    }

    @After
    fun tearDown() {
        mockWebServer.shutdown()
    }
}

Testing Request Construction

Verify that your Retrofit service builds requests correctly:

@Test
fun shouldIncludeAuthorizationHeader() = runTest {
    val tokenInterceptor = OkHttpClient.Builder()
        .addInterceptor { chain ->
            val request = chain.request().newBuilder()
                .addHeader("Authorization", "Bearer test-token")
                .build()
            chain.proceed(request)
        }
        .build()

    val authenticatedService = Retrofit.Builder()
        .baseUrl(mockWebServer.url("/"))
        .client(tokenInterceptor)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(ProductApiService::class.java)

    mockWebServer.enqueue(MockResponse()
        .setResponseCode(200)
        .setBody("""{"id": "1", "name": "Product"}"""))

    authenticatedService.getProduct("1")

    val request = mockWebServer.takeRequest()
    assertEquals("Bearer test-token", request.getHeader("Authorization"))
    assertEquals("/products/1", request.path)
    assertEquals("GET", request.method)
}

@Test
fun shouldEncodeQueryParametersCorrectly() = runTest {
    mockWebServer.enqueue(MockResponse()
        .setResponseCode(200)
        .setBody("""{"items": [], "total": 0}"""))

    apiService.searchProducts(
        query = "blue shoes",
        category = "footwear",
        minPrice = 50.0,
        maxPrice = 200.0,
        page = 2,
        pageSize = 20
    )

    val request = mockWebServer.takeRequest()
    val url = request.requestUrl!!

    assertEquals("blue shoes", url.queryParameter("q"))
    assertEquals("footwear", url.queryParameter("category"))
    assertEquals("50.0", url.queryParameter("min_price"))
    assertEquals("200.0", url.queryParameter("max_price"))
    assertEquals("2", url.queryParameter("page"))
    assertEquals("20", url.queryParameter("page_size"))
}

@Test
fun shouldSendJsonBodyForPostRequest() = runTest {
    mockWebServer.enqueue(MockResponse()
        .setResponseCode(201)
        .setBody("""{"id": "new-prod-1", "name": "New Product"}"""))

    val newProduct = CreateProductRequest(
        name = "New Product",
        price = 49.99,
        category = "electronics",
        tags = listOf("new", "sale")
    )

    apiService.createProduct(newProduct)

    val request = mockWebServer.takeRequest()
    assertEquals("POST", request.method)
    assertEquals("application/json; charset=UTF-8", request.getHeader("Content-Type"))

    val body = Gson().fromJson(request.body.readUtf8(), CreateProductRequest::class.java)
    assertEquals("New Product", body.name)
    assertEquals(49.99, body.price, 0.001)
    assertEquals(listOf("new", "sale"), body.tags)
}

Testing Error Response Handling

@Test
fun shouldThrowHttpExceptionOnClientError() = runTest {
    mockWebServer.enqueue(MockResponse()
        .setResponseCode(404)
        .setBody("""{"error": "Product not found", "code": "PRODUCT_NOT_FOUND"}"""))

    val exception = assertThrows<HttpException> {
        apiService.getProduct("nonexistent")
    }

    assertEquals(404, exception.code())
}

@Test
fun shouldParseErrorResponseBody() = runTest {
    val errorBody = """{"error": "Unauthorized", "message": "Invalid API key"}"""
    mockWebServer.enqueue(MockResponse()
        .setResponseCode(401)
        .setBody(errorBody))

    try {
        apiService.getProtectedResource()
        fail("Should have thrown HttpException")
    } catch (e: HttpException) {
        val errorResponse = e.response()?.errorBody()?.string()
        val error = Gson().fromJson(errorResponse, ApiError::class.java)
        assertEquals("Unauthorized", error.error)
        assertEquals("Invalid API key", error.message)
    }
}

@Test
fun shouldHandleServerError() = runTest {
    mockWebServer.enqueue(MockResponse()
        .setResponseCode(500)
        .setBody("""{"error": "Internal Server Error"}"""))

    val exception = assertThrows<HttpException> {
        apiService.getProduct("1")
    }

    assertEquals(500, exception.code())
}

@Test
fun shouldHandleNetworkTimeout() = runTest {
    val timeoutClient = OkHttpClient.Builder()
        .callTimeout(Duration.ofMillis(500))
        .build()

    val timeoutService = Retrofit.Builder()
        .baseUrl(mockWebServer.url("/"))
        .client(timeoutClient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(ProductApiService::class.java)

    // Enqueue a delayed response
    mockWebServer.enqueue(MockResponse()
        .setBodyDelay(2000, TimeUnit.MILLISECONDS)
        .setBody("""{"id": "1"}"""))

    assertThrows<java.net.SocketTimeoutException> {
        timeoutService.getProduct("1")
    }
}

@Test
fun shouldHandleNetworkDisconnect() = runTest {
    mockWebServer.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST))

    assertThrows<IOException> {
        apiService.getProduct("1")
    }
}

Testing Custom Call Adapters

Call adapters transform Retrofit's default Call<T> response into other types like Result<T>, Flow<T>, or Either<Error, T>:

Result Call Adapter

// Custom Result call adapter implementation
class ResultCallAdapter<T>(private val responseType: Type) : CallAdapter<T, Result<T>> {
    override fun responseType() = responseType

    override fun adapt(call: Call<T>): Result<T> = runCatching {
        val response = call.execute()
        if (response.isSuccessful) {
            response.body() ?: throw NullPointerException("Response body is null")
        } else {
            throw HttpException(response)
        }
    }
}

// Test for the custom adapter
@Test
fun shouldReturnSuccessResultOnOkResponse() = runTest {
    val resultService = Retrofit.Builder()
        .baseUrl(mockWebServer.url("/"))
        .addCallAdapterFactory(ResultCallAdapterFactory())
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(ResultProductApiService::class.java)

    mockWebServer.enqueue(MockResponse()
        .setResponseCode(200)
        .setBody("""{"id": "1", "name": "Widget"}"""))

    val result: Result<Product> = resultService.getProduct("1")

    assertTrue(result.isSuccess)
    assertEquals("Widget", result.getOrThrow().name)
}

@Test
fun shouldReturnFailureResultOnErrorResponse() = runTest {
    val resultService = Retrofit.Builder()
        .baseUrl(mockWebServer.url("/"))
        .addCallAdapterFactory(ResultCallAdapterFactory())
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(ResultProductApiService::class.java)

    mockWebServer.enqueue(MockResponse().setResponseCode(404))

    val result: Result<Product> = resultService.getProduct("nonexistent")

    assertTrue(result.isFailure)
    assertTrue(result.exceptionOrNull() is HttpException)
    assertEquals(404, (result.exceptionOrNull() as HttpException).code())
}

Testing Flow Call Adapter

@Test
fun shouldEmitResponseAsFlow() = runTest {
    val flowService = Retrofit.Builder()
        .baseUrl(mockWebServer.url("/"))
        .addCallAdapterFactory(FlowCallAdapterFactory.create())
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(FlowProductApiService::class.java)

    mockWebServer.enqueue(MockResponse()
        .setResponseCode(200)
        .setBody("""{"id": "1", "name": "Flow Product"}"""))

    flowService.getProduct("1").test {
        val product = awaitItem()
        assertEquals("Flow Product", product.name)
        awaitComplete()
    }
}

Testing Interceptors

@Test
fun shouldRetryOnServerError() = runTest {
    // First request fails with 503, second succeeds
    mockWebServer.enqueue(MockResponse().setResponseCode(503))
    mockWebServer.enqueue(MockResponse()
        .setResponseCode(200)
        .setBody("""{"id": "1", "name": "Product"}"""))

    val retryClient = OkHttpClient.Builder()
        .addInterceptor(RetryInterceptor(maxRetries = 1))
        .build()

    val retryService = Retrofit.Builder()
        .baseUrl(mockWebServer.url("/"))
        .client(retryClient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(ProductApiService::class.java)

    val product = retryService.getProduct("1")

    assertEquals(2, mockWebServer.requestCount)  // Verify retry happened
    assertEquals("Product", product.name)
}

@Test
fun shouldRefreshTokenOn401() = runTest {
    val authInterceptor = AuthInterceptor(
        tokenProvider = { "valid-token" },
        refreshToken = { "new-token" }
    )

    // First call: 401 (token expired)
    mockWebServer.enqueue(MockResponse().setResponseCode(401))
    // After refresh: success
    mockWebServer.enqueue(MockResponse()
        .setResponseCode(200)
        .setBody("""{"id": "1", "name": "Product"}"""))

    val authenticatedClient = OkHttpClient.Builder()
        .addInterceptor(authInterceptor)
        .build()

    val service = Retrofit.Builder()
        .baseUrl(mockWebServer.url("/"))
        .client(authenticatedClient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(ProductApiService::class.java)

    val product = service.getProduct("1")

    assertEquals(2, mockWebServer.requestCount)

    // Second request should use the refreshed token
    val secondRequest = mockWebServer.takeRequest()
    val thirdRequest = mockWebServer.takeRequest()
    assertEquals("Bearer new-token", thirdRequest.getHeader("Authorization"))
}

Testing Serialization Edge Cases

@Test
fun shouldHandleNullableFields() = runTest {
    mockWebServer.enqueue(MockResponse()
        .setResponseCode(200)
        .setBody("""{"id": "1", "name": "Product", "description": null, "imageUrl": null}"""))

    val product = apiService.getProduct("1")

    assertEquals("1", product.id)
    assertEquals("Product", product.name)
    assertNull(product.description)
    assertNull(product.imageUrl)
}

@Test
fun shouldHandleEmptyArray() = runTest {
    mockWebServer.enqueue(MockResponse()
        .setResponseCode(200)
        .setBody("""{"items": [], "total": 0, "page": 1}"""))

    val response = apiService.listProducts()

    assertTrue(response.items.isEmpty())
    assertEquals(0, response.total)
}

@Test
fun shouldHandleMalformedJson() = runTest {
    mockWebServer.enqueue(MockResponse()
        .setResponseCode(200)
        .setBody("not json at all"))

    assertThrows<JsonSyntaxException> {
        apiService.getProduct("1")
    }
}

Testing with Dispatcher Patterns

class ProductRepositoryTest {

    private val mockWebServer = MockWebServer()
    private val apiService: ProductApiService by lazy {
        Retrofit.Builder()
            .baseUrl(mockWebServer.url("/"))
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ProductApiService::class.java)
    }
    private val repository: ProductRepository by lazy {
        ProductRepository(apiService)
    }

    @After
    fun tearDown() = mockWebServer.shutdown()

    @Test
    fun shouldCacheAndReturnProducts() = runTest {
        val productsJson = """
            [
                {"id": "1", "name": "Product A", "price": 10.0},
                {"id": "2", "name": "Product B", "price": 20.0}
            ]
        """.trimIndent()

        mockWebServer.enqueue(MockResponse()
            .setResponseCode(200)
            .setBody(productsJson))

        val products = repository.getProducts()

        assertEquals(2, products.size)
        assertEquals("Product A", products[0].name)

        // Second call should use cache, not make another request
        val cachedProducts = repository.getProducts()
        assertEquals(1, mockWebServer.requestCount)  // Only one HTTP request made
    }
}

Retrofit Testing with HelpMeTest

While unit tests with MockWebServer cover the API client layer, HelpMeTest validates the full user journey that depends on real API responses:

*** Test Cases ***
Product Search Returns Results
    As    LoggedInUser
    Go To    https://app.example.com/search
    Fill Text    [data-testid=search-input]    wireless headphones
    Click    [data-testid=search-button]
    Wait Until Element Is Visible    [data-testid=search-results]
    Element Count Should Be Greater Than    [data-testid=product-item]    0

API Error Shows User-Friendly Message
    As    LoggedInUser
    Go To    https://app.example.com/products/invalid-id
    Wait Until Element Is Visible    [data-testid=error-message]
    Element Should Contain    [data-testid=error-message]    Product not found

This catches issues like missing error handling in the UI layer, or API errors being swallowed silently.

Common Pitfalls

1. Not consuming requests from the queue MockWebServer queues responses. If your test triggers 2 requests but only enqueues 1, the second request gets a connection refused. Enqueue responses for all expected requests, or check mockWebServer.requestCount.

2. Asserting on request before the call completes mockWebServer.takeRequest() blocks until a request arrives. In runTest, ensure the API call runs before calling takeRequest() by using advanceUntilIdle() if needed.

3. Not shutting down MockWebServer Undisposed MockWebServer instances hold ports open, causing flaky failures in subsequent tests. Always call mockWebServer.shutdown() in @After.

4. Using the wrong base URL mockWebServer.url("/") includes a trailing slash. If your Retrofit service interface paths start with /, you'll get double-slash URLs. Use mockWebServer.url("") or ensure paths don't start with /.

Summary

Retrofit testing with MockWebServer validates the entire HTTP stack:

  • Request construction tests for URL building, headers, and body serialization
  • Error response handling tests for 4xx, 5xx, and network failures
  • Custom call adapter tests for Result<T>, Flow<T>, and other wrappers
  • Interceptor tests for retry logic, auth token refresh, and caching
  • Serialization edge cases for null fields, empty arrays, and malformed responses

The combination of MockWebServer's real HTTP engine with Retrofit's real serialization catches integration bugs that pure mocking misses entirely.

Read more