chore: add test in core-navigation (#558)

This commit is contained in:
Diego Beraldin 2024-02-28 18:07:19 +01:00 committed by GitHub
parent 97531b5d4b
commit 0e314b24da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 415 additions and 5 deletions

View File

@ -343,4 +343,104 @@ As far as Compose code is concerned, we take Googles indications as a baselin
### 6.3 Test structure
TBD
#### 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<Navigator> {
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<Tab>()
val navigator = mockk<TabNavigator>(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)
}
}
}
```

View File

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

View File

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

View File

@ -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<Navigator> {
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<Tab>()
val navigator = mockk<TabNavigator>(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<Navigator>(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<Navigator>(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<Navigator>(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<BottomSheetNavigator>(relaxUnitFun = true)
sut.setBottomNavigator(navigator)
sut.showBottomSheet(screen)
verify {
navigator.show(screen)
}
}
@Test
fun whenHideBottomSheet_thenInteractionsAreAsExpected() = runTest {
val navigator = mockk<BottomSheetNavigator>(relaxUnitFun = true) {
every { isVisible } returns true
}
sut.setBottomNavigator(navigator)
sut.hideBottomSheet()
verify {
navigator.hide()
}
}
}

View File

@ -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) {

View File

@ -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]