Skip to content

Commit

Permalink
Merge pull request #399 from nimblehq/feature/354-add-sample-ui-test-…
Browse files Browse the repository at this point in the history
…with-robolectric

[#354] [Compose] 2/2: Add sample UI test with Robolectric
  • Loading branch information
ryan-conway authored Feb 22, 2023
2 parents 931baf5 + 60e6487 commit 5a037e8
Show file tree
Hide file tree
Showing 14 changed files with 196 additions and 52 deletions.
12 changes: 11 additions & 1 deletion sample-compose/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ android {
}

testOptions {
unitTests {
// Robolectric resource processing/loading https://github.com/robolectric/robolectric/pull/4736
isIncludeAndroidResources = true
}
unitTests.all {
if (it.name != "testStagingDebugUnitTest") {
it.extensions.configure(kotlinx.kover.api.KoverTaskExtension::class) {
Expand Down Expand Up @@ -160,7 +164,13 @@ dependencies {
testImplementation("io.mockk:mockk:${Versions.TEST_MOCKK_VERSION}")
testImplementation("app.cash.turbine:turbine:${Versions.TEST_TURBINE_VERSION}")

// Instrument test
// UI test with Robolectric
testImplementation(platform("androidx.compose:compose-bom:${Versions.COMPOSE_BOM_VERSION}"))
testImplementation("androidx.compose.ui:ui-test-junit4")
testImplementation("androidx.test:rules:${Versions.TEST_RULES_VERSION}")
testImplementation("org.robolectric:robolectric:${Versions.TEST_ROBOLECTRIC_VERSION}")

// UI test
androidTestImplementation(platform("androidx.compose:compose-bom:${Versions.COMPOSE_BOM_VERSION}"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("androidx.test:rules:${Versions.TEST_RULES_VERSION}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ package co.nimblehq.sample.compose.ui.screens.home

import androidx.activity.compose.setContent
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.rule.GrantPermissionRule
import co.nimblehq.sample.compose.domain.model.Model
import co.nimblehq.sample.compose.domain.usecase.UseCase
Expand Down Expand Up @@ -66,7 +67,9 @@ class HomeScreenTest {
assertEquals(expectedAppDestination, AppDestination.Second)
}

private fun initComposable(testBody: ComposeContentTestRule.() -> Unit) {
private fun initComposable(
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit
) {
composeRule.activity.setContent {
ComposeTheme {
HomeScreen(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ abstract class BaseViewModel(private val dispatchersProvider: DispatchersProvide
val isLoading: StateFlow<IsLoading>
get() = _isLoading

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

protected val _navigator = MutableSharedFlow<AppDestination>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import co.nimblehq.sample.compose.ui.screens.AppBar
import co.nimblehq.sample.compose.ui.theme.ComposeTheme
import co.nimblehq.sample.compose.ui.userReadableMessage
import com.google.accompanist.permissions.*
import kotlinx.coroutines.flow.*

@Composable
fun HomeScreen(
Expand All @@ -30,11 +31,9 @@ fun HomeScreen(
val isFirstTimeLaunch: Boolean by viewModel.isFirstTimeLaunch.collectAsState()

val context = LocalContext.current
LaunchedEffect(viewModel.error) {
viewModel.error.collect { error ->
val message = error.userReadableMessage(context)
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
val error: Throwable? by viewModel.error.collectAsState()
error?.let {
Toast.makeText(context, it.userReadableMessage(context), Toast.LENGTH_SHORT).show()
}
LaunchedEffect(isFirstTimeLaunch) {
if (isFirstTimeLaunch) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package co.nimblehq.sample.compose.ui.screens.home

import androidx.activity.compose.setContent
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.rules.ActivityScenarioRule
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.UseCase
import co.nimblehq.sample.compose.test.CoroutineTestRule
import co.nimblehq.sample.compose.ui.AppDestination
import co.nimblehq.sample.compose.ui.screens.MainActivity
import co.nimblehq.sample.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.Assert.assertEquals
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.shadows.ShadowToast

@RunWith(RobolectricTestRunner::class)
class HomeScreenTest {

private val coroutinesRule = CoroutineTestRule()

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

/**
* More test samples with Runtime Permissions https://alexzh.com/ui-testing-of-android-runtime-permissions/
*/
@get:Rule
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
android.Manifest.permission.CAMERA
)

private val mockUseCase: UseCase = mockk()

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

@Before
fun setUp() {
every { mockUseCase() } returns flowOf(
listOf(Model(1), Model(2), Model(3))
)

initViewModel()
}

@Test
fun `When entering the Home screen and loading the list item successfully, it shows the list item correctly`() =
initComposable {
onNodeWithText("Home").assertIsDisplayed()

onNodeWithText("1").assertIsDisplayed()
onNodeWithText("2").assertIsDisplayed()
onNodeWithText("3").assertIsDisplayed()
}

@Test
fun `When entering the Home screen and loading the list item failure, it shows the corresponding error`() {
val error = Exception()
every { mockUseCase() } returns flow { throw error }
initViewModel()

initComposable {
onNodeWithText("Home").assertIsDisplayed()

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

@Test
fun `When clicking on a list item, it navigates to Second screen`() = initComposable {
onNodeWithText("1").performClick()

assertEquals(expectedAppDestination, AppDestination.Second)
}

private fun initComposable(
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit
) {
composeRule.activity.setContent {
ComposeTheme {
HomeScreen(
viewModel = viewModel,
navigator = { destination -> expectedAppDestination = destination }
)
}
}
testBody(composeRule)
}

private fun initViewModel() {
viewModel = HomeViewModel(
mockUseCase,
coroutinesRule.testDispatcherProvider
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ class HomeViewModelTest {
}

@Test
fun `when loading models successfully, it shows the model list`() = runTest {
fun `When loading models successfully, it shows the model list`() = runTest {
viewModel.uiModels.test {
expectMostRecentItem() shouldBe models.map { it.toUiModel() }
}
}

@Test
fun `when loading models failed, it shows the corresponding error`() = runTest {
fun `When loading models failed, it shows the corresponding error`() = runTest {
val error = Exception()
every { mockUseCase() } returns flow { throw error }
initViewModel(dispatchers = CoroutineTestRule(StandardTestDispatcher()).testDispatcherProvider)
Expand All @@ -67,7 +67,7 @@ class HomeViewModelTest {
}

@Test
fun `when calling navigate to Second, it navigates to Second screen`() = runTest {
fun `When calling navigate to Second, it navigates to Second screen`() = runTest {
viewModel.navigator.test {
viewModel.navigateToSecond(models[0].toUiModel())

Expand Down
3 changes: 3 additions & 0 deletions sample-compose/app/src/test/resources/robolectric.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Workaround for issue https://github.com/robolectric/robolectric/issues/6593
instrumentedPackages=androidx.loader.content
sdk=30
1 change: 1 addition & 0 deletions sample-compose/buildSrc/src/main/java/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ object Versions {
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_RULES_VERSION = "1.5.0"
const val TEST_TURBINE_VERSION = "0.12.1"
}
11 changes: 8 additions & 3 deletions template-compose/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ android {
}

testOptions {
unitTests {
// Robolectric resource processing/loading https://github.com/robolectric/robolectric/pull/4736
isIncludeAndroidResources = true
}
unitTests.all {
if (it.name != "testStagingDebugUnitTest") {
it.extensions.configure(kotlinx.kover.api.KoverTaskExtension::class) {
Expand Down Expand Up @@ -151,7 +155,8 @@ dependencies {
testImplementation("io.mockk:mockk:${Versions.TEST_MOCKK_VERSION}")
testImplementation("app.cash.turbine:turbine:${Versions.TEST_TURBINE_VERSION}")

// Instrument test
androidTestImplementation(platform("androidx.compose:compose-bom:${Versions.COMPOSE_BOM_VERSION}"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
// UI test with Robolectric
testImplementation(platform("androidx.compose:compose-bom:${Versions.COMPOSE_BOM_VERSION}"))
testImplementation("androidx.compose.ui:ui-test-junit4")
testImplementation("org.robolectric:robolectric:${Versions.TEST_ROBOLECTRIC_VERSION}")
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package co.nimblehq.template.compose.ui.screens.home

import androidx.activity.compose.setContent
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.ui.AppDestination
import co.nimblehq.template.compose.ui.screens.MainActivity
import co.nimblehq.template.compose.ui.theme.ComposeTheme
import org.junit.*
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class HomeScreenTest {

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

private var expectedAppDestination: AppDestination? = null

@Before
fun setUp() {
// TODO more setup logic here
}

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

private fun initComposable(
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit
) {
composeRule.activity.setContent {
ComposeTheme {
HomeScreen(
navigator = { destination -> expectedAppDestination = destination }
)
}
}
testBody(composeRule)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,14 @@ class HomeViewModelTest {
}

@Test
fun `when loading models successfully, it shows the model list`() = runTest {
fun `When loading models successfully, it shows the model list`() = runTest {
viewModel.uiModels.test {
expectMostRecentItem() shouldBe models.map { it.toUiModel() }
}
}

@Test
fun `when loading models failed, it shows the corresponding error`() = runTest {
fun `When loading models failed, it shows the corresponding error`() = runTest {
val error = Exception()
every { mockUseCase() } returns flow { throw error }
initViewModel(dispatchers = CoroutineTestRule(StandardTestDispatcher()).testDispatcherProvider)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Workaround for issue https://github.com/robolectric/robolectric/issues/6593
instrumentedPackages=androidx.loader.content
sdk=30
1 change: 1 addition & 0 deletions template-compose/buildSrc/src/main/java/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,6 @@ object Versions {
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"
}

0 comments on commit 5a037e8

Please sign in to comment.