Skip to content

Commit

Permalink
6 - add ktor, kotlinx.serialization, and add mock network test
Browse files Browse the repository at this point in the history
  • Loading branch information
plusmobileapps committed Dec 17, 2021
1 parent 8c2bbe3 commit 8e5766b
Show file tree
Hide file tree
Showing 11 changed files with 249 additions and 44 deletions.
11 changes: 11 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ plugins {
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
id 'org.jetbrains.kotlin.plugin.allopen'
id 'org.jetbrains.kotlin.plugin.serialization'

}

allOpen {
Expand Down Expand Up @@ -64,4 +66,13 @@ dependencies {
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"

// 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"

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.plusmobileapps.kotlinopenespresso.test

import androidx.test.core.app.launchActivity
import com.plusmobileapps.kotlinopenespresso.data.model.LoginError
import com.plusmobileapps.kotlinopenespresso.data.model.LoginResponse
import com.plusmobileapps.kotlinopenespresso.di.HttpClientModule
import com.plusmobileapps.kotlinopenespresso.di.HttpEngineModule
import com.plusmobileapps.kotlinopenespresso.extensions.click
import com.plusmobileapps.kotlinopenespresso.extensions.startOnPage
import com.plusmobileapps.kotlinopenespresso.extensions.verifyText
import com.plusmobileapps.kotlinopenespresso.extensions.verifyVisible
import com.plusmobileapps.kotlinopenespresso.page.LoginPage
import com.plusmobileapps.kotlinopenespresso.ui.login.LoginActivity
import com.plusmobileapps.kotlinopenespresso.util.MockNetworkTestHelper
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import io.ktor.client.*
import io.ktor.client.engine.*
import io.ktor.client.engine.mock.*
import io.ktor.http.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.After
import org.junit.Rule
import org.junit.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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.plusmobileapps.kotlinopenespresso.ui.login.LoginActivity
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import org.junit.Rule
Expand Down Expand Up @@ -75,6 +76,6 @@ class MockkLoginTest {
}

private fun everyLoginReturns(result: () -> Result<LoggedInUser>) {
every { loginDataSource.login(email, password) } returns result()
coEvery { loginDataSource.login(email, password) } returns result()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.plusmobileapps.kotlinopenespresso.util

import com.plusmobileapps.kotlinopenespresso.data.LoginDataSource
import com.plusmobileapps.kotlinopenespresso.data.model.LoginResponse
import io.ktor.client.*
import io.ktor.client.engine.*
import io.ktor.client.engine.mock.*
import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

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"
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,48 @@ package com.plusmobileapps.kotlinopenespresso.data

import com.plusmobileapps.kotlinopenespresso.OpenForTest
import com.plusmobileapps.kotlinopenespresso.data.model.LoggedInUser
import com.plusmobileapps.kotlinopenespresso.data.model.LoginError
import com.plusmobileapps.kotlinopenespresso.data.model.LoginRequest
import com.plusmobileapps.kotlinopenespresso.data.model.LoginResponse
import io.ktor.client.*
import io.ktor.client.features.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import java.io.IOException
import javax.inject.Inject

/**
* Class that handles authentication w/ login credentials and retrieves user information.
*/
@OpenForTest
class LoginDataSource @Inject constructor() {
class LoginDataSource @Inject constructor(private val httpClient: HttpClient) {

fun login(email: String, password: String): Result<LoggedInUser> {
companion object {
const val LOGIN_URL = "https://plusmobileapps.com/login"
}

suspend fun login(email: String, password: String): Result<LoggedInUser> = withContext(Dispatchers.IO) {
try {
// TODO: handle loggedInUser authentication
val fakeUser = LoggedInUser(java.util.UUID.randomUUID().toString(), "Andrew")
return Result.Success(fakeUser)
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) {
return Result.Error(IOException("Error logging in", e))
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))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class LoginRepository @Inject constructor(val dataSource: LoginDataSource) {
dataSource.logout()
}

fun login(username: String, password: String): Result<LoggedInUser> {
suspend fun login(username: String, password: String): Result<LoggedInUser> {
// handle login
val result = dataSource.login(username, password)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.plusmobileapps.kotlinopenespresso.data.model

import kotlinx.serialization.Serializable

@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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.plusmobileapps.kotlinopenespresso.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.ktor.client.*
import io.ktor.client.engine.*
import io.ktor.client.engine.android.*
import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.*
import javax.inject.Singleton

@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()
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import android.util.Patterns
import androidx.lifecycle.viewModelScope
import com.plusmobileapps.kotlinopenespresso.data.LoginRepository
import com.plusmobileapps.kotlinopenespresso.data.Result

import com.plusmobileapps.kotlinopenespresso.R
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
Expand All @@ -20,15 +22,17 @@ class LoginViewModel @Inject constructor(private val loginRepository: LoginRepos
private val _loginResult = MutableLiveData<LoginResult>()
val loginResult: LiveData<LoginResult> = _loginResult

fun login(username: String, password: String) {
// can be launched in a separate asynchronous job
val result = loginRepository.login(username, password)

if (result is Result.Success) {
_loginResult.value =
LoginResult(success = LoggedInUserView(displayName = result.data.displayName))
} else if (result is Result.Error) {
_loginResult.value = LoginResult(errorString = result.exception.message)
fun login(email: String, password: String) {
viewModelScope.launch {
// can be launched in a separate asynchronous job
val result = loginRepository.login(email, password)

if (result is Result.Success) {
_loginResult.value =
LoginResult(success = LoggedInUserView(displayName = result.data.displayName))
} else if (result is Result.Error) {
_loginResult.value = LoginResult(errorString = result.exception.message)
}
}
}

Expand Down

This file was deleted.

7 changes: 5 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {

ext.kotlin_version = "1.5.31"
ext.kotlin_version = "1.6.0"
ext.hilt_version = "2.38.1"
ext.espresso_version = "3.4.0"
ext.ktor_version = "1.6.7"

repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:7.0.2"
classpath "com.android.tools.build:gradle:7.0.4"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"


// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
Expand Down

0 comments on commit 8e5766b

Please sign in to comment.