Skip to content

Commit c67eb69

Browse files
ComicSASShiftHackZ
andauthored
Compose unit test (#241)
* Implemented compose unit test libraries * Inject ViewModel into ServerSetupScreenTest.kt * Refactor tags to be shared * Added some compose test & updated LocalDiffusionForm Ui * Added ServerSetupScreenTest dispatchers * Refactored AllowLocalCustomModel logic * Update test --------- Co-authored-by: ShiftHackZ <[email protected]>
1 parent 3b3a87f commit c67eb69

File tree

12 files changed

+427
-170
lines changed

12 files changed

+427
-170
lines changed

dependencies.gradle

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ ext {
3838
testMockkVersion = '1.13.11'
3939
testCoroutinesVersion = '1.8.1'
4040
testTurbibeVersion = '1.1.0'
41+
testRobolectricVersion = '4.13'
42+
testCopmoseUiVersion = '1.6.8'
4143

4244
androidx = [
4345
core : "androidx.core:core-ktx:$coreKtxVersion",
@@ -110,13 +112,18 @@ ext {
110112
timber: "com.jakewharton.timber:timber:$timberVersion",
111113
]
112114
apache = [
113-
stringutils: "org.apache.commons:commons-lang3:$apacheLangVersion"
115+
stringutils: "org.apache.commons:commons-lang3:$apacheLangVersion",
114116
]
115117
test = [
116-
junit : "junit:junit:$testJunitVersion",
117-
mockito : "com.nhaarman.mockitokotlin2:mockito-kotlin:$testMockitoVersion",
118-
mockk : "io.mockk:mockk:$testMockkVersion",
119-
coroutines: "org.jetbrains.kotlinx:kotlinx-coroutines-test:$testCoroutinesVersion",
120-
turbine : "app.cash.turbine:turbine:$testTurbibeVersion",
118+
junit : "junit:junit:$testJunitVersion",
119+
mockito : "com.nhaarman.mockitokotlin2:mockito-kotlin:$testMockitoVersion",
120+
mockk : "io.mockk:mockk:$testMockkVersion",
121+
coroutines : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$testCoroutinesVersion",
122+
turbine : "app.cash.turbine:turbine:$testTurbibeVersion",
123+
robolectric : "org.robolectric:robolectric:$testRobolectricVersion",
124+
composeUiJunit : "androidx.compose.ui:ui-test-junit4:$testCopmoseUiVersion",
125+
composeUiManifest: "androidx.compose.ui:ui-test-manifest:$testCopmoseUiVersion",
126+
koin : "io.insert-koin:koin-test:$koinVersion",
127+
koinJunit : "io.insert-koin:koin-test-junit4:$koinVersion",
121128
]
122129
}

presentation/build.gradle

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@ android {
1515
kotlinCompilerExtensionVersion = "1.5.7"
1616
}
1717
testOptions {
18-
unitTests.returnDefaultValues = true
19-
unitTests.all {
20-
jvmArgs(
21-
"--add-opens", "java.base/java.lang=ALL-UNNAMED",
22-
"--add-opens", "java.base/java.lang.reflect=ALL-UNNAMED"
23-
)
18+
unitTests {
19+
all {
20+
jvmArgs(
21+
"--add-opens", "java.base/java.lang=ALL-UNNAMED",
22+
"--add-opens", "java.base/java.lang.reflect=ALL-UNNAMED"
23+
)
24+
}
25+
returnDefaultValues = true
26+
includeAndroidResources = true
2427
}
2528
}
2629
}
@@ -59,7 +62,12 @@ dependencies {
5962
implementation ui.composeEasyCrop
6063

6164
testImplementation test.junit
65+
testImplementation test.koin
66+
testImplementation test.koinJunit
6267
testImplementation test.mockk
6368
testImplementation test.coroutines
6469
testImplementation test.turbine
70+
testImplementation test.robolectric
71+
testImplementation test.composeUiJunit
72+
debugImplementation test.composeUiManifest
6573
}

presentation/src/main/java/com/shifthackz/aisdv1/presentation/navigation/graph/MainNavGraph.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@ import com.shifthackz.aisdv1.presentation.screen.loader.ConfigurationLoaderScree
1313
import com.shifthackz.aisdv1.presentation.screen.logger.LoggerScreen
1414
import com.shifthackz.aisdv1.presentation.screen.setup.ServerSetupLaunchSource
1515
import com.shifthackz.aisdv1.presentation.screen.setup.ServerSetupScreen
16+
import com.shifthackz.aisdv1.presentation.screen.setup.ServerSetupViewModel
1617
import com.shifthackz.aisdv1.presentation.screen.splash.SplashScreen
1718
import com.shifthackz.aisdv1.presentation.screen.web.webui.WebUiScreen
1819
import com.shifthackz.aisdv1.presentation.utils.Constants
20+
import org.koin.androidx.compose.getViewModel
21+
import org.koin.compose.koinInject
22+
import org.koin.core.parameter.parametersOf
1923

2024
fun NavGraphBuilder.mainNavGraph() {
2125
addDestination(
@@ -30,7 +34,12 @@ fun NavGraphBuilder.mainNavGraph() {
3034
val sourceKey = entry.arguments
3135
?.getInt(Constants.PARAM_SOURCE)
3236
?: ServerSetupLaunchSource.SPLASH.ordinal
33-
ServerSetupScreen(launchSourceKey = sourceKey)
37+
ServerSetupScreen(
38+
viewModel = getViewModel<ServerSetupViewModel>(
39+
parameters = { parametersOf(sourceKey) }
40+
),
41+
buildInfoProvider = koinInject()
42+
)
3443
}.apply {
3544
route = Constants.ROUTE_SERVER_SETUP_FULL
3645
addArgument(

presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/setup/ServerSetupScreen.kt

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import androidx.compose.runtime.LaunchedEffect
3737
import androidx.compose.ui.Modifier
3838
import androidx.compose.ui.platform.LocalContext
3939
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
40+
import androidx.compose.ui.platform.testTag
4041
import androidx.compose.ui.res.stringResource
4142
import androidx.compose.ui.unit.dp
4243
import com.shifthackz.aisdv1.core.common.appbuild.BuildInfoProvider
@@ -49,15 +50,13 @@ import com.shifthackz.aisdv1.presentation.screen.setup.components.ConfigurationS
4950
import com.shifthackz.aisdv1.presentation.screen.setup.steps.ConfigurationStep
5051
import com.shifthackz.aisdv1.presentation.screen.setup.steps.SourceSelectionStep
5152
import com.shifthackz.aisdv1.presentation.utils.PermissionUtil
52-
import org.koin.androidx.compose.getViewModel
53-
import org.koin.compose.koinInject
54-
import org.koin.core.parameter.parametersOf
5553
import com.shifthackz.aisdv1.core.localization.R as LocalizationR
5654

5755
@Composable
5856
fun ServerSetupScreen(
5957
modifier: Modifier = Modifier,
60-
launchSourceKey: Int,
58+
viewModel: ServerSetupViewModel,
59+
buildInfoProvider: BuildInfoProvider = BuildInfoProvider.stub,
6160
) {
6261
val keyboardController = LocalSoftwareKeyboardController.current
6362
val context = LocalContext.current
@@ -69,9 +68,7 @@ fun ServerSetupScreen(
6968
}
7069

7170
MviComponent(
72-
viewModel = getViewModel<ServerSetupViewModel>(
73-
parameters = { parametersOf(launchSourceKey) }
74-
),
71+
viewModel = viewModel,
7572
processEffect = { effect ->
7673
when (effect) {
7774
ServerSetupEffect.LaunchManageStoragePermission -> {
@@ -92,7 +89,7 @@ fun ServerSetupScreen(
9289
ScreenContent(
9390
modifier = modifier.fillMaxSize(),
9491
state = state,
95-
buildInfoProvider = koinInject(),
92+
buildInfoProvider = buildInfoProvider,
9693
processIntent = intentHandler,
9794
)
9895
}
@@ -147,6 +144,7 @@ private fun ScreenContent(
147144
bottomBar = {
148145
Button(
149146
modifier = Modifier
147+
.testTag(ServerSetupScreenTags.MAIN_BUTTON)
150148
.height(height = 68.dp)
151149
.background(MaterialTheme.colorScheme.background)
152150
.fillMaxWidth()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.shifthackz.aisdv1.presentation.screen.setup
2+
3+
object ServerSetupScreenTags {
4+
const val MAIN_BUTTON = "ServerSetupMainButton"
5+
const val CUSTOM_MODEL_SWITCH = "CustomModelSwitch"
6+
}

presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/setup/ServerSetupViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ class ServerSetupViewModel(
106106
it.copy(
107107
localCustomModel = intent.allow,
108108
localModels = currentState.localModels.withNewState(
109-
currentState.localModels.find { m -> m.id == LocalAiModel.CUSTOM.id }!!.copy(
109+
currentState.localModels.find { m -> m.id == LocalAiModel.CUSTOM.id }?.copy(
110110
selected = intent.allow,
111111
),
112112
),

presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/setup/forms/LocalDiffusionForm.kt

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,19 @@ import androidx.compose.ui.Alignment
3232
import androidx.compose.ui.Modifier
3333
import androidx.compose.ui.draw.clip
3434
import androidx.compose.ui.graphics.Color
35+
import androidx.compose.ui.platform.testTag
3536
import androidx.compose.ui.res.stringResource
3637
import androidx.compose.ui.text.font.FontWeight
3738
import androidx.compose.ui.text.style.TextAlign
39+
import androidx.compose.ui.text.style.TextOverflow
3840
import androidx.compose.ui.unit.dp
3941
import androidx.compose.ui.unit.times
4042
import com.shifthackz.aisdv1.core.common.appbuild.BuildInfoProvider
4143
import com.shifthackz.aisdv1.core.common.appbuild.BuildType
4244
import com.shifthackz.aisdv1.domain.entity.DownloadState
4345
import com.shifthackz.aisdv1.domain.entity.LocalAiModel
4446
import com.shifthackz.aisdv1.presentation.screen.setup.ServerSetupIntent
47+
import com.shifthackz.aisdv1.presentation.screen.setup.ServerSetupScreenTags.CUSTOM_MODEL_SWITCH
4548
import com.shifthackz.aisdv1.presentation.screen.setup.ServerSetupState
4649
import com.shifthackz.aisdv1.core.localization.R as LocalizationR
4750

@@ -68,8 +71,10 @@ fun LocalDiffusionForm(
6871
.clickable { processIntent(ServerSetupIntent.SelectLocalModel(model)) },
6972
) {
7073
Row(
71-
modifier = Modifier.padding(vertical = 4.dp),
72-
horizontalArrangement = Arrangement.Center,
74+
modifier = Modifier
75+
.padding(vertical = 4.dp)
76+
.fillMaxWidth(),
77+
horizontalArrangement = Arrangement.SpaceEvenly,
7378
verticalAlignment = Alignment.CenterVertically,
7479
) {
7580
val icon = when (model.downloadState) {
@@ -82,20 +87,28 @@ fun LocalDiffusionForm(
8287
}
8388
Icon(
8489
modifier = modifier
85-
.padding(horizontal = 8.dp)
90+
.padding(start = 8.dp)
8691
.size(48.dp),
8792
imageVector = icon,
8893
contentDescription = "Download state",
8994
)
9095
Column(
91-
modifier = Modifier.padding(start = 4.dp)
96+
modifier = Modifier
97+
.padding(horizontal = 4.dp)
98+
.weight(1f)
9299
) {
93-
Text(text = model.name)
100+
Text(
101+
text = model.name,
102+
overflow = TextOverflow.Ellipsis,
103+
maxLines = 2
104+
)
94105
if (model.id != LocalAiModel.CUSTOM.id) {
95-
Text(model.size)
106+
Text(
107+
text = model.size,
108+
maxLines = 1
109+
)
96110
}
97111
}
98-
Spacer(modifier = Modifier.weight(1f))
99112
if (model.id != LocalAiModel.CUSTOM.id) {
100113
Button(
101114
modifier = Modifier.padding(end = 8.dp),
@@ -113,6 +126,7 @@ fun LocalDiffusionForm(
113126
}
114127
),
115128
color = LocalContentColor.current,
129+
maxLines = 1
116130
)
117131
}
118132
}
@@ -258,6 +272,7 @@ fun LocalDiffusionForm(
258272
verticalAlignment = Alignment.CenterVertically,
259273
) {
260274
Switch(
275+
modifier = Modifier.testTag(CUSTOM_MODEL_SWITCH),
261276
checked = state.localCustomModel,
262277
onCheckedChange = {
263278
processIntent(ServerSetupIntent.AllowLocalCustomModel(it))

presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/setup/mappers/LocalModelMappers.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ fun LocalAiModel.mapToUi(): ServerSetupState.LocalModel = with(this) {
1919
}
2020

2121
fun List<ServerSetupState.LocalModel>.withNewState(
22-
model: ServerSetupState.LocalModel,
22+
model: ServerSetupState.LocalModel?,
2323
): List<ServerSetupState.LocalModel> =
2424
map {
25+
if (model == null) return@map it
2526
if (it.id == model.id) model
2627
else {
2728
if (model.selected) it.copy(selected = false)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.shifthackz.aisdv1.presentation.core
2+
3+
import androidx.compose.ui.test.SemanticsNodeInteraction
4+
import androidx.compose.ui.test.assertIsDisplayed
5+
import androidx.compose.ui.test.filterToOne
6+
import androidx.compose.ui.test.hasParent
7+
import androidx.compose.ui.test.hasTestTag
8+
import androidx.compose.ui.test.junit4.ComposeContentTestRule
9+
import androidx.compose.ui.test.onAllNodesWithTag
10+
import androidx.compose.ui.test.onNodeWithTag
11+
import androidx.compose.ui.test.onRoot
12+
import androidx.compose.ui.test.printToLog
13+
import org.junit.Before
14+
import org.robolectric.shadows.ShadowLog
15+
16+
interface CoreComposeTest {
17+
18+
val composeTestRule: ComposeContentTestRule
19+
20+
@Before
21+
@Throws(Exception::class)
22+
fun setUp() {
23+
// Redirect Logcat to console output to read printToLog Compose debug messages
24+
ShadowLog.stream = System.out
25+
}
26+
27+
fun printComposeUiTreeToLog(tag: String, testTag: String? = null) {
28+
if (testTag.isNullOrEmpty()) {
29+
composeTestRule.onRoot().printToLog(tag)
30+
} else {
31+
composeTestRule.onNodeWithTag(testTag).printToLog(tag)
32+
}
33+
}
34+
35+
fun onNodeWithTestTag(tag: String, parentTestTag: String? = null): SemanticsNodeInteraction =
36+
if (parentTestTag != null) {
37+
composeTestRule.onAllNodesWithTag(tag)
38+
.filterToOne(hasParent(hasTestTag(parentTestTag)))
39+
.assertIsDisplayed()
40+
} else {
41+
composeTestRule.onNodeWithTag(tag)
42+
.assertIsDisplayed()
43+
}
44+
45+
fun retrieveTextFromNodeWithTestTag(tag: String, parentTestTag: String? = null): String =
46+
(onNodeWithTestTag(tag, parentTestTag)
47+
.fetchSemanticsNode().config
48+
.first { it.key.name == "Text" }
49+
.value as List<*>).first().toString()
50+
}

presentation/src/test/java/com/shifthackz/aisdv1/presentation/screen/gallery/detail/GalleryDetailViewModelTest.kt

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,9 @@ class GalleryDetailViewModelTest : CoreViewModelTest<GalleryDetailViewModel>() {
119119
@Test
120120
fun `given received Delete Request intent, expected modal field in UI state is DeleteImageConfirm`() {
121121
viewModel.processIntent(GalleryDetailIntent.Delete.Request)
122-
runTest {
123-
val expected = Modal.DeleteImageConfirm(isAll = false, isMultiple = false)
124-
val actual = (viewModel.state.value as? GalleryDetailState.Content)?.screenModal
125-
Assert.assertEquals(expected, actual)
126-
}
122+
val expected = Modal.DeleteImageConfirm(isAll = false, isMultiple = false)
123+
val actual = (viewModel.state.value as? GalleryDetailState.Content)?.screenModal
124+
Assert.assertEquals(expected, actual)
127125
}
128126

129127
@Test
@@ -138,11 +136,10 @@ class GalleryDetailViewModelTest : CoreViewModelTest<GalleryDetailViewModel>() {
138136

139137
viewModel.processIntent(GalleryDetailIntent.Delete.Confirm)
140138

141-
runTest {
142-
val expected = Modal.None
143-
val actual = (viewModel.state.value as? GalleryDetailState.Content)?.screenModal
144-
Assert.assertEquals(expected, actual)
145-
}
139+
val expected = Modal.None
140+
val actual = (viewModel.state.value as? GalleryDetailState.Content)?.screenModal
141+
Assert.assertEquals(expected, actual)
142+
146143
verify {
147144
stubDeleteGalleryItemUseCase(5598L)
148145
}
@@ -188,31 +185,25 @@ class GalleryDetailViewModelTest : CoreViewModelTest<GalleryDetailViewModel>() {
188185
@Test
189186
fun `given received SelectTab intent with IMAGE tab, expected expected selectedTab field in UI state is IMAGE`() {
190187
viewModel.processIntent(GalleryDetailIntent.SelectTab(GalleryDetailState.Tab.IMAGE))
191-
runTest {
192-
val expected = GalleryDetailState.Tab.IMAGE
193-
val actual = viewModel.state.value.selectedTab
194-
Assert.assertEquals(expected, actual)
195-
}
188+
val expected = GalleryDetailState.Tab.IMAGE
189+
val actual = viewModel.state.value.selectedTab
190+
Assert.assertEquals(expected, actual)
196191
}
197192

198193
@Test
199194
fun `given received SelectTab intent with INFO tab, expected expected selectedTab field in UI state is INFO`() {
200195
viewModel.processIntent(GalleryDetailIntent.SelectTab(GalleryDetailState.Tab.INFO))
201-
runTest {
202-
val expected = GalleryDetailState.Tab.INFO
203-
val actual = viewModel.state.value.selectedTab
204-
Assert.assertEquals(expected, actual)
205-
}
196+
val expected = GalleryDetailState.Tab.INFO
197+
val actual = viewModel.state.value.selectedTab
198+
Assert.assertEquals(expected, actual)
206199
}
207200

208201
@Test
209202
fun `given received SelectTab intent with ORIGINAL tab, expected expected selectedTab field in UI state is ORIGINAL`() {
210203
viewModel.processIntent(GalleryDetailIntent.SelectTab(GalleryDetailState.Tab.ORIGINAL))
211-
runTest {
212-
val expected = GalleryDetailState.Tab.ORIGINAL
213-
val actual = viewModel.state.value.selectedTab
214-
Assert.assertEquals(expected, actual)
215-
}
204+
val expected = GalleryDetailState.Tab.ORIGINAL
205+
val actual = viewModel.state.value.selectedTab
206+
Assert.assertEquals(expected, actual)
216207
}
217208

218209
@Test
@@ -266,10 +257,8 @@ class GalleryDetailViewModelTest : CoreViewModelTest<GalleryDetailViewModel>() {
266257
@Test
267258
fun `given received DismissDialog intent, expected screenModal field in UI state is None`() {
268259
viewModel.processIntent(GalleryDetailIntent.DismissDialog)
269-
runTest {
270-
val expected = Modal.None
271-
val actual = viewModel.state.value.screenModal
272-
Assert.assertEquals(expected, actual)
273-
}
260+
val expected = Modal.None
261+
val actual = viewModel.state.value.screenModal
262+
Assert.assertEquals(expected, actual)
274263
}
275264
}

0 commit comments

Comments
 (0)