Alpha alpha! Do you copy?
Almost all apps work with remote APIs for data fetching, uploading and processing. The most commonly used library for this task is Retrofit. As this has becomes very obvious part of application development, it becomes even more critical to test our api layer to verify our set up.
In this article, we’ll explore how can we write unit test cases for our network layer and make it more and more error free. We’ll be using Retrofit as rest client. So let’s begin.
Retrofit set up
The very first step is to add retrofit dependencies along with gson in our build.gradle as follows
// Retrofit implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' // Gson implementation 'com.google.code.gson:gson:2.9.0'
Now, we define an interface where we list our apis end point calls as follows
const val BASE_USER_PATH_URL = "/aqua30/users" | |
interface TestApis { | |
@GET(BASE_USER_PATH_URL) | |
suspend fun getAllUsers(): List<User> | |
@GET("$BASE_USER_PATH_URL/{id}") | |
suspend fun getUserById(@Path("id") id: Int): User | |
} |
data class User( | |
val id: Int, | |
val name: String, | |
val city: String, | |
val country: String | |
) |
We’re assuming here that we’ll fetch some users list and a single user by its id.
Then we define a user repository interface to create our user data layer as follows.
interface UserRepository { | |
suspend fun getAllUsers(): ApiResponse<List<User>> | |
suspend fun getUserById(id: Int): ApiResponse<User> | |
} |
We’ll now define its implementation as follows.
data class ApiResponse<T>( | |
val httpCode: Int = HttpURLConnection.HTTP_OK, | |
val body: T? = null, | |
val errorMessage: String? = null, | |
) |
class UserRepositoryImpl( | |
private val testApis: TestApis | |
): UserRepository { | |
override suspend fun getAllUsers(): ApiResponse<List<User>> { | |
return try { | |
ApiResponse( | |
body = testApis.getAllUsers() | |
) | |
} catch (e: HttpException) { | |
ApiResponse( | |
httpCode = e.code(), | |
errorMessage = "server error" | |
) | |
} catch (e: IOException) { | |
ApiResponse( | |
errorMessage = "connection error" | |
) | |
} | |
} | |
override suspend fun getUserById(id: Int): ApiResponse<User> { | |
return try { | |
ApiResponse( | |
body = testApis.getUserById(id) | |
) | |
} catch (e: HttpException) { | |
ApiResponse( | |
httpCode = e.code(), | |
errorMessage = "server error" | |
) | |
} catch (e: IOException) { | |
ApiResponse( | |
errorMessage = "connection error" | |
) | |
} | |
} | |
} |
Now if we want to access the user data, it’ll only be accessible vis User Repository. Alright! Now we’re done with our layer set up. Let’s move on to testing set up.
Set up for Testing
The first thing to note is that the test cases will resides in test folder and not in androidTest folder. We don’t have any android related dependency in this layer.
Let’s add some dependencies and understand why we need them.
- Coroutine test: It provides us support to test coroutines. We get test scopes and dispatchers to run coroutines.
// Coroutine test testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2"
- JUnit4: It provides us with unit testing framework apis to run tests locally on JVM.
testImplementation 'junit:junit:4.13.2'
- MockWebServer: It provides support for mocking of the server with which we can actually make api calls and get the output.
// Mock web server testImplementation 'com.squareup.okhttp3:mockwebserver:4.10.0'
- Truth: It provides support for better assertion and logging of results.
// Google truth for assertion testImplementation "com.google.truth:truth:1.1.3"
So our dependencies block will now look like as follows.
dependencies { | |
... other suppport dependencies | |
// Retrofit | |
implementation 'com.squareup.retrofit2:retrofit:2.9.0' | |
implementation 'com.squareup.retrofit2:converter-gson:2.9.0' | |
// Mock web server | |
testImplementation 'com.squareup.okhttp3:mockwebserver:4.10.0' | |
// Gson | |
implementation 'com.google.code.gson:gson:2.9.0' | |
// JUnit | |
testImplementation 'junit:junit:4.13.2' | |
// Coroutine test | |
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2" | |
// Google truth for assertion | |
testImplementation "com.google.truth:truth:1.1.3" | |
} |
Let’s move on and create a test class by right clicking on the UserRepositoryImpl class name and selecting Create test.
Now once our class is created, we need to set up few variables so that we can write our tests easily.
private lateinit var repository: UserRepository | |
private lateinit var testApis: TestApis | |
private lateinit var mockWebServer: MockWebServer | |
@Before | |
fun setUp() { | |
mockWebServer = MockWebServer() | |
mockWebServer.start() | |
testApis = RetrofitHelper.testApiInstance(mockWebServer.url("/").toString()) | |
repository = UserRepositoryImpl(testApis) | |
} | |
@After | |
fun tearDown() { | |
mockWebServer.shutdown() | |
} | |
Job Offers
Let’s understand what we did here.
In the @Before annotation, we initialized our repository, api class and mock server objects. We start our mock server and passed the base url from mock web server so that it can hit the mock server and give us the response as expected.
In the @After annotation, we simply shut down the server.
Difference in real and mock server
So why we are mocking a server? This is because mocking a server would give us control on what all kind of responses and requests we want to test our apis handling. As we’re not running tests on a real device and hence don’t have access to a real server. To make our tests functions as expected we’re mocking a real server and MockWebServer would let us do exactly that.
Cool! Now we’re all set to start writing test cases. So let’s begin.
Writing Test Cases
Test Case 1
Let’s write a test wherein we want to verify that if the server has two users and we hit the get all user api, we would get 2 users in return with http code 200.
We run this test in a test scope because we’ve suspend functions.
Line 3–6: So first, we’ll create a list of users which we expect in the server response.
Line 7–9: Create a mock response for the server to return by setting http code and body. So in this case server will return this response no matter which api we hit.
Line 10: Finally we set this mock response to our server.
Line 12: We hit our api end point.
Line 13: We assert if we get exactly same number of users as we set in our server response. We get a true for this.
Line 14: We assert if the list is exactly the same as we set in our server response. We get a true for this.
@Test | |
fun `for multiple users, api must return all users with http code 200`() = runTest { | |
val users = listOf( | |
User(1,"Saurabh","Delhi","India"), | |
User(2,"Zergain","London","UK"), | |
) | |
val expectedResponse = MockResponse() | |
.setResponseCode(HttpURLConnection.HTTP_OK) | |
.setBody(Gson().toJson(users)) | |
mockWebServer.enqueue(expectedResponse) | |
val actualResponse = repository.getAllUsers() | |
assertThat(actualResponse.body).hasSize(2) | |
assertThat(actualResponse.body).isEqualTo(users) | |
} |
By running our test case, gives us a Pass result.
Test Case 2
Let’s write a test wherein we want to verify that if the server don’t have the user id we pass and we hit the get user by id api, we would get http code 404 and a null body.
Line 3–4: Create a response which we expect in the server response.
Line 5: Set this mock response to our server.
Line 7: Hit the get user by id api.
Line 8: We assert if we get exactly same http code as we set in our server response. We get a true for this.
Line 9: We assert if we get null body as we set in our server response. We get a true for this too.
Note: If we look at our actual function getUserById, we notice that we handled HttpException in which we add http code in our response. Hence we can assert http code in this case.
@Test | |
fun `for user id not available, api must return with http code 404 and null user object`() = runTest { | |
val expectedResponse = MockResponse() | |
.setResponseCode(HttpURLConnection.HTTP_NOT_FOUND) | |
mockWebServer.enqueue(expectedResponse) | |
val actualResponse = repository.getUserById(1) | |
assertThat(actualResponse.httpCode).isEqualTo(HttpURLConnection.HTTP_NOT_FOUND) | |
assertThat(actualResponse.body).isNull() | |
} |
By running our test case, gives us a Pass result.
Bamn! We verified that our api set up is handling the responses correctly and as expected. Now we can get started and write more test cases for various scenarios and assert if all those are handled correctly.
I would leave that as practice for you to play around and observe the behavior. Let me know if you get stuck somewhere.
Gist For Reference
I’ve added the gist for UserRepositoryImplTest for reference.
@file:OptIn(ExperimentalCoroutinesApi::class) | |
class UserRepositoryImplTest { | |
private lateinit var repository: UserRepository | |
private lateinit var testApis: TestApis | |
private lateinit var mockWebServer: MockWebServer | |
@Before | |
fun setUp() { | |
mockWebServer = MockWebServer() | |
mockWebServer.start() | |
testApis = RetrofitHelper.testApiInstance(mockWebServer.url("/").toString()) | |
repository = UserRepositoryImpl(testApis) | |
} | |
@After | |
fun tearDown() { | |
mockWebServer.shutdown() | |
} | |
@Test | |
fun `for no users, api must return empty with http code 200`() = runTest { | |
val users = emptyList<User>() | |
val expectedResponse = MockResponse() | |
.setResponseCode(HttpURLConnection.HTTP_OK) | |
.setBody(Gson().toJson(users)) | |
mockWebServer.enqueue(expectedResponse) | |
val actualResponse = repository.getAllUsers() | |
assertThat(actualResponse.body).hasSize(0) | |
} | |
@Test | |
fun `for multiple users, api must return all users with http code 200`() = runTest { | |
val users = listOf( | |
User(1,"Saurabh","Delhi","India"), | |
User(2,"Zergain","London","UK"), | |
) | |
val expectedResponse = MockResponse() | |
.setResponseCode(HttpURLConnection.HTTP_OK) | |
.setBody(Gson().toJson(users)) | |
mockWebServer.enqueue(expectedResponse) | |
val actualResponse = repository.getAllUsers() | |
assertThat(actualResponse.body).hasSize(2) | |
assertThat(actualResponse.body).isEqualTo(users) | |
} | |
@Test | |
fun `for server error, api must return with http code 5xx`() = runTest { | |
val expectedResponse = MockResponse() | |
.setResponseCode(HttpURLConnection.HTTP_INTERNAL_ERROR) | |
mockWebServer.enqueue(expectedResponse) | |
val actualResponse = repository.getAllUsers() | |
assertThat(actualResponse.httpCode).isEqualTo(HttpURLConnection.HTTP_INTERNAL_ERROR) | |
} | |
@Test | |
fun `for server error, api must return with http code 5xx and error message`() = runTest { | |
val expectedResponse = MockResponse() | |
.setResponseCode(HttpURLConnection.HTTP_INTERNAL_ERROR) | |
mockWebServer.enqueue(expectedResponse) | |
val actualResponse = repository.getAllUsers() | |
assertThat(actualResponse.httpCode).isEqualTo(HttpURLConnection.HTTP_INTERNAL_ERROR) | |
assertThat(actualResponse.errorMessage).isEqualTo("server error") | |
} | |
@Test | |
fun `for user id, api must return with http code 200 and user object`() = runTest { | |
val mockUser = User(1,"Saurabh","Delhi","India") | |
val expectedResponse = MockResponse() | |
.setResponseCode(HttpURLConnection.HTTP_OK) | |
.setBody(Gson().toJson(mockUser)) | |
mockWebServer.enqueue(expectedResponse) | |
val actualResponse = repository.getUserById(1) | |
assertThat(actualResponse.httpCode).isEqualTo(HttpURLConnection.HTTP_OK) | |
assertThat(actualResponse.errorMessage).isNull() | |
assertThat(actualResponse.body).isEqualTo(mockUser) | |
} | |
@Test | |
fun `for user id not available, api must return with http code 404 and null user object`() = runTest { | |
val expectedResponse = MockResponse() | |
.setResponseCode(HttpURLConnection.HTTP_NOT_FOUND) | |
mockWebServer.enqueue(expectedResponse) | |
val actualResponse = repository.getUserById(1) | |
assertThat(actualResponse.httpCode).isEqualTo(HttpURLConnection.HTTP_NOT_FOUND) | |
assertThat(actualResponse.body).isNull() | |
} | |
} |
Bonus Read
In case you want to read about testing various layers of your app. Do checkout below links.
That is all for now! Stay tuned!
Connect with me on medium(if the content is helpful to you) or on github and subscribe to email to be in sync for further interesting topics on Android/IOS/Backend/Web.
Until next time…
Cheers!
This article was originally published on proandroiddev.com on August 23, 2022