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 foundThis 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.