TDD: Create Retrofit Functions in Test Environment
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.kt → Hits.kt and Hit.kt → ImageData.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.