Learn how to test Retrofit APIs using MockWebServer: fake HTTP responses, request paths, headers, JSON bodies, successful and error responses, malformed JSON, timeouts, schemas, consumer/provider contracts, backward compatibility and serialization.
API models
data class TaskDto(
val id: Long,
val title: String,
val completed: Boolean
)
data class CreateTaskRequest(
val title: String
)
data class ApiErrorDto(
val code: String,
val message: String
)Retrofit service
interface TaskApi {
@GET("v1/tasks/{id}")
suspend fun getTask(
@Path("id") id: Long,
@Header("Authorization") token: String
): Response<TaskDto>
@POST("v1/tasks")
suspend fun createTask(
@Header("Authorization") token: String,
@Body request: CreateTaskRequest
): Response<TaskDto>
}API integration boundary
flowchart LR
TEST["JUnit test"]
API["Retrofit interface"]
CONVERTER["JSON converter"]
CLIENT["OkHttp client"]
SERVER["MockWebServer"]
TEST --> API --> CONVERTER --> CLIENT --> SERVERReal components:
- Retrofit
- OkHttp
- Converter such as Moshi
- Interceptors, when included
- Request annotations
Replaced component:
- Production backend
Test Setup
class TaskApiTest {
private lateinit var server: MockWebServer
private lateinit var api: TaskApi
@Before
fun setUp() {
server = MockWebServer()
server.start()
val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
api = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(
MoshiConverterFactory.create(moshi)
)
.build()
.create(TaskApi::class.java)
}
@After
fun tearDown() {
server.shutdown()
}
}The Retrofit base URL must end with /.
---
23. Retrofit API Testing
Retrofit API testing verifies the Android client’s HTTP integration.
It checks:
* HTTP method
* Request URL and path
* Query parameters
* Headers
* Request body
* Response conversion
* Error handling
* Timeout handling
* API contract assumptions
What is not being tested?
A MockWebServer test does not verify:
* Production backend logic
* Production DNS
* Production TLS certificates
* Actual production network performance
* Server database behaviour
It verifies that the Android client creates and handles HTTP messages correctly.
Recommended test matrix
| Scenario | Expected result |
|---|---|
| HTTP 200 with valid JSON | Parsed DTO |
| HTTP 201 after POST | Created DTO |
| HTTP 204 | Empty successful response |
| HTTP 400 | Validation error |
| HTTP 401 | Unauthorized error |
| HTTP 404 | Not-found error |
| HTTP 500 | Server error |
| Invalid JSON | Serialization failure |
| Slow response | Timeout |
| Missing optional field | Default/null behaviour |
| Missing required field | Controlled parsing failure |
| Unexpected extra field | Remains compatible if allowed |
---
24. MockWebServer
MockWebServer is a local programmable HTTP server from OkHttp.
It allows tests to:
* Enqueue responses
* Record requests
* Verify request details
* Simulate errors
* Simulate slow responses
* Return malformed content
* Return responses in sequence
* Dispatch responses by path
sequenceDiagram
participant Test
participant Retrofit
participant MockServer
Test->>MockServer: Enqueue fake response
Test->>Retrofit: Call getTask()
Retrofit->>MockServer: GET /v1/tasks/10
MockServer-->>Retrofit: HTTP 200 + JSON
Retrofit-->>Test: Response<TaskDto>
Test->>MockServer: Inspect recorded requestMockWebServer provides MockResponse for controlled responses and RecordedRequest for checking method, path, body and headers. Official MockWebServer guide.
---
25. Creating Fake HTTP Responses
Successful response
server.enqueue(
MockResponse.Builder()
.code(200)
.addHeader(
"Content-Type",
"application/json"
)
.body(
"""
{
"id": 10,
"title": "Learn API testing",
"completed": false
}
""".trimIndent()
)
.build()
)Error response
server.enqueue(
MockResponse.Builder()
.code(400)
.addHeader(
"Content-Type",
"application/json"
)
.body(
"""
{
"code": "INVALID_TITLE",
"message": "Title is required"
}
""".trimIndent()
)
.build()
)No-content response
server.enqueue(
MockResponse.Builder()
.code(204)
.build()
)Depending on the OkHttp version, older projects may use:
MockResponse()
.setResponseCode(200)
.setBody("{}")Keep test APIs consistent with the project’s installed OkHttp version.
---
26. Verifying Request Path
Use takeRequest() to obtain the request received by MockWebServer.
@Test
fun getTask_sendsCorrectMethodAndPath() = runTest {
server.enqueue(successfulTaskResponse())
api.getTask(
id = 10,
token = "Bearer test-token"
)
val request = server.takeRequest()
assertEquals("GET", request.method)
assertEquals("/v1/tasks/10", request.url.encodedPath)
}Query parameter example
@GET("v1/tasks")
suspend fun getTasks(
@Query("page") page: Int,
@Query("completed") completed: Boolean
): Response<List<TaskDto>>val request = server.takeRequest()
assertEquals("1", request.url.queryParameter("page"))
assertEquals(
"true",
request.url.queryParameter("completed")
)Test:
* HTTP method
* Encoded path
* Path parameters
* Query parameters
* Optional query omission
* URL encoding
* Dynamic @Url behaviour
---
27. Verifying Headers and Body
Header verification
@Test
fun createTask_sendsAuthorizationAndJsonBody() = runTest {
server.enqueue(successfulTaskResponse(code = 201))
api.createTask(
token = "Bearer test-token",
request = CreateTaskRequest(
title = "Learn MockWebServer"
)
)
val recorded = server.takeRequest()
assertEquals(
"Bearer test-token",
recorded.headers["Authorization"]
)
assertTrue(
recorded.headers["Content-Type"]
?.startsWith("application/json") == true
)
}Body verification
Avoid comparing raw JSON strings because whitespace and field order may vary.
Parse the body:
val rawBody = recorded.body?.utf8().orEmpty()
val adapter =
moshi.adapter(CreateTaskRequest::class.java)
val requestBody = adapter.fromJson(rawBody)
assertEquals(
CreateTaskRequest("Learn MockWebServer"),
requestBody
)Important banking example
Verify that a payment request contains:
* Amount
* Currency
* Beneficiary ID
* Idempotency key
* Request ID
* Authorization header
Do not use real tokens or personal information in tests.
---
28. Successful-Response Testing
Test that Retrofit correctly converts a valid HTTP response into the expected model.
@Test
fun getTask_http200_parsesTaskDto() = runTest {
server.enqueue(
MockResponse.Builder()
.code(200)
.addHeader(
"Content-Type",
"application/json"
)
.body(
"""
{
"id": 10,
"title": "Retrofit testing",
"completed": false
}
""".trimIndent()
)
.build()
)
val response = api.getTask(
id = 10,
token = "Bearer test-token"
)
assertTrue(response.isSuccessful)
assertEquals(
TaskDto(
id = 10,
title = "Retrofit testing",
completed = false
),
response.body()
)
}Test successful cases such as:
* 200 OK
* 201 Created
* 202 Accepted
* 204 No Content
* Empty list
* Optional fields
* Pagination metadata
* Response headers
Do not assume every successful response contains a body.
---
29. HTTP Error-Response Testing
For a Retrofit function returning `Response<T>`, HTTP error responses are represented by unsuccessful `Response` objects.@Test
fun getTask_http404_returnsNotFoundResponse() = runTest {
server.enqueue(
MockResponse.Builder()
.code(404)
.addHeader(
"Content-Type",
"application/json"
)
.body(
"""
{
"code": "TASK_NOT_FOUND",
"message": "Task does not exist"
}
""".trimIndent()
)
.build()
)
val response = api.getTask(
id = 999,
token = "Bearer test-token"
)
assertFalse(response.isSuccessful)
assertEquals(404, response.code())
assertNull(response.body())
assertNotNull(response.errorBody())
}Return-type difference
Returning Response<TaskDto>
suspend fun getTask(): Response<TaskDto>The caller inspects:
response.isSuccessful
response.code()
response.body()
response.errorBody()Returning TaskDto
suspend fun getTask(): TaskDtoNon-successful HTTP responses normally cause Retrofit to throw HttpException.
@Test
fun getTask_http401_throwsHttpException() = runTest {
server.enqueue(
MockResponse.Builder()
.code(401)
.build()
)
val error = runCatching {
apiReturningBody.getTask()
}.exceptionOrNull()
assertTrue(error is HttpException)
assertEquals(401, (error as HttpException).code())
}Test important statuses:
| Status | Possible mapping |
|---|---|
| 400 | Invalid request |
| 401 | Session expired |
| 403 | Forbidden |
| 404 | Not found |
| 409 | Conflict |
| 422 | Validation failure |
| 429 | Rate limited |
| 500–599 | Server error |
Do not treat every non-200 response as the same error.
---
30. Malformed JSON Testing
Malformed JSON verifies that the client handles invalid server content safely.
@Test
fun getTask_malformedJson_failsParsing() = runTest {
server.enqueue(
MockResponse.Builder()
.code(200)
.addHeader(
"Content-Type",
"application/json"
)
.body(
"""
{
"id": 10,
"title":
}
""".trimIndent()
)
.build()
)
val error = runCatching {
api.getTask(
id = 10,
token = "Bearer test-token"
)
}.exceptionOrNull()
assertNotNull(error)
}Malformed-response cases include:
* Invalid JSON syntax
* Wrong data type
* Missing required field
* Unexpected null
* Invalid enum
* Invalid date
* Empty response body
* HTML error page returned as JSON
* Truncated body
Repository tests should verify conversion into a stable application error:
assertEquals(
DataError.InvalidResponse,
result.error
)Avoid asserting a converter’s exact internal exception class unless the application depends on it.
---
31. Timeout Testing
A timeout test verifies behaviour when the server responds too slowly.
Client with short timeout
val client = OkHttpClient.Builder()
.readTimeout(500, TimeUnit.MILLISECONDS)
.build()Slow fake response
server.enqueue(
MockResponse.Builder()
.code(200)
.body(
"""
{
"id": 10,
"title": "Slow task",
"completed": false
}
""".trimIndent()
)
.throttleBody(
1,
1,
TimeUnit.SECONDS
)
.build()
)Test
@Test
fun getTask_slowResponse_throwsTimeout() = runTest {
val error = runCatching {
apiWithShortTimeout.getTask(
id = 10,
token = "Bearer test-token"
)
}.exceptionOrNull()
assertTrue(
error is SocketTimeoutException ||
error is InterruptedIOException
)
}MockWebServer supports slow responses, including throttled bodies, for testing timeout behaviour. Official MockWebServer guide.
Test:
* Connect timeout
* Read timeout
* Write timeout
* Call timeout
* Repository error mapping
* Retry policy
* Cancellation
* Partial response
Payment warning
After a payment timeout, do not immediately assume failure or blindly resubmit. The request may have reached the server. Check transaction status using an idempotency key or transaction ID.
---
32. Contract-Testing Fundamentals
An API contract defines the agreement between client and server.
It includes:
* HTTP method
* Endpoint
* Request headers
* Query/path parameters
* Request schema
* Response schema
* Status codes
* Nullability
* Field types
* Error format
* Authentication
* Versioning rules
flowchart LR
APP["Android consumer"]
CONTRACT["API contract"]
SERVER["Backend provider"]
APP --> CONTRACT
SERVER --> CONTRACTA contract test answers:
> Does the Android application’s expectation match what the backend promises?
Example contract
GET /v1/tasks/{id}
200:
{
"id": number, required
"title": string, required
"completed": boolean, required
}
404:
{
"code": string, required
"message": string, required
}OpenAPI provides a machine-readable standard for describing endpoints, operations, parameters, authentication and schemas. OpenAPI Specification.
---
33. Consumer and Provider Contracts
Consumer
The system calling the API:
Android applicationThe consumer specifies what it requires:
* Fields it reads
* Data types
* Status codes it handles
* Required headers
* Error structures
Provider
The system serving the API:
Backend serviceThe provider verifies that its implementation satisfies the consumer’s expectation.
sequenceDiagram
participant Android as Android consumer
participant Contract
participant Backend as API provider
Android->>Contract: Define required interaction
Backend->>Contract: Verify implementation
Contract-->>Android: Compatible
Contract-->>Backend: CompatibleExample
Consumer expectation:
Given task 10 exists
When GET /v1/tasks/10 is requested
Then return:
Status 200
Content-Type application/json
Body includes id, title and completedProvider verification should execute this interaction against the provider implementation.
Contract tests complement, rather than replace:
* Android MockWebServer tests
* Backend unit tests
* Full integration tests
* End-to-end tests
---
34. Request-Schema Testing
Request-schema testing verifies that the Android client sends the correct structure.
Expected request
{
"title": "Learn contract testing"
}Test
@Test
fun createTask_sendsExpectedRequestSchema() = runTest {
server.enqueue(
successfulTaskResponse(code = 201)
)
api.createTask(
token = "Bearer test-token",
request = CreateTaskRequest(
title = "Learn contract testing"
)
)
val recorded = server.takeRequest()
val json = recorded.body?.utf8().orEmpty()
val parsed =
moshi.adapter(CreateTaskRequest::class.java)
.fromJson(json)
assertNotNull(parsed)
assertEquals(
"Learn contract testing",
parsed?.title
)
}Verify:
* Required fields exist
* Field names are correct
* Types are correct
* Null fields follow the contract
* Optional fields are omitted or included correctly
* Enum values are supported
* Date format is correct
* Numeric precision is preserved
* Sensitive values are not accidentally included
For payments, verify money uses the agreed representation, such as minor units:{
"amountMinor": 10050,
"currency": "INR"
}This avoids floating-point ambiguity.
---
35. Response-Schema Testing
Response-schema testing verifies that the client correctly handles the provider’s promised response.
Expected response
{
"id": 10,
"title": "Learn testing",
"completed": false
}Test required fields
@Test
fun response_withRequiredFields_isDeserialized() = runTest {
server.enqueue(
jsonResponse(
"""
{
"id": 10,
"title": "Learn testing",
"completed": false
}
"""
)
)
val response = api.getTask(
id = 10,
token = "Bearer test-token"
)
assertEquals(10, response.body()?.id)
assertEquals(
"Learn testing",
response.body()?.title
)
}Test:
* Required field present
* Optional field missing
* Nullable field is null
* Wrong field type
* Unknown enum
* Extra field
* Empty collection
* Nested model
* Error-response schema
* Pagination structure
A test that parses only the happy-path fixture is not enough.
---
36. Backward Compatibility
A backend change is backward-compatible when older application versions can continue working.
Usually compatible changes
* Add an optional response field
* Add a new endpoint
* Add an optional request parameter
* Add a new optional header
* Extend behaviour without changing existing meanings
Common breaking changes
* Remove a field
* Rename a field
* Change a field type
* Make an optional field required
* Change nullability
* Change date format
* Change endpoint path
* Change status-code meaning
* Add an enum value when the client cannot handle unknown values
* Change error-body structure
Compatibility test
@Test
fun responseWithAdditionalField_stillParses() = runTest {
server.enqueue(
jsonResponse(
"""
{
"id": 10,
"title": "Testing",
"completed": false,
"priority": "HIGH"
}
"""
)
)
val response = api.getTask(
id = 10,
token = "Bearer test-token"
)
assertEquals(10, response.body()?.id)
}Safer enum handling
Backend:
{
"status": "REVERSED"
}If the app understands only `SUCCESS` and `FAILED`, parsing may fail.Use a safe fallback where supported:
enum class TransactionStatus {
SUCCESS,
FAILED,
PENDING,
UNKNOWN
}Backward compatibility matters because users do not all update Android applications immediately.
---
37. Serialization and Deserialization Testing
Serialization
Converts a Kotlin object into JSON.
CreateTaskRequest → JSONDeserialization
Converts JSON into a Kotlin object.
JSON → TaskDtoflowchart LR
REQUEST["Kotlin request"]
SER["Serialize"]
JSON1["Request JSON"]
JSON2["Response JSON"]
DES["Deserialize"]
DTO["Kotlin DTO"]
REQUEST --> SER --> JSON1
JSON2 --> DES --> DTOSerialization test
@Test
fun createTaskRequest_serializesCorrectly() {
val adapter =
moshi.adapter(CreateTaskRequest::class.java)
val json = adapter.toJson(
CreateTaskRequest("Learn JSON")
)
val parsed =
adapter.fromJson(json)
assertEquals(
CreateTaskRequest("Learn JSON"),
parsed
)
}Deserialization test
@Test
fun validJson_deserializesToTaskDto() {
val adapter =
moshi.adapter(TaskDto::class.java)
val result = adapter.fromJson(
"""
{
"id": 10,
"title": "Learn JSON",
"completed": false
}
""".trimIndent()
)
assertEquals(
TaskDto(
id = 10,
title = "Learn JSON",
completed = false
),
result
)
}Test:
* Custom JSON field names
* Nullable fields
* Default values
* Nested objects
* Lists
* Enums
* Dates and time zones
* Numeric precision
* Unknown fields
* Missing fields
* Invalid data types
Retrofit uses converter factories to transform HTTP representations into application types. Retrofit converter API.
---
38. Integration-Testing Common Mistakes
1. Calling the production backend from automated tests
2. Using real authentication credentials
3. Hardcoding a fixed localhost port
4. Forgetting to shut down MockWebServer
5. Using the production base URL accidentally
6. Testing only HTTP 200
7. Ignoring 201, 202 and 204
8. Treating every HTTP error identically
9. Comparing raw JSON strings
10. Ignoring request headers
11. Not verifying HTTP method or path
12. Not testing query-parameter encoding
13. Ignoring empty response bodies
14. Not testing malformed JSON
15. Not testing missing required fields
16. Not testing extra response fields
17. Using long real waits for timeout tests
18. Retrying every error
19. Retrying non-idempotent requests blindly
20. Assuming timeout means the server did nothing
21. Testing converter implementation rather than application requirements
22. Making the mock response unrealistically different from the API contract
23. Duplicating outdated JSON fixtures across many tests
24. Not version-controlling contract fixtures
25. Updating consumer tests without coordinating backend changes
26. Considering MockWebServer tests to be provider tests
27. Not checking backward compatibility
28. Failing on unknown enum values without a fallback strategy
29. Using floating-point numbers for financial amounts
30. Exposing parsing or HTTP exceptions directly to the UI
31. Verifying exact implementation details of interceptors
32. Allowing tests to depend on request order unnecessarily
---
Complete Banking Use Case: Payment API
Request
{
"beneficiaryId": "B101",
"amountMinor": 100000,
"currency": "INR",
"idempotencyKey": "unique-key"
}Recommended tests
| Scenario | Verification |
|---|---|
| Valid payment | Correct POST path and body |
| Authentication | Authorization header |
| Request tracing | Request ID header |
| Duplicate protection | Idempotency key present |
| HTTP 201 | Successful transaction parsed |
| HTTP 202 | Pending transaction parsed |
| HTTP 400 | Validation error mapped |
| HTTP 401 | Session-expired handling |
| HTTP 409 | Duplicate/conflict handled |
| HTTP 429 | Rate-limit error handled |
| HTTP 500 | Server error mapped |
| Malformed JSON | Invalid-response error |
| Timeout | Status inquiry, not blind resubmission |
| Additional response field | Old client still parses |
| New unknown status | Maps safely to unknown |
---
Interview Questions and Answers
1. What does a Retrofit integration test verify?
It verifies request creation, HTTP communication, conversion and response/error handling across Retrofit, OkHttp and the converter.
2. What is MockWebServer?
It is a programmable local HTTP server used to return controlled responses and record client requests.
3. Why use MockWebServer instead of mocking Retrofit?
MockWebServer exercises real HTTP request creation, OkHttp behaviour and serialization.
4. Does MockWebServer test the production backend?
No. It tests the client against a simulated server.
5. What is MockResponse?
It defines a response that MockWebServer should return, including status, headers, body and timing.
6. What is RecordedRequest?
It represents a request received by MockWebServer and exposes its method, URL, headers and body.
7. How do you verify a request path?
Call takeRequest() and assert its URL path and query parameters.
8. How should JSON request bodies be compared?
Deserialize them into a model or JSON structure instead of comparing raw strings.
9. How do you test a successful Retrofit response?
Enqueue valid JSON with a successful status, call the API and assert the parsed DTO.
10. How do you test an HTTP error?
Enqueue an error status and body, then assert the response code, error body or thrown HttpException.
11. When does Retrofit throw HttpException?
A suspend API returning a body directly normally throws it for non-successful HTTP responses.
12. What happens when a Retrofit API returns Response<T>?
The caller receives success and error responses and must inspect isSuccessful, code, body and errorBody.
13. What is the difference between an HTTP error and a network error?
An HTTP error contains a server response such as 404 or 500. A network error occurs when a valid HTTP response is not received, such as timeout or DNS failure.
14. How do you test malformed JSON?
Return invalid or contract-breaking JSON and verify a parsing failure or mapped invalid-response error.15. How do you test a timeout?
Use a short client timeout and configure MockWebServer to delay or throttle the response.
16. Should a timed-out payment be retried immediately?
No. First check its status because the original request may have reached the server.
17. What is an API contract?
It is the agreement covering endpoints, methods, parameters, headers, schemas, status codes and errors.
18. What is a consumer in contract testing?
The consumer is the system calling the API, such as the Android application.
19. What is a provider?
The provider is the backend service implementing the API.
20. What is consumer-driven contract testing?
Consumers publish the interactions they require, and providers verify their implementations against those contracts.
21. Do contract tests replace integration tests?
No. They complement client integration, provider integration and end-to-end testing.
22. What is request-schema testing?
It verifies that the client sends required fields with correct names, types, formats and nullability.
23. What is response-schema testing?
It verifies that returned data matches the structure and types expected by the client.
24. What is OpenAPI?
It is a language-independent standard for describing HTTP API operations, parameters, security and schemas.
25. What is a backward-compatible API change?
A change that allows existing clients to continue operating correctly.
26. Is adding a response field normally backward-compatible?
Usually yes, provided clients tolerate unknown fields.
27. Is removing a response field backward-compatible?
Usually no when existing consumers depend on that field.
28. Why can adding an enum value break an app?
A client with strict enum parsing may not recognize the new value and can fail deserialization.
29. How can unknown enum values be handled?
Map unrecognized values to a safe UNKNOWN state where the serialization library and business rules allow it.
30. What is serialization?
Converting an application object into a transport representation such as JSON.
31. What is deserialization?
Converting JSON into an application object.
32. What serialization cases should be tested?
Field names, nullability, defaults, enums, dates, nested objects and numeric precision.
33. Why should financial amounts avoid floating-point representations?
Floating-point values can introduce precision errors. Prefer agreed minor-unit integers or exact decimal types.
34. Why must the Retrofit base URL end with /?
Retrofit resolves relative endpoint paths against a directory-style base URL.
35. Should authentication tokens be hardcoded in tests?
Use fake non-sensitive tokens only. Never place real credentials in source code.
36. What is the most important Retrofit test principle?
Verify the Android client’s contract and behaviour using controlled, realistic responses without depending on the production server.
---
Quick Revision
Retrofit test:
Real Retrofit + OkHttp + converter
Fake backend through MockWebServer
MockResponse:
Controls status, headers, body and timing
RecordedRequest:
Verifies method, path, query, headers and body
HTTP error:
Server returned a non-success status
Network error:
No valid HTTP response received
Contract:
Agreement between Android consumer and backend provider
Request schema:
What Android sends
Response schema:
What Android expects
Backward compatibility:
Old app versions continue to work
Serialization:
Kotlin object → JSON
Deserialization:
JSON → Kotlin object