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

[#465] [Compose] Optimize to test "SharedFlow" execution in Composable with Robolectric #466

Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ abstract class BaseViewModel : ViewModel() {
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<IsLoading> = _isLoading

protected val _error = MutableStateFlow<Throwable?>(null)
val error: StateFlow<Throwable?> = _error
protected val _error = MutableSharedFlow<Throwable>()
val error: SharedFlow<Throwable> = _error

protected val _navigator = MutableSharedFlow<AppDestination>()
val navigator: SharedFlow<AppDestination> = _navigator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,14 @@ fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
navigator: (destination: AppDestination) -> Unit,
) {
val context = LocalContext.current
viewModel.error.collectAsEffect { e -> e.showToast(context) }
viewModel.navigator.collectAsEffect { destination -> navigator(destination) }

val isLoading: IsLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val uiModels: List<UiModel> by viewModel.uiModels.collectAsStateWithLifecycle()
val isFirstTimeLaunch: Boolean by viewModel.isFirstTimeLaunch.collectAsStateWithLifecycle()

val context = LocalContext.current
val error: Throwable? by viewModel.error.collectAsStateWithLifecycle()
error?.showToast(context)
LaunchedEffect(isFirstTimeLaunch) {
if (isFirstTimeLaunch) {
context.showToast("This is the first time launch")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import org.junit.runner.Description

@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineTestRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
var testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {

val testDispatcherProvider = object : DispatchersProvider {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package co.nimblehq.sample.compose.ui.screens

import co.nimblehq.sample.compose.test.CoroutineTestRule
import kotlinx.coroutines.test.StandardTestDispatcher

abstract class BaseScreenTest {

protected val coroutinesRule = CoroutineTestRule()

protected fun setStandardTestDispatcher() {
coroutinesRule.testDispatcher = StandardTestDispatcher()
}

protected fun advanceUntilIdle() {
coroutinesRule.testDispatcher.scheduler.advanceUntilIdle()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import androidx.test.rule.GrantPermissionRule
import co.nimblehq.sample.compose.R
import co.nimblehq.sample.compose.domain.model.Model
import co.nimblehq.sample.compose.domain.usecase.*
import co.nimblehq.sample.compose.test.CoroutineTestRule
import co.nimblehq.sample.compose.ui.AppDestination
import co.nimblehq.sample.compose.ui.screens.BaseScreenTest
import co.nimblehq.sample.compose.ui.screens.MainActivity
import co.nimblehq.sample.compose.ui.theme.ComposeTheme
import io.kotest.matchers.shouldBe
Expand All @@ -26,9 +26,7 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.shadows.ShadowToast

@RunWith(RobolectricTestRunner::class)
class HomeScreenTest {

private val coroutinesRule = CoroutineTestRule()
class HomeScreenTest : BaseScreenTest() {

@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
Expand All @@ -42,8 +40,10 @@ class HomeScreenTest {
)

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

private lateinit var viewModel: HomeViewModel
private var expectedAppDestination: AppDestination? = null
Expand All @@ -54,8 +54,6 @@ class HomeScreenTest {
listOf(Model(1), Model(2), Model(3))
)
every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(false)

initViewModel()
}

@Test
Expand All @@ -70,12 +68,14 @@ class HomeScreenTest {

@Test
fun `When entering the Home screen and loading the list item failure, it shows the corresponding error`() {
setStandardTestDispatcher()

val error = Exception()
every { mockGetModelsUseCase() } returns flow { throw error }
initViewModel()

initComposable {
onNodeWithText("Home").assertIsDisplayed()
composeRule.waitForIdle()
advanceUntilIdle()

ShadowToast.showedToast(activity.getString(R.string.error_generic)) shouldBe true
}
Expand All @@ -89,13 +89,15 @@ class HomeScreenTest {
}

private fun initComposable(
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit,
) {
initViewModel()

composeRule.activity.setContent {
ComposeTheme {
HomeScreen(
viewModel = viewModel,
navigator = { destination -> expectedAppDestination = destination }
navigator = { destination -> expectedAppDestination = destination },
)
}
}
Expand Down
24 changes: 12 additions & 12 deletions sample-compose/buildSrc/src/main/java/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,25 @@ object Versions {
const val ANDROID_VERSION_NAME = "3.19.0"

// Dependencies (Alphabet sorted)
const val ACCOMPANIST_PERMISSIONS_VERSION = "0.28.0"
const val ACCOMPANIST_PERMISSIONS_VERSION = "0.30.1"
const val ANDROID_COMMON_KTX_VERSION = "0.1.1"
const val ANDROID_CRYPTO_VERSION = "1.0.0"
const val ANDROIDX_CORE_KTX_VERSION = "1.9.0"
const val ANDROIDX_CORE_KTX_VERSION = "1.10.1"
const val ANDROIDX_DATASTORE_PREFERENCES_VERSION = "1.0.0"
const val ANDROIDX_LIFECYCLE_VERSION = "2.6.0-rc01"
const val ANDROIDX_TEST_CORE_VERSION = "1.4.0"
const val ANDROIDX_LIFECYCLE_VERSION = "2.6.1"

const val CHUCKER_VERSION = "3.5.2"
const val COMPOSE_BOM_VERSION = "2022.12.00"
const val COMPOSE_COMPILER_VERSION = "1.4.3"
const val COMPOSE_BOM_VERSION = "2023.04.01"
const val COMPOSE_COMPILER_VERSION = "1.4.7"
const val COMPOSE_NAVIGATION_VERSION = "2.5.3"

const val HILT_VERSION = "2.44"
const val HILT_NAVIGATION_COMPOSE_VERSION = "1.0.0"

const val JAVAX_INJECT_VERSION = "1"

const val KOTLIN_VERSION = "1.8.10"
const val KOTLINX_COROUTINES_VERSION = "1.6.4"
const val KOTLIN_VERSION = "1.8.21"
const val KOTLINX_COROUTINES_VERSION = "1.7.1"
const val KOVER_VERSION = "0.6.0"

const val MOSHI_VERSION = "1.12.0"
Expand All @@ -43,10 +42,11 @@ object Versions {
const val DETEKT_VERSION = "1.21.0"

// Testing libraries
const val TEST_ANDROIDX_CORE_VERSION = "1.4.0"
const val TEST_JUNIT_VERSION = "4.13.2"
const val TEST_KOTEST_VERSION = "5.5.4"
const val TEST_MOCKK_VERSION = "1.12.3"
const val TEST_ROBOLECTRIC_VERSION = "4.9.2"
const val TEST_KOTEST_VERSION = "5.6.2"
const val TEST_MOCKK_VERSION = "1.13.5"
const val TEST_ROBOLECTRIC_VERSION = "4.10.2"
const val TEST_RULES_VERSION = "1.5.0"
const val TEST_TURBINE_VERSION = "0.12.1"
const val TEST_TURBINE_VERSION = "0.13.0"
}
3 changes: 1 addition & 2 deletions sample-compose/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ android {
minSdk = Versions.ANDROID_MIN_SDK_VERSION
targetSdk = Versions.ANDROID_TARGET_SDK_VERSION

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}

Expand Down Expand Up @@ -81,7 +80,7 @@ dependencies {
testImplementation("io.mockk:mockk:${Versions.TEST_MOCKK_VERSION}")
testImplementation("io.kotest:kotest-assertions-core:${Versions.TEST_KOTEST_VERSION}")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.KOTLINX_COROUTINES_VERSION}")
testImplementation("androidx.test:core:${Versions.ANDROIDX_TEST_CORE_VERSION}")
testImplementation("androidx.test:core:${Versions.TEST_ANDROIDX_CORE_VERSION}")
testImplementation("org.robolectric:robolectric:${Versions.TEST_ROBOLECTRIC_VERSION}")
testImplementation("app.cash.turbine:turbine:${Versions.TEST_TURBINE_VERSION}")
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import org.junit.runner.Description

@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineTestRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
var testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {

val testDispatcherProvider = object : DispatchersProvider {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package co.nimblehq.template.compose.ui.screens

import co.nimblehq.template.compose.test.CoroutineTestRule
import kotlinx.coroutines.test.StandardTestDispatcher

abstract class BaseScreenTest {

protected val coroutinesRule = CoroutineTestRule()

protected fun setStandardTestDispatcher() {
coroutinesRule.testDispatcher = StandardTestDispatcher()
}

protected fun advanceUntilIdle() {
coroutinesRule.testDispatcher.scheduler.advanceUntilIdle()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,81 @@ import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.*
import androidx.test.ext.junit.rules.ActivityScenarioRule
import co.nimblehq.template.compose.R
import co.nimblehq.template.compose.domain.model.Model
import co.nimblehq.template.compose.domain.usecase.UseCase
import co.nimblehq.template.compose.ui.AppDestination
import co.nimblehq.template.compose.ui.screens.BaseScreenTest
import co.nimblehq.template.compose.ui.screens.MainActivity
import co.nimblehq.template.compose.ui.theme.ComposeTheme
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.*
import org.junit.*
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.shadows.ShadowToast

@RunWith(RobolectricTestRunner::class)
class HomeScreenTest {
class HomeScreenTest : BaseScreenTest() {

@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()

private val mockUseCase: UseCase = mockk()

private lateinit var viewModel: HomeViewModel
private var expectedAppDestination: AppDestination? = null

@Before
fun setUp() {
// TODO more setup logic here
every { mockUseCase() } returns flowOf(
listOf(Model(1), Model(2), Model(3))
)
}

@Test
fun `When entering the Home screen, it shows UI correctly`() = initComposable {
onNodeWithText(activity.getString(R.string.app_name)).assertIsDisplayed()
}

@Test
fun `When entering the Home screen and loading the data failure, it shows the corresponding error`() {
setStandardTestDispatcher()

val error = Exception()
every { mockUseCase() } returns flow { throw error }

initComposable {
composeRule.waitForIdle()
advanceUntilIdle()

ShadowToast.showedToast(activity.getString(R.string.error_generic)) shouldBe true
}
}

private fun initComposable(
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit,
) {
initViewModel()

composeRule.activity.setContent {
ComposeTheme {
HomeScreen(
viewModel = viewModel,
navigator = { destination -> expectedAppDestination = destination }
)
}
}
testBody(composeRule)
}

private fun initViewModel() {
viewModel = HomeViewModel(
coroutinesRule.testDispatcherProvider,
mockUseCase,
)
}
}
24 changes: 12 additions & 12 deletions template-compose/buildSrc/src/main/java/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,25 @@ object Versions {
const val ANDROID_VERSION_NAME = "1.0.0"

// Dependencies (Alphabet sorted)
const val ACCOMPANIST_PERMISSIONS_VERSION = "0.28.0"
const val ACCOMPANIST_PERMISSIONS_VERSION = "0.30.1"
const val ANDROID_COMMON_KTX_VERSION = "0.1.1"
const val ANDROID_CRYPTO_VERSION = "1.0.0"
const val ANDROIDX_CORE_KTX_VERSION = "1.9.0"
const val ANDROIDX_CORE_KTX_VERSION = "1.10.1"
const val ANDROIDX_DATASTORE_PREFERENCES_VERSION = "1.0.0"
const val ANDROIDX_LIFECYCLE_VERSION = "2.6.0-rc01"
const val ANDROIDX_TEST_CORE_VERSION = "1.4.0"
const val ANDROIDX_LIFECYCLE_VERSION = "2.6.1"

const val CHUCKER_VERSION = "3.5.2"
const val COMPOSE_BOM_VERSION = "2022.12.00"
const val COMPOSE_COMPILER_VERSION = "1.4.3"
const val COMPOSE_BOM_VERSION = "2023.04.01"
const val COMPOSE_COMPILER_VERSION = "1.4.7"
const val COMPOSE_NAVIGATION_VERSION = "2.5.3"

const val HILT_VERSION = "2.44"
const val HILT_NAVIGATION_COMPOSE_VERSION = "1.0.0"

const val JAVAX_INJECT_VERSION = "1"

const val KOTLIN_VERSION = "1.8.10"
const val KOTLINX_COROUTINES_VERSION = "1.6.4"
const val KOTLIN_VERSION = "1.8.21"
const val KOTLINX_COROUTINES_VERSION = "1.7.1"
const val KOVER_VERSION = "0.6.0"

const val MOSHI_VERSION = "1.12.0"
Expand All @@ -43,9 +42,10 @@ object Versions {
const val DETEKT_VERSION = "1.21.0"

// Testing libraries
const val TEST_ANDROIDX_CORE_VERSION = "1.4.0"
const val TEST_JUNIT_VERSION = "4.13.2"
const val TEST_KOTEST_VERSION = "4.6.3"
const val TEST_MOCKK_VERSION = "1.13.3"
const val TEST_ROBOLECTRIC_VERSION = "4.9.2"
const val TEST_TURBINE_VERSION = "0.12.1"
const val TEST_KOTEST_VERSION = "5.6.2"
const val TEST_MOCKK_VERSION = "1.13.5"
const val TEST_ROBOLECTRIC_VERSION = "4.10.2"
const val TEST_TURBINE_VERSION = "0.13.0"
}
3 changes: 1 addition & 2 deletions template-compose/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ android {
minSdk = Versions.ANDROID_MIN_SDK_VERSION
targetSdk = Versions.ANDROID_TARGET_SDK_VERSION

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}

Expand Down Expand Up @@ -81,7 +80,7 @@ dependencies {
testImplementation("io.mockk:mockk:${Versions.TEST_MOCKK_VERSION}")
testImplementation("io.kotest:kotest-assertions-core:${Versions.TEST_KOTEST_VERSION}")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.KOTLINX_COROUTINES_VERSION}")
testImplementation("androidx.test:core:${Versions.ANDROIDX_TEST_CORE_VERSION}")
testImplementation("androidx.test:core:${Versions.TEST_ANDROIDX_CORE_VERSION}")
testImplementation("org.robolectric:robolectric:${Versions.TEST_ROBOLECTRIC_VERSION}")
testImplementation("app.cash.turbine:turbine:${Versions.TEST_TURBINE_VERSION}")
}