Skip to content

Mock Ktor Testing

One of the most popular frameworks used for networking on Android most notably would be Retrofit and is a great library to use with a simple API. Although when it comes to writing a mock network test in Espresso, the solution to mock the network traffic is running a MockWebServer or any other JVM server of your choice. Instead of running a separate server, what if the networking library used could swap out the underlying engine executing the Http calls with a mock engine? That is exactly what Ktor from Jetbrains can do with the MockEngine and will be used to write a mock network Espresso test.

Architecture

Working with the previous architecture, a Ktor HttpClient will be injected into the LoginDataSource except the test will inject the MockEngine.

Adding Ktor and KotlinxSerialization

// build.gradle 

buildscript {
    ext.kotlin_version = "1.6.0"
    ext.ktor_version = "1.6.7"

    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
    }
}

// app/build.gradle   
plugins {
    id 'org.jetbrains.kotlin.plugin.serialization'
}

dependencies {

    // Ktor
    implementation "io.ktor:ktor-client-android:$ktor_version"
    implementation "io.ktor:ktor-client-serialization-jvm:$ktor_version"
    implementation "io.ktor:ktor-client-json:$ktor_version"
    androidTestImplementation "io.ktor:ktor-client-mock:$ktor_version"

    // Kotlinx Serialization - JSON
    implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0"

}

Create an Http Client

@Module
@InstallIn(SingletonComponent::class)
object HttpEngineModule {
    @Provides
    @Singleton
    fun providesHttpEngine(): HttpClientEngine = Android.create()
}

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

    @Provides
    @Singleton
    fun providesHttpClient(httpClientEngine: HttpClientEngine): HttpClient =
        HttpClient(httpClientEngine) {
            install(JsonFeature) {
                serializer = KotlinxSerializer()
            }
        }

}

Implement the API Call

@Serializable
data class LoginRequest(val username: String, val password: String)

@Serializable
data class LoginResponse(val id: String, val displayName: String)

@Serializable
data class LoginError(val errorCode: Int, val message: String)

class LoginDataSource @Inject constructor(private val httpClient: HttpClient) {

    companion object {
        const val LOGIN_URL = "https://plusmobileapps.com/login"
    }

    suspend fun login(email: String, password: String): Result<LoggedInUser> = withContext(Dispatchers.IO) {
        try {
            val response = httpClient.post<LoginResponse>(LOGIN_URL) {
                contentType(ContentType.Application.Json)
                body = LoginRequest(email, password)
            }
            val user = LoggedInUser(response.id, response.displayName)
            Result.Success(user)
        } catch (e: Throwable) {
            val errorMessage = if (e is ClientRequestException) {
                val response = e.response.readText(Charsets.UTF_8)
                val error = Json.decodeFromString<LoginError>(response)
                error.message
            } else {
                "Don't know the error"
            }
            Result.Error(IOException(errorMessage, e))
        }
    }
}

Write a MockNetworkHelper

In order to keep the tests a tidy, it would help to write a MockNetworkHelper to abstract away and consolidate the logic for responding to specific endpoints.

class MockNetworkTestHelper {

    val httpClientEngine: HttpClientEngine = MockEngine { request ->
        when (request.url.fullUrl) {
            LoginDataSource.LOGIN_URL -> getLoginResponse.invoke(this, request)
            else -> error("Unhandled ${request.url.fullUrl}")
        }
    }

    private var getLoginResponse: MockRequestHandler = defaultLoginResponseHandler

    fun everyLoginReturns(response: MockRequestHandler) {
        this.getLoginResponse = response
    }

    fun destroy() {
        getLoginResponse = defaultLoginResponseHandler
    }

    companion object {
        val defaultLoginResponseHandler: MockRequestHandler = {
            respond(
                Json.encodeToString(LoginResponse("default-id", "Buzz Killington")),
                HttpStatusCode.OK,
                headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
            )
        }
    }
}

val Url.hostWithPortIfRequired: String get() = if (port == protocol.defaultPort) host else hostWithPort
val Url.fullUrl: String get() = "${protocol.name}://$hostWithPortIfRequired$fullPath"

Write the test

@UninstallModules(HttpEngineModule::class)
@HiltAndroidTest
class MockNetworkLoginTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    private val networkHelper = MockNetworkTestHelper()

    @BindValue
    @JvmField
    val mockClient: HttpClientEngine = networkHelper.httpClientEngine

    private val username = "andrew"
    private val password = "password123"
    private val displayName = "Buzz Killington"

    @After
    fun tearDown() {
        networkHelper.destroy()
    }

    @Test
    fun successfulLogin() {
        networkHelper.everyLoginReturns {
            respond(
                Json.encodeToString(LoginResponse("first-id", displayName)),
                HttpStatusCode.OK,
                headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
            )
        }

        val activityScenario = launchActivity<LoginActivity>()

        startOnPage<LoginPage> {
            enterInfo(username, password)
        }.goToLoggedInPage {
            onWelcomeGreeting().verifyText("Welcome $displayName!")
        }.goToSettings()

        activityScenario.close()
    }

    @Test
    fun errorLogin() {
        val expectedError = LoginError(1, "There was an error")
        networkHelper.everyLoginReturns {
            respond(
                Json.encodeToString(LoginError.serializer(), expectedError),
                HttpStatusCode.BadRequest
            )
        }

        val activityScenario = launchActivity<LoginActivity>()

        startOnPage<LoginPage> {
            enterInfo(username, password)
            onSignInOrRegisterButton().click()
            onErrorMessage().verifyText(expectedError.message).verifyVisible()
        }

        activityScenario.close()
    }
}

Counting Idling Resource

@HiltViewModel
class LoginViewModel @Inject constructor(
    private val loginRepository: LoginRepository,
    private val idlingResource: CountingIdlingResource
) : ViewModel() {

    fun login(email: String, password: String) {
        idlingResource.increment()
        viewModelScope.launch {
            // delay is just for local development to ensure espresso waits for job to finish
            delay(2_000)

            // can be launched in a separate asynchronous job
            val result = loginRepository.login(email, password)

            when (result) {
                is Result.Error -> LoginResult(errorString = result.exception.message)
                is Result.Success -> LoginResult(success = LoggedInUserView(displayName = result.data.displayName))
            }.let { _loginResult.value = it }

            idlingResource.decrement()
        }
    }
}

Resources