From 0e314b24da099ca29db8d9d37881b4133f1e9653 Mon Sep 17 00:00:00 2001 From: Diego Beraldin Date: Wed, 28 Feb 2024 18:07:19 +0100 Subject: [PATCH] chore: add test in core-navigation (#558) --- CONTRIBUTING.md | 102 +++++++- core/navigation/build.gradle.kts | 10 +- .../DefaultDrawerCoordinatorTest.kt | 67 +++++ .../DefaultNavigationCoordinatorTest.kt | 235 ++++++++++++++++++ .../core/testutils/DispatcherTestRule.kt | 4 +- gradle/libs.versions.toml | 2 + 6 files changed, 415 insertions(+), 5 deletions(-) create mode 100644 core/navigation/src/androidUnitTest/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/navigation/DefaultDrawerCoordinatorTest.kt create mode 100644 core/navigation/src/androidUnitTest/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/navigation/DefaultNavigationCoordinatorTest.kt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d102244f..a2e432f76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -343,4 +343,104 @@ As far as Compose code is concerned, we take Google’s indications as a baselin ### 6.3 Test structure -TBD \ No newline at end of file +#### 6.3.1 Unit tests + +Unit test are targeted as a single unit of code: the test class will have the same name of the +component under test, followed by the `Test` suffix and will be placed in the same package, within +the `androidUnitTest` source set. The tests will be platform specific for now, since multi-platform +tests under the `commonTest` set require some additional setup and a considerable amount of extra +effort. + +For every subject under test (SUT), the dependencies will be doubled using test mocks created using +the `mockk` library, and the assertions on flows and channels will be made using the `turbine` +library. + +Each test class will contain at least one method annotated with `@Test` and, especially if it +contains suspending functions, its body will be wrapped in a `runTest` scope function (better to +always include it). In order for suspending functions to be called in the correct coroutine +context (where the main thread of Android is replaced by an `Unconfined` dispatcher), the JUnit rule +`DispatcherTestRule` defined in :core:testutils should be included and annotated with `@get:Rule`. + +Each test method shall consider a single interaction (W) on the SUT that happens in a +precondition (G) and should produce a result (T) against which some assertions will be performed. + +These three elements will be reflected in the method name, which shall have the GWT form, i.e.: +```givenX_whenY_thenZ``` + +The body of the method will be therefore divided into three parts (separated by a blank line): + +- (optional) precondition: mock setup with predefined answers to invocations; +- interaction: the method/property to test will be invoked/interacted with on the SUT; +- result: one or more assertions on the result got in the previous step; optionally this part will + also contains some verification on the mocks/spies to make sure the proper interactions have or + have not happened according to the expectations in the previous step. + +Reference unit test: + +```kotlin +class DefaultNavigationCoordinatorTest { + + // coroutine test rule (must be a public property) + @get:Rule + val dispatcherRule = DispatcherTestRule() + + // subject under test + private val sut = DefaultNavigationCoordinator() + + // test method (1) + @Test + fun givenNavigatorCanPop_whenRootNavigatorSet_thenCanPopIsUpdated() = runTest { + // setup + val navigator = mockk { + every { canPop } returns true + } + + // interaction + sut.setRootNavigator(navigator) + + // assertions + val value = sut.canPop.value + assertTrue(value) + } + + // test method (2) + @Test + fun whenChangeTab_thenCurrentTabIsUpdated() = runTest { + // setup with capturing slots + val tabSlot = slot() + val navigator = mockk(relaxUnitFun = true) { + every { current = capture(tabSlot) } answers {} + } + val tab = object : Tab { + override val options @Composable get() = TabOptions(index = 0u, "title") + + @Composable + override fun Content() { + Box(modifier = Modifier.fillMaxSize()) + } + } + sut.setTabNavigator(navigator) + + // interaction + sut.changeTab(tab) + + // assertions + val value = tabSlot.captured + assertEquals(tab, value) + } + + // test method (3) + @Test + fun whenSubmitDeeplink_thenValueIsEmitted() = runTest { + val url = "deeplink-url" + // interaction + sut.submitDeeplink(url) + + // assertions on the flow with turbine's test extensions + sut.deepLinkUrl.test { + val value = awaitItem() + assertEquals(url, value) + } + } +} +``` \ No newline at end of file diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index c73eccd86..5ece8c272 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -43,8 +43,14 @@ kotlin { implementation(projects.core.persistence) implementation(projects.domain.lemmy.data) } - commonTest.dependencies { - implementation(kotlin("test")) + val androidUnitTest by getting { + dependencies { + implementation(libs.kotlinx.coroutines.test) + implementation(kotlin("test-junit")) + implementation(libs.mockk) + implementation(libs.turbine) + implementation(projects.core.testutils) + } } } } diff --git a/core/navigation/src/androidUnitTest/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/navigation/DefaultDrawerCoordinatorTest.kt b/core/navigation/src/androidUnitTest/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/navigation/DefaultDrawerCoordinatorTest.kt new file mode 100644 index 000000000..0d29967ce --- /dev/null +++ b/core/navigation/src/androidUnitTest/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/navigation/DefaultDrawerCoordinatorTest.kt @@ -0,0 +1,67 @@ +package com.github.diegoberaldin.raccoonforlemmy.core.navigation + +import app.cash.turbine.test +import com.github.diegoberaldin.raccoonforlemmy.core.testutils.DispatcherTestRule +import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.CommunityModel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DefaultDrawerCoordinatorTest { + + @get:Rule + val dispatcherRule = DispatcherTestRule() + + private val sut = DefaultDrawerCoordinator() + + @Test + fun whenToggled_thenEventIsEmitted() = runTest { + launch { + sut.toggleDrawer() + } + + sut.events.test { + val evt = awaitItem() + assertEquals(DrawerEvent.Toggle, evt) + } + } + + @Test + fun whenClosed_thenEventIsEmitted() = runTest { + launch { + sut.closeDrawer() + } + + sut.events.test { + val evt = awaitItem() + assertEquals(DrawerEvent.Close, evt) + } + } + + @Test + fun whenSetGesturesEnabled_thenStateIsUpdated() = runTest { + val initial = sut.gesturesEnabled.value + assertTrue(initial) + + sut.setGesturesEnabled(false) + + val value = sut.gesturesEnabled.value + assertFalse(value) + } + + @Test + fun whenSendEvent_thenEventIsEmitted() = runTest { + val community = CommunityModel(id = 0, name = "test") + launch { + sut.sendEvent(DrawerEvent.OpenCommunity(community)) + } + sut.events.test { + val evt = awaitItem() + assertEquals(DrawerEvent.OpenCommunity(community), evt) + } + } +} \ No newline at end of file diff --git a/core/navigation/src/androidUnitTest/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/navigation/DefaultNavigationCoordinatorTest.kt b/core/navigation/src/androidUnitTest/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/navigation/DefaultNavigationCoordinatorTest.kt new file mode 100644 index 000000000..9305291a8 --- /dev/null +++ b/core/navigation/src/androidUnitTest/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/navigation/DefaultNavigationCoordinatorTest.kt @@ -0,0 +1,235 @@ +package com.github.diegoberaldin.raccoonforlemmy.core.navigation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.cash.turbine.test +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.bottomSheet.BottomSheetNavigator +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabNavigator +import cafe.adriel.voyager.navigator.tab.TabOptions +import com.github.diegoberaldin.raccoonforlemmy.core.testutils.DispatcherTestRule +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DefaultNavigationCoordinatorTest { + + @get:Rule + val dispatcherRule = DispatcherTestRule() + + private val sut = DefaultNavigationCoordinator() + + @Test + fun whenSetCurrentSection_thenValueIsUpdated() = runTest { + val initial = sut.currentSection.value + assertNull(initial) + + sut.setCurrentSection(TabNavigationSection.Settings) + + val value = sut.currentSection.value + assertEquals(TabNavigationSection.Settings, value) + } + + @Test + fun whenSetCurrentSectionTwice_thenOnDoubleTabSelectionTriggered() = runTest { + sut.setCurrentSection(TabNavigationSection.Profile) + launch { + delay(250) + sut.setCurrentSection(TabNavigationSection.Profile) + } + sut.onDoubleTabSelection.test { + val section = awaitItem() + assertEquals(TabNavigationSection.Profile, section) + } + } + + @Test + fun givenNavigatorCanPop_whenRootNavigatorSet_thenCanPopIsUpdated() = runTest { + val initial = sut.canPop.value + assertFalse(initial) + val navigator = mockk { + every { canPop } returns true + } + + sut.setRootNavigator(navigator) + + val value = sut.canPop.value + assertTrue(value) + } + + @Test + fun whenSubmitDeeplink_thenValueIsEmitted() = runTest { + val url = "deeplink-url" + sut.submitDeeplink(url) + + sut.deepLinkUrl.test { + val value = awaitItem() + assertEquals(url, value) + } + } + + @Test + fun whenSetInboxUnread_thenValueIsUpdated() = runTest { + val count = 5 + sut.setInboxUnread(count) + val value = sut.inboxUnread.value + assertEquals(count, value) + } + + @Test + fun whenSetExitMessageVisible_thenValueIsUpdated() = runTest { + val initial = sut.exitMessageVisible.value + assertFalse(initial) + + sut.setExitMessageVisible(true) + val value = sut.exitMessageVisible.value + assertTrue(value) + } + + @Test + fun whenChangeTab_thenCurrentTabIsUpdated() = runTest { + val tabSlot = slot() + val navigator = mockk(relaxUnitFun = true) { + every { current = capture(tabSlot) } answers {} + } + val tab = object : Tab { + override val options @Composable get() = TabOptions(index = 0u, "title") + + @Composable + override fun Content() { + Box(modifier = Modifier.fillMaxSize()) + } + } + sut.setTabNavigator(navigator) + + sut.changeTab(tab) + + val value = tabSlot.captured + assertEquals(tab, value) + } + + @Test + fun whenPushScreen_thenInteractionsAreAsExpected() = runTest { + val previous = object : Screen { + + override val key: ScreenKey = "old" + + @Composable + override fun Content() { + Box(modifier = Modifier.fillMaxSize()) + } + } + val screen = object : Screen { + + override val key: ScreenKey = "new" + + @Composable + override fun Content() { + Box(modifier = Modifier.fillMaxSize()) + } + } + val navigator = mockk(relaxUnitFun = true) { + every { canPop } returns true + every { lastItem } returns previous + } + sut.setRootNavigator(navigator) + + sut.pushScreen(screen) + + val canPop = sut.canPop.value + assertTrue(canPop) + verify { + navigator.push(screen) + } + } + + @Test + fun whenPushScreenTwice_thenInteractionsAreAsExpected() = runTest { + val screen = object : Screen { + + override val key: ScreenKey = "" + + @Composable + override fun Content() { + Box(modifier = Modifier.fillMaxSize()) + } + } + val navigator = mockk(relaxUnitFun = true) { + every { canPop } returns true + every { lastItem } returns screen + } + sut.setRootNavigator(navigator) + + sut.pushScreen(screen) + + val canPop = sut.canPop.value + assertTrue(canPop) + verify(inverse = true) { + navigator.push(screen) + } + } + + @Test + fun whenPopScreen_thenInteractionsAreAsExpected() = runTest { + val navigator = mockk(relaxUnitFun = true) { + every { pop() } returns true + every { canPop } returns false + } + sut.setRootNavigator(navigator) + + sut.popScreen() + + val canPop = sut.canPop.value + assertFalse(canPop) + verify { + navigator.pop() + } + } + + @Test + fun whenShowBottomSheet_thenInteractionsAreAsExpected() = runTest { + val screen = object : Screen { + @Composable + override fun Content() { + Box(modifier = Modifier.fillMaxSize()) + } + } + val navigator = mockk(relaxUnitFun = true) + sut.setBottomNavigator(navigator) + + sut.showBottomSheet(screen) + + verify { + navigator.show(screen) + } + } + + @Test + fun whenHideBottomSheet_thenInteractionsAreAsExpected() = runTest { + val navigator = mockk(relaxUnitFun = true) { + every { isVisible } returns true + } + sut.setBottomNavigator(navigator) + + sut.hideBottomSheet() + + verify { + navigator.hide() + } + } +} \ No newline at end of file diff --git a/core/testutils/src/androidMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/testutils/DispatcherTestRule.kt b/core/testutils/src/androidMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/testutils/DispatcherTestRule.kt index 9e6c8e990..914325dc7 100644 --- a/core/testutils/src/androidMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/testutils/DispatcherTestRule.kt +++ b/core/testutils/src/androidMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/testutils/DispatcherTestRule.kt @@ -11,11 +11,11 @@ import org.junit.runner.Description @OptIn(ExperimentalCoroutinesApi::class) class DispatcherTestRule( - private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() + private val dispatcher: TestDispatcher = UnconfinedTestDispatcher() ) : TestWatcher() { override fun starting(description: Description) { - Dispatchers.setMain(testDispatcher) + Dispatchers.setMain(dispatcher) } override fun finished(description: Description) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 98a37bb78..ad4299dba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ multiplatform_settings = "1.1.1" reorderable = "1.3.1" sqlcipher = "4.5.5" sqldelight = "2.0.1" +turbine = "1.0.0" voyager = "1.0.0" android_targetSdk = "34" @@ -92,6 +93,7 @@ exoplayer_ui = { module = "androidx.media3:media3-ui", version.ref = "androidx.m reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } [plugins]