TDD: Create Retrofit Functions in Test Environment

Homan Huang
9 min readApr 14, 2021

--

This is probably the third time I write about Retrofit. It’s easy to create a new Android app today with Retrofit library. You can open the Android Studio, insert your cloud package like this,

Right? They are POJO classes + Service file. Very easy👍, the problem is that you don’t know whether those codes were working. Yeah! You will have this doubt until your app is running. This burden is ❌not OK. That can cause your heart attack or digestion problem. Because you don’t know when you can run your first trial, maybe hours or even days. No Kidding, this doubt can increase your medical 💰expense.

Thanks for Hilt Injection(Done in AndroidTest), we can test the API function right away. Here is the slogan: No delay about the gut feeling in your heart.

— === Menu === —

⚡️1. Gradle
🦄2.
POJO
🗒……………📮 PixBay Exercise — Postman
🌐3. Api Service
🗒……………🔑 RestAPI Authentication
🗒……………🔗 RestAPI Host URL
🗒……………🎏 Retrofit
🗒……………♓️ RestAPI Service
🗒……………😃 Test Option Explain
🔬 4.
Test API Service
🗒……………🚫 Set Permissions
🗒……………🏃 HiltTestRunner
🗒……………⏱ CouroutineTestRule
🗒……………📍 Test Location
🗒……………⏳ Test Download Hits
🗒……………🖼️ Test Image List
🗒……………📡 App Link Test

⚡️1. Gradle …… Menu

Gradle is always the first priority until we have automatic configuration. Here is my test library on the androidTest folder.

  • Hilt:
//region dagger hilt + WorkManager
// android plugin
// id 'kotlin-kapt'
// id 'dagger.hilt.android.plugin'
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"

// For instrumentation tests
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
kaptAndroidTest "com.google.dagger:hilt-compiler:$hilt_version"

// For local unit tests
testImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
kaptTest "com.google.dagger:hilt-compiler:$hilt_version"

// For Workmanager
implementation "androidx.work:work-runtime-ktx:2.5.0"
implementation 'androidx.hilt:hilt-work:1.0.0-beta01'
kapt 'androidx.hilt:hilt-compiler:1.0.0-beta01'
//endregion
  • Retrofit:
//region retrofit + OkHttp + GSON
// Retrofit
def retrofit_version = '2.9.0'
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
// GSON
implementation "com.google.code.gson:gson:2.8.6"
// OkHttp
def OkHttp_version = '4.9.0'
implementation "com.squareup.okhttp3:okhttp:$OkHttp_version"
implementation "com.squareup.okhttp3:logging-interceptor:$OkHttp_version"
//endregion
  • Coroutines:
//region kotlin coroutines
// Kotlin Coroutines
def coroutines_version = '1.4.3'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
//endregion

These shall be enough to inject the retrofit.

🦄2. POJO …… Menu

Here is my process:

Grab the JSON by Postman. Or maybe the your RestApi manual has the JSON sample, you can use it to create the POJO classes.

If you don’t have “Kotlin data class File from JSON”, please install from the plugin.

POJO Example:

📮 PixBay Exercise — Postman …… Menu

Let’s take an exercise from PixBay API.

Doc: https://pixabay.com/api/docs/
Image example — https://pixabay.com/api/?key={ KEY }&q=yellow+flowers&image_type=photo

At first, you need to register a key at PixBay. Next, we can test this command on Postman.

Let’s input your key and search for banana. If everything you input is correct, you shall have this:

Let’s paste the JSON to pojo package.

Their contents don’t suit their filename. We often catch this kind of situation. Let’s rename them by shift+F6: ImagJson.ktHits.kt and Hit.ktImageData.kt.

So you’ll have data path like this: retrofit query →response.body() →hits →ImageData.

👌:Your POJO is ready.

🌐3. Api Service …… Menu

🔑RestAPI Authentication …… Menu

In Project view, either API key or API token, you can put it on the gradle.properties:

(PixBay Exercise → Change to API_KEY)

Edit .gitignore next to the gradle.properties:

This will hide it to the public upload.

In the Gradle.module, please add the new variable for API token or key:
(PixBay Exercise → Change to API_KEY)

// BuildConfig:   Type      Var         Value
buildConfigField("String", "APP_TOKEN", APP_TOKEN )

We can create the interceptor module if your API server uses Token Authentication.

di/ApiInterceptorModule.kt: ( For OkHttp )
(PixBay Exercise → 🚫No need: We need to insert key in query!)

@Module
@InstallIn(SingletonComponent::class)
object ApiInterceptorModule {
@Provides
@Singleton
fun provideInterceptor(): Interceptor = Interceptor { chain ->
val requestBuilder = chain.request().newBuilder()
requestBuilder.header("Content-Type", "application/json")
requestBuilder.header("X-App-Token", APP_TOKEN)
chain.proceed(requestBuilder.build())
}
}

You need to separate all variables and functions into different modules.

🐯: Why?
🤓: So you can test them and replace them with fake data.

🔗 RestAPI Host URL …… Menu

Tips: Don’t make one Big…Big… Module! Which means that you 🤷give up the testing. Or you have to 😓rewrite everything in the test directory when you have to uninstall the only module😵. Are you smart enough? I call it stupid.

di/BaseUrlModule.kt.(PixBay Exercise)

@Module
@InstallIn(SingletonComponent::class)
object BaseUrlModule {
@Provides
@Singleton
fun provideBaseUrl(): String = "https://pixabay.com/"
}

🎏 Retrofit …… Menu

Now, you can form a di/RetrofitModule.kt.

@Module
@InstallIn(SingletonComponent::class)
object RetrofitModule {

// switch to show http log
val HTTP_DEBUG = true
// http timeout in second
val mTimeout = 100L

@Provides
@Singleton
fun provideGson(): Gson = GsonBuilder().setLenient().create()

@Provides
@Singleton
fun provideOkHttpClient(
App_Token_Interceptor: Interceptor
): OkHttpClient =
if (HTTP_DEBUG) {
val logger = HttpLoggingInterceptor()
logger.level = HttpLoggingInterceptor.Level.BODY
OkHttpClient.Builder()
.addInterceptor(logger)
.addInterceptor(App_Token_Interceptor)
.readTimeout(mTimeout, TimeUnit.SECONDS)
.connectTimeout(mTimeout, TimeUnit.SECONDS)
.build()
} else // debug OFF
OkHttpClient.Builder()
.addInterceptor(App_Token_Interceptor)
.readTimeout(mTimeout, TimeUnit.SECONDS)
.connectTimeout(mTimeout, TimeUnit.SECONDS)
.build()

@Provides
@Singleton
fun provideRetrofit(
okHttpClient: OkHttpClient,
gson: Gson,
BaseURL: String
): Retrofit =
Retrofit.Builder()
.baseUrl(BaseURL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()

}
  • PixBay Exercise: Remove App_Token_Interceptor
    The read timeout, 0.1 seconds, which may be too short, please expand it to 1 minute or 6000L.

♓️ RestAPI Service …… Menu

You shall know what kind of data you need after you read the instruction manual. Now, you may need a base interface to handle the response body from the Retrofit interface.

data/remote/service/BaseApiHelper.kt: (Live Template)

interface BaseApiHelper {

suspend fun $name$

}

PixBay Exercise: ( response.body() → Hits? )

interface BaseApiHelper {

suspend fun searchImage(imageString: String): Hits?

}

data/remote/service/PixBayApiService.kt
PixBay Exercise:

interface PixBayApiService {

@GET("/api/")
suspend fun searchForImage(
@Query("q")searchQuery: String,
@Query("key") apiKey: String = BuildConfig.API_KEY
): Response<Hits>

}

If you don’t know the type, you can input ResponseBody👌. Let’s input Hits.

Next, let’s combine both interfaces into one.
PixBay Exercise: PixBayApiHelper.kt

class PixBayApiHelper @Inject constructor(
private val apiService: PixBayApiService
): BaseApiHelper {
}

Let’s implement the member:

override suspend fun searchImage(imageString: String): Hits? {
val response = apiService.searchForImage(imageString)
// http response code
when (response.code()) {
200 -> {
return response.body()
}
else -> {
// trouble, show detail
lge
("Please check error: \n" +
"\t\tmessage: \t${response.message()}\n" +
"\t\turl: \t${response.raw().request.url}")
return null
}
}
}

I am using my logcat shortcut. Please add

LogHelper.kt:

const val TAG = "MLOG"
fun
lgd(s:String) = Log.d(TAG, s)
fun lgi(s:String) = Log.i(TAG, s)
fun lge(s:String) = Log.e(TAG, s)
fun lgv(s:String) = Log.v(TAG, s)
fun lgw(s:String) = Log.w(TAG, s)

You need to add MLOG filter in Logcat,

to check the right log.

Nothing new if you have learned HTTP server. This is a dispatcher which redirects the success body.

di/ApiModule.kt

@Module
@InstallIn(SingletonComponent::class)
object ApiServiceModule {

@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): PixBayApiService =
retrofit.create(PixBayApiService::class.java)

}

di/ApiHelperModule.kt

@Module
@InstallIn(SingletonComponent::class)
object ApiHelperModule {

@Provides
@Singleton
fun provideApiHelper(apiHelper: PixBayApiHelper):
BaseApiHelper = apiHelper

}

Can see the production chain of PixBay Exercise?

BaseUrlModule → RetrofitModule → ApiServiceModule →ApiHelperModule

😃 Test Option Explain …… Menu

The BaseApiHelper can provide you an option to test with fake data. For example,

@ExperimentalCoroutinesApi
@LargeTest
@HiltAndroidTest
@UninstallModules(
ApiHelperModule::class,
...
)
class ManifestLoadDatabaseErrorTest {
...
@Module
@InstallIn(SingletonComponent::class)
class FakeModule {
// fake api helper
@Provides
fun provideApiHelper(apiHelper: FakeApiHelper):
BaseApiHelper = apiHelper

}

And FakeApiHelper.kt,

class FakeApiHelper @Inject constructor(
private val apiService: YourApiService
): BaseApiHelper {

override
suspend fun getSchoolCount(): Int {
return -1
}
...

Simple, isn’t it? You don’t need to think about how to mock stuff. Replacing the original with the fake one is an express service provided by Hilt.

🔬 4. Test API Service …… Menu

I guess that you only take 10 minutes to input all those files. Now let’s check whether they are working fine.

🚫 Set Permissions …… Menu

Api Service is related to Internet and Network service. We need to add the permissions in AndroidManifest.XML.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.homan.huang.pixbaysample">

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />


<application
...

🏃 HiltTestRunner …… Menu

To make things organize, I prefer to collect all tools and function into the same folder. Let’s add a package in androidTest folder: util.

util/HiltTestRunner.kt

class HiltTestRunner: AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
) : Application {
return super.newApplication(
cl,
HiltTestApplication::class.java.name,
context)
}
}

Now, we need to update the runner in Gradle.Module:

//testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner "com.homan.huang.pixbaysample.util.HiltTestRunner"

⏱ CouroutineTestRule …… Menu

util/CoroutineTestRule.kt

@ExperimentalCoroutinesApi
class CoroutineTestRule(
val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {

val testDispatcherProvider = object : DispatcherProvider {
override fun default(): CoroutineDispatcher = testDispatcher
override fun
io(): CoroutineDispatcher = testDispatcher
override fun
main(): CoroutineDispatcher = testDispatcher
override fun
unconfined(): CoroutineDispatcher = testDispatcher
}

override fun starting(description: Description?) {
super.starting(description)
Dispatchers.setMain(testDispatcher)
}

override fun finished(description: Description?) {
super.finished(description)
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
}
}

interface DispatcherProvider {

fun main(): CoroutineDispatcher = Dispatchers.Main
fun
default(): CoroutineDispatcher = Dispatchers.Default
fun
io(): CoroutineDispatcher = Dispatchers.IO
fun
unconfined(): CoroutineDispatcher = Dispatchers.Unconfined

}

class DefaultDispatcherProvider : DispatcherProvider

📍 Test Location …… Menu

The AipService file is located at data/remote. Let’s add a new package in the androidTest folder with the same location.

Now, let’s label the new test with ApiTest.kt.

data/remote/ApiTest.kt

@ExperimentalCoroutinesApi
@HiltAndroidTest
class ApiTest {

@get:Rule(order = 0)
var mGrantPermissionRule =
GrantPermissionRule.grant(
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE"
)

@get:Rule(order = 1)
var hiltRule = HiltAndroidRule(this)

@get:Rule(order = 2)
var instantTaskExecutorRule = InstantTaskExecutorRule()

@get:Rule(order = 3)
var coroutinesTestRule = CoroutineTestRule()

@Inject
lateinit var apiHelper: PixBayApiHelper
@Inject
lateinit var apiService: PixBayApiService

@Before
fun setup() {
hiltRule.inject()
}
}

I have added four rules. You must load them in order.

Let’s add a test case.

⏳ Test Download Hits …… Menu

@Test
fun test_api_service() = runBlocking {
val
body = apiHelper.searchImage("banana")
Truth.assertThat(body).isNotNull()
}

I like bananas. Let’s run. If you encounter an error before this test, you’ll continue with the old error in the AVD. The solution is simple: Shutdown the AVD, wipe data, and restart AVD again.

The “runBlockingTest” will not work on the API service test because it’ll skip all time delays or waitings.

Passed!

🖼️ Test Image List …… Menu

@Test
fun test_image_data() = runBlocking {
val
body = apiHelper.searchImage("banana")
val imageList = body?.hits
Truth.assertThat(imageList?.size).isGreaterThan(1)
}

Passed!

📡 App Link Test …… Menu

We are not perfect human beings. You are lucky to get those tests passed. Let’s try on the bad key we have input: Open the gradle.properties, change the API_KEY=”bad_key”, and sync.

@Test
fun test_api_link() = runBlocking {
val
response = apiService.searchForImage("banana")
val code = response.code()
lgd("code: $code")
lge("Let\'s check: \n" +
"\t\tmessage: \t${response.message()}\n" +
"\t\turl: \t${response.raw().request.url}")
Truth.assertThat(code).isEqualTo(200)
}

Result:

No detail, but we can check Logcat.

2021-04-13 16:50:09.635 D/MLOG: code: 400
2021-04-13 16:50:09.635 E/MLOG: Let's check:
message:
url: https://pixabay.com/api/?q=banana&key=bad_key

You recognize the error right away. This is a great place to test your host address, API key, and parsing problem. So you can perfect your code.

🥳Hope You Have a Great Testing Day!

--

--

Homan Huang
Homan Huang

Written by Homan Huang

Computer Science BS from SFSU. I studied and worked on Android system since 2017. If you are interesting in my past works, please go to my LinkedIn.

No responses yet