Skip to content

Commit 6d0cb70

Browse files
committed
refactor: Replace ComposeContentTestRule with ComposeUiTest in UI tests
This commit refactors the UI test classes to use `ComposeUiTest` and `SemanticsNodeInteractionsProvider` instead of the more specific `ComposeContentTestRule`. This change improves abstraction and makes the test helpers more platform-agnostic. - Replace `ComposeContentTestRule` with `ComposeUiTest` in all test case classes (`CrudTestCase`, `LocaleTestCase`, `SignInTestCase`, etc.). - Page object classes (e.g., `MainTestScreen`, `NoteScreen`, `SignInScreen`) now accept a `SemanticsNodeInteractionsProvider` instead of `ComposeContentTestRule`. The property name is changed from `composeTestRule` to `nodeProvider`. - Introduce a `reflect` utility function to obtain a `ComposeUiTest` instance from a `ComposeContentTestRule`, ensuring compatibility with existing test rule implementations like `AndroidComposeTestRule`. - Update `AbstractJvmUiTests` to use the new `ComposeUiTest` for running test cases. - Add the `ui-test` dependency to the `commonMain` source set.
1 parent c438746 commit 6d0cb70

28 files changed

+276
-149
lines changed

app/android/src/androidTest/java/com/softartdev/notedelight/ui/SignInTest.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
@file:OptIn(ExperimentalTestApi::class)
2+
13
package com.softartdev.notedelight.ui
24

5+
import androidx.compose.ui.test.ComposeUiTest
6+
import androidx.compose.ui.test.ExperimentalTestApi
37
import androidx.test.espresso.Espresso
48
import androidx.test.ext.junit.runners.AndroidJUnit4
59
import androidx.test.filters.FlakyTest
610
import com.softartdev.notedelight.DbTestEncryptor
711
import com.softartdev.notedelight.MainActivity
12+
import com.softartdev.notedelight.reflect
813
import com.softartdev.notedelight.ui.cases.SignInTestCase
914
import leakcanary.DetectLeaksAfterTestSuccess
1015
import leakcanary.TestDescriptionHolder
@@ -26,6 +31,8 @@ class SignInTest {
2631
.around(DetectLeaksAfterTestSuccess())
2732
.around(composeTestRule)
2833

34+
private val composeUiTest: ComposeUiTest = reflect(composeTestRule)
35+
2936
@Test
30-
fun signInTest() = SignInTestCase(composeTestRule, Espresso::closeSoftKeyboard).invoke()
37+
fun signInTest() = SignInTestCase(composeUiTest, Espresso::closeSoftKeyboard).invoke()
3138
}

app/android/src/androidTest/java/com/softartdev/notedelight/ui/SignInToSettingsTest.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
@file:OptIn(ExperimentalTestApi::class)
2+
13
package com.softartdev.notedelight.ui
24

5+
import androidx.compose.ui.test.ComposeUiTest
6+
import androidx.compose.ui.test.ExperimentalTestApi
37
import androidx.test.espresso.Espresso
48
import androidx.test.ext.junit.runners.AndroidJUnit4
59
import androidx.test.filters.FlakyTest
610
import com.softartdev.notedelight.DbTestEncryptor
711
import com.softartdev.notedelight.MainActivity
12+
import com.softartdev.notedelight.reflect
813
import com.softartdev.notedelight.ui.cases.SignInToSettingsTestCase
914
import leakcanary.DetectLeaksAfterTestSuccess
1015
import leakcanary.TestDescriptionHolder
@@ -26,7 +31,9 @@ class SignInToSettingsTest {
2631
.around(DetectLeaksAfterTestSuccess())
2732
.around(composeTestRule)
2833

34+
private val composeUiTest: ComposeUiTest = reflect(composeTestRule)
35+
2936
@Test
30-
fun signInToSettingsTest() = SignInToSettingsTestCase(composeTestRule, Espresso::closeSoftKeyboard).invoke()
37+
fun signInToSettingsTest() = SignInToSettingsTestCase(composeUiTest, Espresso::closeSoftKeyboard).invoke()
3138
}
3239

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
@file:OptIn(ExperimentalTestApi::class)
2+
3+
package com.softartdev.notedelight
4+
5+
import androidx.compose.ui.test.ComposeUiTest
6+
import androidx.compose.ui.test.ExperimentalTestApi
7+
import androidx.compose.ui.test.junit4.ComposeContentTestRule
8+
import java.lang.reflect.Field
9+
import java.lang.reflect.Method
10+
11+
fun reflect(composeTestRule: ComposeContentTestRule): ComposeUiTest {
12+
// Just in case some wrapper directly implements ComposeUiTest.
13+
(composeTestRule as? ComposeUiTest)?.let { return it }
14+
15+
// 1) Fast path: both AndroidComposeTestRule and DesktopComposeTestRule store it in "composeTest".
16+
findField(composeTestRule, "composeTest")?.let { field ->
17+
field.getOrNull(composeTestRule)?.let { value ->
18+
if (value is ComposeUiTest) return value
19+
}
20+
}
21+
22+
// 2) Robust path: scan all fields in the hierarchy and return the first ComposeUiTest found.
23+
for (field in allFields(composeTestRule.javaClass)) {
24+
field.getOrNull(composeTestRule)?.let { value ->
25+
if (value is ComposeUiTest) return value
26+
}
27+
}
28+
29+
// 3) Last resort: scan no-arg methods returning ComposeUiTest.
30+
for (method in allNoArgMethods(composeTestRule.javaClass)) {
31+
if (ComposeUiTest::class.java.isAssignableFrom(method.returnType)) {
32+
method.isAccessible = true
33+
val value = runCatching { method.invoke(composeTestRule) }.getOrNull()
34+
if (value is ComposeUiTest) return value
35+
}
36+
}
37+
38+
throw IllegalStateException(
39+
"Failed to reflect ComposeUiTest from ${composeTestRule.javaClass.name}. " +
40+
"Expected AndroidComposeTestRule/DesktopComposeTestRule internals to contain it."
41+
)
42+
}
43+
44+
@Suppress("SameParameterValue")
45+
private fun findField(target: Any, name: String): Field? {
46+
var c: Class<*>? = target.javaClass
47+
while (c != null && c != Any::class.java) {
48+
runCatching { c.getDeclaredField(name) }
49+
.onSuccess { return it }
50+
c = c.superclass
51+
}
52+
return null
53+
}
54+
55+
private fun allFields(clazz: Class<*>): Sequence<Field> =
56+
generateSequence(clazz) { it.superclass }
57+
.takeWhile { it != Any::class.java }
58+
.flatMap { it.declaredFields.asSequence() }
59+
60+
private fun allNoArgMethods(clazz: Class<*>): Sequence<Method> =
61+
generateSequence(clazz) { it.superclass }
62+
.takeWhile { it != Any::class.java }
63+
.flatMap { it.declaredMethods.asSequence() }
64+
.filter { it.parameterTypes.isEmpty() }
65+
66+
private fun Field.getOrNull(instance: Any): Any? =
67+
runCatching {
68+
isAccessible = true
69+
get(instance)
70+
}.getOrNull()

ui/test-jvm/src/main/kotlin/com/softartdev/notedelight/ext.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
@file:OptIn(ExperimentalTestApi::class)
2+
13
package com.softartdev.notedelight
24

5+
import androidx.compose.ui.test.ComposeUiTest
6+
import androidx.compose.ui.test.ExperimentalTestApi
37
import androidx.compose.ui.test.SemanticsNodeInteraction
48
import androidx.compose.ui.test.assertIsDisplayed
59
import androidx.compose.ui.test.assertIsNotDisplayed
610
import androidx.compose.ui.test.assertIsSelectable
711
import androidx.compose.ui.test.assertIsSelected
812
import androidx.compose.ui.test.isNotDisplayed
9-
import androidx.compose.ui.test.junit4.ComposeTestRule
1013
import co.touchlab.kermit.Logger
1114

1215
const val ASSERT_WAIT_TIMEOUT_MILLIS: Long = 5_000
@@ -60,7 +63,7 @@ inline fun retryUntilNotDisplayed(
6063
return sni.assertIsNotDisplayed()
6164
}
6265

63-
inline fun ComposeTestRule.waitUntilDisplayed(
66+
inline fun ComposeUiTest.waitUntilDisplayed(
6467
description: String,
6568
crossinline blockSNI: () -> SemanticsNodeInteraction,
6669
) = waitUntil(conditionDescription = description, timeoutMillis = ASSERT_WAIT_TIMEOUT_MILLIS) {
@@ -73,7 +76,7 @@ inline fun ComposeTestRule.waitUntilDisplayed(
7376
return@waitUntil true
7477
}
7578

76-
inline fun ComposeTestRule.waitAssert(
79+
inline fun ComposeUiTest.waitAssert(
7780
description: String,
7881
crossinline assert: () -> Unit
7982
) = waitUntil(conditionDescription = description, timeoutMillis = ASSERT_WAIT_TIMEOUT_MILLIS) {
@@ -85,7 +88,7 @@ inline fun ComposeTestRule.waitAssert(
8588
return@waitUntil true
8689
}
8790

88-
inline fun ComposeTestRule.waitUntilSelected(
91+
inline fun ComposeUiTest.waitUntilSelected(
8992
description: String,
9093
crossinline blockSNI: () -> SemanticsNodeInteraction
9194
) = waitUntil(conditionDescription = description, timeoutMillis = ASSERT_WAIT_TIMEOUT_MILLIS) {

ui/test-jvm/src/main/kotlin/com/softartdev/notedelight/ui/AbstractJvmUiTests.kt

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
@file:OptIn(ExperimentalTestApi::class)
2+
13
package com.softartdev.notedelight.ui
24

5+
import androidx.compose.ui.test.ComposeUiTest
6+
import androidx.compose.ui.test.ExperimentalTestApi
37
import androidx.compose.ui.test.junit4.ComposeContentTestRule
48
import com.softartdev.notedelight.AbstractUITests
9+
import com.softartdev.notedelight.reflect
510
import com.softartdev.notedelight.ui.cases.CrudTestCase
611
import com.softartdev.notedelight.ui.cases.EditTitleAfterCreateTestCase
712
import com.softartdev.notedelight.ui.cases.EditTitleAfterSaveTestCase
@@ -12,32 +17,33 @@ import com.softartdev.notedelight.ui.cases.SettingPasswordTestCase
1217

1318
abstract class AbstractJvmUiTests : AbstractUITests() {
1419
abstract val composeTestRule: ComposeContentTestRule
20+
override val composeUiTest: ComposeUiTest by lazy { reflect(composeTestRule) }
1521

1622
open fun setUp() = Unit
1723

1824
open fun tearDown() = Unit
1925

20-
open fun crudNoteTest() = CrudTestCase(composeTestRule).invoke()
26+
open fun crudNoteTest() = CrudTestCase(composeUiTest).invoke()
2127

22-
open fun editTitleAfterCreateTest() = EditTitleAfterCreateTestCase(composeTestRule).invoke()
28+
open fun editTitleAfterCreateTest() = EditTitleAfterCreateTestCase(composeUiTest).invoke()
2329

24-
open fun editTitleAfterSaveTest() = EditTitleAfterSaveTestCase(composeTestRule).invoke()
30+
open fun editTitleAfterSaveTest() = EditTitleAfterSaveTestCase(composeUiTest).invoke()
2531

26-
open fun prepopulateDatabase() = PrepopulateDbTestCase(composeTestRule).invoke()
32+
open fun prepopulateDatabase() = PrepopulateDbTestCase(composeUiTest).invoke()
2733

2834
open fun flowAfterCryptTest() = FlowAfterCryptTestCase(
29-
composeTestRule = composeTestRule,
35+
composeUiTest = composeUiTest,
3036
pressBack = ::pressBack,
3137
closeSoftKeyboard = ::closeSoftKeyboard
3238
).invoke()
3339

3440
open fun settingPasswordTest() = SettingPasswordTestCase(
35-
composeTestRule = composeTestRule,
41+
composeUiTest = composeUiTest,
3642
closeSoftKeyboard = ::closeSoftKeyboard
3743
).invoke()
3844

3945
open fun localeTest() = LocaleTestCase(
40-
composeTestRule = composeTestRule,
46+
composeUiTest = composeUiTest,
4147
pressBack = ::pressBack
4248
).invoke()
4349

ui/test-jvm/src/main/kotlin/com/softartdev/notedelight/ui/BaseTestCase.kt

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
@file:OptIn(ExperimentalTestApi::class)
2+
13
package com.softartdev.notedelight.ui
24

3-
import androidx.compose.ui.test.junit4.ComposeContentTestRule
5+
import androidx.compose.ui.test.ComposeUiTest
6+
import androidx.compose.ui.test.ExperimentalTestApi
47
import com.softartdev.notedelight.ui.screen.MainTestScreen
58
import com.softartdev.notedelight.ui.screen.NoteScreen
69
import com.softartdev.notedelight.ui.screen.SettingsTestScreen
@@ -13,18 +16,18 @@ import com.softartdev.notedelight.ui.screen.dialog.EditTitleDialog
1316
import com.softartdev.notedelight.ui.screen.dialog.EnterPasswordDialog
1417
import com.softartdev.notedelight.ui.screen.dialog.LanguageDialog
1518

16-
abstract class BaseTestCase(val composeTestRule: ComposeContentTestRule) {
19+
abstract class BaseTestCase(val composeUiTest: ComposeUiTest) {
1720

18-
private val commonDialog: CommonDialog = CommonDialogImpl(composeTestRule)
21+
private val commonDialog: CommonDialog = CommonDialogImpl(composeUiTest)
1922

20-
fun signInScreen(block: SignInScreen.() -> Unit) = SignInScreen(composeTestRule).block()
23+
fun signInScreen(block: SignInScreen.() -> Unit) = SignInScreen(composeUiTest).block()
2124

22-
fun mainTestScreen(block: MainTestScreen.() -> Unit) = MainTestScreen(composeTestRule).block()
25+
fun mainTestScreen(block: MainTestScreen.() -> Unit) = MainTestScreen(composeUiTest).block()
2326

24-
fun noteScreen(block: NoteScreen.() -> Unit) = NoteScreen(composeTestRule).block()
27+
fun noteScreen(block: NoteScreen.() -> Unit) = NoteScreen(composeUiTest).block()
2528

2629
fun settingsTestScreen(block: SettingsTestScreen.() -> Unit) =
27-
SettingsTestScreen(composeTestRule).block()
30+
SettingsTestScreen(composeUiTest).block()
2831

2932
fun commonDialog(block: CommonDialog.() -> Unit) = commonDialog.block()
3033

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
@file:OptIn(ExperimentalTestApi::class)
2+
13
package com.softartdev.notedelight.ui.cases
24

3-
import androidx.compose.ui.test.junit4.ComposeContentTestRule
5+
import androidx.compose.ui.test.ComposeUiTest
6+
import androidx.compose.ui.test.ExperimentalTestApi
47
import androidx.compose.ui.test.performClick
58
import androidx.compose.ui.test.performTextInput
69
import com.softartdev.notedelight.ui.BaseTestCase
@@ -10,30 +13,30 @@ import kotlinx.coroutines.test.runTest
1013
import java.util.UUID
1114

1215
class CrudTestCase(
13-
composeTestRule: ComposeContentTestRule
14-
) : () -> Unit, BaseTestCase(composeTestRule) {
16+
composeUiTest: ComposeUiTest
17+
) : () -> Unit, BaseTestCase(composeUiTest) {
1518

1619
private val actualNoteText = UUID.randomUUID().toString().substring(0, 30)
1720

1821
override fun invoke() = runTest {
1922
mainTestScreen {
20-
composeTestRule.waitUntilDisplayed("fab", blockSNI = ::fabSNI)
23+
composeUiTest.waitUntilDisplayed("fab", blockSNI = ::fabSNI)
2124
fabSNI.performClick()
2225
noteScreen {
2326
textFieldSNI.performTextInput(actualNoteText)
2427
saveNoteMenuButtonSNI.performClick()
2528
backButtonSNI.performClick()
2629
}
2730
noteItemTitleText = actualNoteText
28-
composeTestRule.waitUntilDisplayed("noteListItem", blockSNI = ::noteListItemSNI)
31+
composeUiTest.waitUntilDisplayed("noteListItem", blockSNI = ::noteListItemSNI)
2932
noteListItemSNI.performClick()
3033
noteScreen {
3134
deleteNoteMenuButtonSNI.performClick()
3235
commonDialog {
3336
yesDialogButtonSNI.performClick()
3437
}
3538
}
36-
composeTestRule.waitUntilDisplayed("emptyResultLabel", blockSNI = ::emptyResultLabelSNI)
39+
composeUiTest.waitUntilDisplayed("emptyResultLabel", blockSNI = ::emptyResultLabelSNI)
3740
}
3841
}
3942
}

ui/test-jvm/src/main/kotlin/com/softartdev/notedelight/ui/cases/EditTitleAfterCreateTestCase.kt

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
@file:OptIn(ExperimentalTestApi::class)
2+
13
package com.softartdev.notedelight.ui.cases
24

3-
import androidx.compose.ui.test.junit4.ComposeContentTestRule
5+
import androidx.compose.ui.test.ComposeUiTest
6+
import androidx.compose.ui.test.ExperimentalTestApi
47
import androidx.compose.ui.test.onNodeWithContentDescription
58
import androidx.compose.ui.test.performClick
69
import androidx.compose.ui.test.performTextInput
@@ -16,32 +19,32 @@ import org.jetbrains.compose.resources.getString
1619
import java.util.UUID
1720

1821
class EditTitleAfterCreateTestCase(
19-
composeTestRule: ComposeContentTestRule
20-
) : () -> Unit, BaseTestCase(composeTestRule) {
22+
composeUiTest: ComposeUiTest
23+
) : () -> Unit, BaseTestCase(composeUiTest) {
2124

2225
private val actualNoteTitle = "title"
2326

2427
override fun invoke() = runTest {
2528
mainTestScreen {
26-
composeTestRule.waitUntilDisplayed("fab", blockSNI = ::fabSNI)
29+
composeUiTest.waitUntilDisplayed("fab", blockSNI = ::fabSNI)
2730
fabSNI.performClick()
2831
noteScreen {
2932
val actualNoteText = UUID.randomUUID().toString().substring(0, 30)
3033
textFieldSNI.performTextInput(actualNoteText)
3134
editTitleMenuButtonSNI.performClick()
3235
editTitleDialog {
33-
composeTestRule.waitUntilDisplayed("editTitle", blockSNI = ::editTitleSNI)
36+
composeUiTest.waitUntilDisplayed("editTitle", blockSNI = ::editTitleSNI)
3437
editTitleSNI.performTextReplacement(actualNoteTitle)
3538
yesDialogButtonSNI.performClick()
3639
}
37-
composeTestRule
40+
composeUiTest
3841
.onNodeWithContentDescription(label = runBlocking { getString(Res.string.enter_title) })
3942
.assertDoesNotExist()
4043
saveNoteMenuButtonSNI.performClick()
4144
backButtonSNI.performClick()
4245
}
4346
noteItemTitleText = actualNoteTitle
44-
composeTestRule.waitUntilDisplayed("noteListItem", blockSNI = ::noteListItemSNI)
47+
composeUiTest.waitUntilDisplayed("noteListItem", blockSNI = ::noteListItemSNI)
4548
}
4649
}
4750
}

0 commit comments

Comments
 (0)