Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#610] [Part 1] Update navigation library and refactor template to expose event callback to navigate #611

Draft
wants to merge 1 commit into
base: bug/606-fix-incorrect-imports-in-homescreentest-and-mockutil
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion sample-compose/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ android {
targetSdk = libs.versions.androidTargetSdk.get().toInt()
versionCode = libs.versions.androidVersionCode.get().toInt()
versionName = libs.versions.androidVersionName.get()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner = "co.nimblehq.sample.compose.HiltTestRunner"
}

buildTypes {
Expand Down Expand Up @@ -155,6 +155,9 @@ dependencies {
androidTestImplementation(libs.test.compose.ui.junit4)
androidTestImplementation(libs.test.rules)
androidTestImplementation(libs.test.mockk.android)
androidTestImplementation(libs.test.navigation)
androidTestImplementation(libs.test.hilt.android)
kspAndroidTest(libs.test.hilt.android.kotlin)

// Unable to resolve activity for Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER]
// cmp=co.nimblehq.sample.compose/androidx.activity.ComponentActivity } --
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package co.nimblehq.sample.compose

import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication

class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
Original file line number Diff line number Diff line change
@@ -1,34 +1,45 @@
package co.nimblehq.sample.compose.ui.screens.main.home

import androidx.activity.compose.setContent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.rule.GrantPermissionRule
import co.nimblehq.sample.compose.domain.usecases.GetModelsUseCase
import co.nimblehq.sample.compose.domain.usecases.IsFirstTimeLaunchPreferencesUseCase
import co.nimblehq.sample.compose.domain.usecases.UpdateFirstTimeLaunchPreferencesUseCase
import co.nimblehq.sample.compose.test.MockUtil
import co.nimblehq.sample.compose.test.TestDispatchersProvider
import co.nimblehq.sample.compose.ui.base.BaseDestination
import co.nimblehq.sample.compose.ui.AppNavGraph
import co.nimblehq.sample.compose.ui.screens.MainActivity
import co.nimblehq.sample.compose.ui.screens.main.MainDestination
import co.nimblehq.sample.compose.ui.theme.ComposeTheme
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test

@HiltAndroidTest
class HomeScreenTest {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)

@get:Rule
@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()
private lateinit var navController: TestNavHostController

/**
* More test samples with Runtime Permissions https://alexzh.com/ui-testing-of-android-runtime-permissions/
Expand All @@ -38,24 +49,27 @@ class HomeScreenTest {
android.Manifest.permission.CAMERA
)

private val mockGetModelsUseCase: GetModelsUseCase = mockk()
private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = mockk()
private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = mockk()
private val mockGetModelsUseCase: GetModelsUseCase = mockk(relaxed = true)
private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = mockk(relaxed = true)
private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase =
mockk(relaxed = true)

private lateinit var viewModel: HomeViewModel
private var expectedDestination: BaseDestination? = null
// Cannot mock viewModel with mockk here because it will throw ClassCastException
// Ref: https://github.com/mockk/mockk/issues/321
@BindValue
val viewModel: HomeViewModel = HomeViewModel(
mockGetModelsUseCase,
mockIsFirstTimeLaunchPreferencesUseCase,
mockUpdateFirstTimeLaunchPreferencesUseCase,
TestDispatchersProvider
)

@Before
fun setUp() {
hiltRule.inject()

every { mockGetModelsUseCase() } returns flowOf(MockUtil.models)
every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(false)

viewModel = HomeViewModel(
mockGetModelsUseCase,
mockIsFirstTimeLaunchPreferencesUseCase,
mockUpdateFirstTimeLaunchPreferencesUseCase,
TestDispatchersProvider
)
}

@Test
Expand All @@ -71,23 +85,52 @@ class HomeScreenTest {
}

@Test
fun when_clicking_on_a_list_item__it_navigates_to_Second_screen() = initComposable {
fun when_clicking_on_a_list_item__it_navigates_to_Second_screen() = initComposableNavigation {
onNodeWithText("1").performClick()

assertEquals(expectedDestination, MainDestination.Second)
onNodeWithText("Second").assertIsDisplayed()

navController.currentBackStackEntry?.destination?.hasRoute(MainDestination.Second.route, null)
}

@Test
fun when_long_clicking_on_a_list_item_and_click_edit__it_navigates_to_Third_screen() = initComposableNavigation {
onNodeWithText("1").performTouchInput { longClick() }

onNodeWithText("Edit").performClick()

onNodeWithText("Third").assertIsDisplayed()

navController.currentBackStackEntry?.destination?.hasRoute(MainDestination.Third.route, null)
}

private fun initComposable(
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit
) {
composeRule.activity.setContent {
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(ComposeNavigator())
ComposeTheme {
HomeScreen(
viewModel = viewModel,
navigator = { destination -> expectedDestination = destination }
onNavigateToSecondScreen = {},
onNavigateToThirdScreen = {},
)
}
}
testBody(composeRule)
}

private fun initComposableNavigation(
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit
) {
composeRule.activity.setContent {
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(ComposeNavigator())
ComposeTheme {
AppNavGraph(navController)
}
}
testBody(composeRule)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package co.nimblehq.sample.compose.extensions

import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDeepLink
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.compose.composable
import co.nimblehq.sample.compose.ui.base.BaseAppDestination

/**
* Use this extension or [navigate(BaseAppDestination.Up())] to prevent duplicated navigation events
*/
fun NavHostController.navigateAppDestinationUp() {
navigateTo(BaseAppDestination.Up())
}

/**
* TODO Create new class extend NavHostController then move the related codes to that class
*/
private const val IntervalInMillis: Long = 1000L
private var lastNavigationEventExecutedTimeInMillis: Long = 0L

/**
* Use this extension to prevent duplicated navigation events with the same destination in a short time
*/
private fun NavHostController.throttleNavigation(
appDestination: BaseAppDestination,
onNavigate: () -> Unit,
) {
val currentTime = System.currentTimeMillis()
if (currentBackStackEntry?.destination?.route == appDestination.route
&& (currentTime - lastNavigationEventExecutedTimeInMillis < IntervalInMillis)
) {
return
}
lastNavigationEventExecutedTimeInMillis = currentTime

onNavigate()
}

/**
* Navigate to provided [BaseAppDestination]
* Caution to use this method. This method use savedStateHandle to store the Parcelable data.
* When previousBackstackEntry is popped out from navigation stack, savedStateHandle will return null and cannot retrieve data.
* eg.Login -> Home, the Login screen will be popped from the back-stack on logging in successfully.
*/
fun <T : BaseAppDestination> NavHostController.navigateTo(
appDestination: T,
builder: (NavOptionsBuilder.() -> Unit)? = null,
) = throttleNavigation(appDestination) {
when (appDestination) {
is BaseAppDestination.Up -> {
appDestination.results.forEach { (key, value) ->
previousBackStackEntry?.savedStateHandle?.set(key, value)
}
navigateUp()
}
else -> {
appDestination.parcelableArgument?.let { (key, value) ->
currentBackStackEntry?.savedStateHandle?.set(key, value)
}
navigate(route = appDestination.destination) {
if (builder != null) {
builder()
}
}
}
}
}

private const val NavAnimationDurationInMillis = 300

fun AnimatedContentTransitionScope<NavBackStackEntry>.enterSlideInLeftTransition() =
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(NavAnimationDurationInMillis)
)

fun AnimatedContentTransitionScope<NavBackStackEntry>.exitSlideOutLeftTransition() =
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(NavAnimationDurationInMillis)
)

fun AnimatedContentTransitionScope<NavBackStackEntry>.enterSlideInRightTransition() =
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(NavAnimationDurationInMillis)
)

fun AnimatedContentTransitionScope<NavBackStackEntry>.exitSlideOutRightTransition() =
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(NavAnimationDurationInMillis)
)

fun AnimatedContentTransitionScope<NavBackStackEntry>.enterSlideInUpTransition() =
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Up,
animationSpec = tween(NavAnimationDurationInMillis)
)

fun AnimatedContentTransitionScope<NavBackStackEntry>.exitSlideOutDownTransition() =
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Down,
animationSpec = tween(NavAnimationDurationInMillis)
)

fun NavGraphBuilder.composable(
destination: BaseAppDestination,
deepLinks: List<NavDeepLink> = emptyList(),
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = {
enterSlideInLeftTransition()
},
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = {
exitSlideOutLeftTransition()
},
popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = {
enterSlideInRightTransition()
},
popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = {
exitSlideOutRightTransition()
},
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
) {
composable(
route = destination.route,
arguments = destination.arguments,
deepLinks = deepLinks,
enterTransition = enterTransition,
exitTransition = exitTransition,
popEnterTransition = popEnterTransition,
popExitTransition = popExitTransition,
content = content
)
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package co.nimblehq.sample.compose.ui

import co.nimblehq.sample.compose.ui.base.BaseDestination
import co.nimblehq.sample.compose.ui.base.BaseAppDestination

sealed class AppDestination {

object RootNavGraph : BaseDestination("rootNavGraph")
object RootNavGraph : BaseAppDestination("rootNavGraph")

object MainNavGraph : BaseDestination("mainNavGraph")
object MainNavGraph : BaseAppDestination("mainNavGraph")
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
package co.nimblehq.sample.compose.ui

import androidx.compose.runtime.Composable
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import co.nimblehq.sample.compose.ui.base.BaseDestination
import co.nimblehq.sample.compose.ui.screens.main.mainNavGraph

@Composable
Expand All @@ -22,42 +17,3 @@ fun AppNavGraph(
mainNavGraph(navController = navController)
}
}

fun NavGraphBuilder.composable(
destination: BaseDestination,
content: @Composable (NavBackStackEntry) -> Unit,
) {
composable(
route = destination.route,
arguments = destination.arguments,
deepLinks = destination.deepLinks.map {
navDeepLink {
uriPattern = it
}
},
content = content
)
}

/**
* Navigate to provided [BaseDestination] with a Pair of key value String and Data [parcel]
* Caution to use this method. This method use savedStateHandle to store the Parcelable data.
* When previousBackstackEntry is popped out from navigation stack, savedStateHandle will return null and cannot retrieve data.
* eg.Login -> Home, the Login screen will be popped from the back-stack on logging in successfully.
*/
fun NavHostController.navigate(destination: BaseDestination, parcel: Pair<String, Any?>? = null) {
when (destination) {
is BaseDestination.Up -> {
destination.results.forEach { (key, value) ->
previousBackStackEntry?.savedStateHandle?.set(key, value)
}
navigateUp()
}
else -> {
parcel?.let { (key, value) ->
currentBackStackEntry?.savedStateHandle?.set(key, value)
}
navigate(route = destination.destination)
}
}
}
Loading