Skip to content

Commit 5ca8eb5

Browse files
committed
refactor: Use test tags instead of string resources in UI tests
This commit refactors the UI tests to use `testTag` for locating composables instead of relying on string resources. This change improves test stability and performance by removing the need for `runBlockingAll` to resolve localized strings during test execution. - A new `TestTags.kt` file has been introduced to centralize all test tag constants. - UI composables across various screens (`Main`, `NoteDetail`, `SignIn`, `Settings`) and dialogs (`EditTitle`, `Language`, `Delete`, `Save`) have been updated to use the `modifier.testTag()` attribute. - The corresponding test screen models (`*TestScreen.kt`, `*Dialog.kt`) have been updated to use `onNodeWithTag` instead of `onNodeWithText` or `onNodeWithContentDescription` with dynamic string lookups. - The `TESTING_GUIDE.md` has been updated to document this new best practice, recommending the use of test tags over string resources for creating robust UI tests.
1 parent 769bcc1 commit 5ca8eb5

File tree

19 files changed

+182
-94
lines changed

19 files changed

+182
-94
lines changed

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,16 @@ package com.softartdev.notedelight.ui
22

33
import androidx.compose.ui.test.assertIsDisplayed
44
import androidx.compose.ui.test.junit4.createAndroidComposeRule
5-
import androidx.compose.ui.test.onNodeWithContentDescription
5+
import androidx.compose.ui.test.onNodeWithTag
66
import androidx.lifecycle.Lifecycle.State.DESTROYED
77
import androidx.test.espresso.Espresso
88
import androidx.test.ext.junit.runners.AndroidJUnit4
99
import androidx.test.filters.LargeTest
1010
import com.softartdev.notedelight.MainActivity
11+
import com.softartdev.notedelight.util.CREATE_NOTE_FAB_TAG
1112
import kotlinx.coroutines.test.runTest
1213
import leakcanary.DetectLeaksAfterTestSuccess
1314
import leakcanary.TestDescriptionHolder
14-
import notedelight.ui.shared.generated.resources.Res
15-
import notedelight.ui.shared.generated.resources.create_note
16-
import org.jetbrains.compose.resources.getString
1715
import org.junit.Assert.assertTrue
1816
import org.junit.Rule
1917
import org.junit.Test
@@ -34,7 +32,7 @@ class SignOutTest {
3432
@Test
3533
fun signOutTest() = runTest {
3634
composeTestRule
37-
.onNodeWithContentDescription(label = getString(Res.string.create_note))
35+
.onNodeWithTag(CREATE_NOTE_FAB_TAG)
3836
.assertIsDisplayed()
3937

4038
Espresso.pressBackUnconditionally()

docs/TESTING_GUIDE.md

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -458,18 +458,16 @@ class LocaleTestCase(
458458
settingsMenuButtonSNI.performClick()
459459
}
460460

461-
// Verify localized strings
462-
val settingsText = runBlocking { getString(Res.string.settings) }
463-
composeUiTest.onNodeWithText(settingsText).assertIsDisplayed()
464-
465-
// Test language dialog
461+
// Verify localized strings using test tags
466462
settingsTestScreen {
463+
settingsMenuButtonSNI.assertIsDisplayed()
467464
languageSNI.performClick()
468465
}
469466

470-
// Verify language options
471-
val chooseLanguageText = runBlocking { getString(Res.string.choose_language) }
472-
composeUiTest.onNodeWithText(chooseLanguageText).assertIsDisplayed()
467+
// Verify language dialog using test tags
468+
languageDialog {
469+
langDialogTitleSNI.assertIsDisplayed()
470+
}
473471
}
474472
}
475473
```
@@ -647,6 +645,31 @@ composeTestRule.onNodeWithTag("unique_tag")
647645
composeTestRule.onNode(hasClickAction())
648646
```
649647

648+
**Using Test Tags for String Resources**:
649+
When writing UI tests, always use test tags instead of suspending string resources. This avoids the need for `runBlockingAll` and makes tests more reliable and faster.
650+
651+
**Best Practice**:
652+
- ✅ Use test tags: `onNodeWithTag(TEST_TAG)`
653+
- ❌ Avoid: `onNodeWithText(runBlocking { getString(Res.string.label) })`
654+
655+
**Example**:
656+
```kotlin
657+
// In TestTags.kt
658+
const val SIGN_IN_BUTTON_TAG = "SIGN_IN_BUTTON_TAG"
659+
660+
// In UI component
661+
Button(
662+
modifier = Modifier.testTag(SIGN_IN_BUTTON_TAG),
663+
onClick = { }
664+
) { Text(stringResource(Res.string.sign_in)) }
665+
666+
// In test
667+
val signInButtonSNI: SemanticsNodeInteraction
668+
get() = nodeProvider.onNodeWithTag(SIGN_IN_BUTTON_TAG)
669+
```
670+
671+
All test tags are defined in [`ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/util/TestTags.kt`](../ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/util/TestTags.kt).
672+
650673
Actions:
651674
```kotlin
652675
node.performClick()

ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/ui/Composables.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable
2222
import androidx.compose.ui.Alignment
2323
import androidx.compose.ui.Modifier
2424
import androidx.compose.ui.platform.testTag
25+
import com.softartdev.notedelight.util.EMPTY_RESULT_LABEL_TAG
2526
import androidx.compose.ui.text.TextStyle
2627
import androidx.compose.ui.text.font.FontWeight
2728
import androidx.compose.ui.unit.dp
@@ -96,6 +97,7 @@ fun Empty() {
9697
) {
9798
Spacer(modifier = Modifier.weight(1f))
9899
Text(
100+
modifier = Modifier.testTag(EMPTY_RESULT_LABEL_TAG),
99101
text = stringResource(Res.string.label_empty_result),
100102
style = MaterialTheme.typography.headlineSmall,
101103
)

ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/EditTitleDialog.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import androidx.compose.runtime.mutableStateOf
1515
import androidx.compose.runtime.remember
1616
import androidx.compose.runtime.setValue
1717
import androidx.compose.ui.Modifier
18-
import androidx.compose.ui.semantics.contentDescription
19-
import androidx.compose.ui.semantics.semantics
18+
import androidx.compose.ui.platform.testTag
19+
import com.softartdev.notedelight.util.ENTER_TITLE_DIALOG_TAG
20+
import com.softartdev.notedelight.util.YES_BUTTON_TAG
2021
import androidx.compose.ui.text.TextRange
2122
import androidx.compose.ui.text.input.TextFieldValue
2223
import com.softartdev.notedelight.presentation.title.EditTitleAction
@@ -44,15 +45,14 @@ fun EditTitleDialog(editTitleViewModel: EditTitleViewModel) {
4445
fun ShowEditTitleDialog(
4546
result: EditTitleResult,
4647
onAction: (action: EditTitleAction) -> Unit = {},
47-
label: String = stringResource(Res.string.enter_title),
4848
) = AlertDialog(
4949
title = { Text(text = stringResource(Res.string.dialog_title_change_title)) },
5050
text = {
5151
var textRange by remember { mutableStateOf(TextRange(0, result.title.length)) }
5252
Column {
5353
if (result.loading) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
5454
TextField(
55-
modifier = Modifier.semantics { contentDescription = label },
55+
modifier = Modifier.testTag(ENTER_TITLE_DIALOG_TAG),
5656
value = TextFieldValue(text = result.title, selection = textRange),
5757
onValueChange = {
5858
textRange = it.selection
@@ -67,7 +67,7 @@ fun ShowEditTitleDialog(
6767
)
6868
}
6969
},
70-
confirmButton = { Button(onClick = { onAction(EditTitleAction.OnEditClick) }) { Text(stringResource(Res.string.yes)) } },
70+
confirmButton = { Button(modifier = Modifier.testTag(YES_BUTTON_TAG), onClick = { onAction(EditTitleAction.OnEditClick) }) { Text(stringResource(Res.string.yes)) } },
7171
dismissButton = { Button(onClick = { onAction(EditTitleAction.Cancel) }) { Text(stringResource(Res.string.cancel)) } },
7272
onDismissRequest = { onAction(EditTitleAction.Cancel) },
7373
)

ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/LanguageDialog.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import androidx.compose.ui.semantics.Role
2222
import androidx.compose.ui.unit.dp
2323
import com.softartdev.notedelight.model.LanguageEnum
2424
import com.softartdev.notedelight.presentation.settings.LanguageViewModel
25+
import com.softartdev.notedelight.util.CHOOSE_LANGUAGE_DIALOG_TITLE_TAG
26+
import com.softartdev.notedelight.util.OK_BUTTON_TAG
2527
import com.softartdev.notedelight.util.stringResource
2628
import com.softartdev.notedelight.util.testTag
2729
import io.github.softartdev.theme_prefs.generated.resources.ok
@@ -47,7 +49,12 @@ fun LanguageDialogBody(
4749
onLanguageSelected: (LanguageEnum) -> Unit,
4850
onDismiss: () -> Unit
4951
) = AlertDialog(
50-
title = { Text(text = stringResource(Res.string.choose_language)) },
52+
title = {
53+
Text(
54+
modifier = Modifier.testTag(CHOOSE_LANGUAGE_DIALOG_TITLE_TAG),
55+
text = stringResource(Res.string.choose_language)
56+
)
57+
},
5158
text = {
5259
Column(Modifier.selectableGroup()) {
5360
LanguageEnum.entries.forEach { language: LanguageEnum ->
@@ -77,7 +84,12 @@ fun LanguageDialogBody(
7784
}
7885
}
7986
},
80-
confirmButton = { Button(onClick = onDismiss) { Text(stringResource(ThemePrefsRes.string.ok)) } },
87+
confirmButton = {
88+
Button(
89+
modifier = Modifier.testTag(OK_BUTTON_TAG),
90+
onClick = onDismiss
91+
) { Text(stringResource(ThemePrefsRes.string.ok)) }
92+
},
8193
onDismissRequest = onDismiss,
8294
)
8395

ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/note/DeleteDialog.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import androidx.compose.material3.AlertDialog
44
import androidx.compose.material3.Button
55
import androidx.compose.material3.Text
66
import androidx.compose.runtime.Composable
7+
import androidx.compose.ui.Modifier
8+
import androidx.compose.ui.platform.testTag
9+
import com.softartdev.notedelight.util.YES_BUTTON_TAG
710
import com.softartdev.notedelight.presentation.note.DeleteViewModel
811
import com.softartdev.notedelight.ui.dialog.PreviewDialog
912
import notedelight.ui.shared.generated.resources.Res
@@ -24,7 +27,12 @@ fun DeleteDialog(deleteViewModel: DeleteViewModel) = DeleteDialog(
2427
fun DeleteDialog(onDeleteClick: () -> Unit, onDismiss: () -> Unit) = AlertDialog(
2528
title = { Text(text = stringResource(Res.string.action_delete_note)) },
2629
text = { Text(stringResource(Res.string.note_delete_dialog_message)) },
27-
confirmButton = { Button(onClick = onDeleteClick) { Text(stringResource(Res.string.yes)) } },
30+
confirmButton = {
31+
Button(
32+
modifier = Modifier.testTag(YES_BUTTON_TAG),
33+
onClick = onDeleteClick
34+
) { Text(stringResource(Res.string.yes)) }
35+
},
2836
dismissButton = { Button(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
2937
onDismissRequest = onDismiss,
3038
)

ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/note/SaveDialog.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
1212
import androidx.compose.material3.Text
1313
import androidx.compose.runtime.Composable
1414
import androidx.compose.ui.Modifier
15+
import androidx.compose.ui.platform.testTag
1516
import androidx.compose.ui.unit.dp
17+
import com.softartdev.notedelight.util.YES_BUTTON_TAG
1618
import com.softartdev.notedelight.presentation.note.SaveViewModel
1719
import com.softartdev.notedelight.ui.dialog.AlertDialogContent
1820
import com.softartdev.notedelight.ui.dialog.PreviewDialog
@@ -45,7 +47,10 @@ fun SaveDialog(
4547
Spacer(Modifier.width(8.dp))
4648
Button(onClick = doNotSaveAndNavBack) { Text(stringResource(Res.string.no)) }
4749
Spacer(Modifier.width(8.dp))
48-
Button(onClick = saveNoteAndNavBack) { Text(stringResource(Res.string.yes)) }
50+
Button(
51+
modifier = Modifier.testTag(YES_BUTTON_TAG),
52+
onClick = saveNoteAndNavBack
53+
) { Text(stringResource(Res.string.yes)) }
4954
}
5055
},
5156
title = { Text(stringResource(Res.string.note_changes_not_saved_dialog_title)) },

ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/ui/main/MainScreen.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ import androidx.compose.runtime.mutableStateOf
2727
import androidx.compose.runtime.remember
2828
import androidx.compose.ui.Alignment
2929
import androidx.compose.ui.Modifier
30-
import androidx.compose.ui.semantics.clearAndSetSemantics
31-
import androidx.compose.ui.semantics.contentDescription
30+
import androidx.compose.ui.platform.testTag
3231
import androidx.paging.PagingData
3332
import androidx.paging.compose.LazyPagingItems
3433
import androidx.paging.compose.collectAsLazyPagingItems
@@ -40,6 +39,8 @@ import com.softartdev.notedelight.presentation.main.NoteListResult
4039
import com.softartdev.notedelight.ui.Empty
4140
import com.softartdev.notedelight.ui.Error
4241
import com.softartdev.notedelight.ui.Loader
42+
import com.softartdev.notedelight.util.CREATE_NOTE_FAB_TAG
43+
import com.softartdev.notedelight.util.MAIN_SETTINGS_BUTTON_TAG
4344
import kotlinx.coroutines.flow.Flow
4445
import kotlinx.coroutines.flow.flowOf
4546
import notedelight.ui.shared.generated.resources.Res
@@ -72,7 +73,10 @@ fun MainScreen(
7273
TopAppBar(
7374
title = { Text(stringResource(Res.string.app_name)) },
7475
actions = {
75-
IconButton(onClick = { onAction(MainAction.OnSettingsClick) }) {
76+
IconButton(
77+
modifier = Modifier.testTag(MAIN_SETTINGS_BUTTON_TAG),
78+
onClick = { onAction(MainAction.OnSettingsClick) },
79+
) {
7680
Icon(
7781
imageVector = Icons.Default.Settings,
7882
contentDescription = stringResource(Res.string.settings)
@@ -109,10 +113,10 @@ fun MainScreen(
109113
}, floatingActionButton = {
110114
val text = stringResource(Res.string.create_note)
111115
ExtendedFloatingActionButton(
116+
modifier = Modifier.testTag(CREATE_NOTE_FAB_TAG),
112117
text = { Text(text) },
113118
onClick = { onAction(MainAction.OnNoteClick(0)) },
114119
icon = { Icon(Icons.Default.Add, contentDescription = Icons.Default.Add.name) },
115-
modifier = Modifier.clearAndSetSemantics { contentDescription = text }
116120
)
117121
}
118122
)

ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/ui/main/NoteDetail.kt

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ import androidx.compose.ui.Alignment
3434
import androidx.compose.ui.Modifier
3535
import androidx.compose.ui.platform.testTag
3636
import androidx.compose.ui.unit.dp
37+
import com.softartdev.notedelight.util.DELETE_NOTE_BUTTON_TAG
38+
import com.softartdev.notedelight.util.EDIT_TITLE_BUTTON_TAG
39+
import com.softartdev.notedelight.util.SAVE_NOTE_BUTTON_TAG
3740
import com.softartdev.notedelight.presentation.note.NoteAction
3841
import com.softartdev.notedelight.presentation.note.NoteResult
3942
import com.softartdev.notedelight.presentation.note.NoteViewModel
@@ -104,19 +107,28 @@ fun NoteDetailBody(
104107
}
105108
},
106109
actions = {
107-
IconButton(onClick = { onAction(NoteAction.Save(titleState.value, textState.value)) }) {
110+
IconButton(
111+
modifier = Modifier.testTag(SAVE_NOTE_BUTTON_TAG),
112+
onClick = { onAction(NoteAction.Save(titleState.value, textState.value)) },
113+
) {
108114
Icon(
109115
imageVector = Icons.Default.Save,
110116
contentDescription = stringResource(Res.string.action_save_note)
111117
)
112118
}
113-
IconButton(onClick = { onAction(NoteAction.Edit) }) {
119+
IconButton(
120+
modifier = Modifier.testTag(EDIT_TITLE_BUTTON_TAG),
121+
onClick = { onAction(NoteAction.Edit) },
122+
) {
114123
Icon(
115124
imageVector = Icons.Default.Title,
116125
contentDescription = stringResource(Res.string.action_edit_title)
117126
)
118127
}
119-
IconButton(onClick = { onAction(NoteAction.Delete) }) {
128+
IconButton(
129+
modifier = Modifier.testTag(DELETE_NOTE_BUTTON_TAG),
130+
onClick = { onAction(NoteAction.Delete) },
131+
) {
120132
Icon(
121133
imageVector = Icons.Default.Delete,
122134
contentDescription = stringResource(Res.string.action_delete_note)

ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/ui/settings/SettingsScreen.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,14 @@ import androidx.compose.runtime.getValue
3939
import androidx.compose.ui.Modifier
4040
import androidx.compose.ui.graphics.vector.ImageVector
4141
import androidx.compose.ui.platform.LocalUriHandler
42+
import androidx.compose.ui.platform.testTag
4243
import androidx.compose.ui.semantics.contentDescription
4344
import androidx.compose.ui.semantics.semantics
44-
import androidx.compose.ui.semantics.testTag
45+
import androidx.compose.ui.semantics.testTag as semanticsTestTag
4546
import androidx.compose.ui.semantics.toggleableState
47+
import com.softartdev.notedelight.util.ENABLE_ENCRYPTION_SWITCH_TAG
48+
import com.softartdev.notedelight.util.LANGUAGE_BUTTON_TAG
49+
import com.softartdev.notedelight.util.SET_PASSWORD_BUTTON_TAG
4650
import androidx.compose.ui.state.ToggleableState
4751
import androidx.compose.ui.unit.dp
4852
import androidx.lifecycle.compose.LifecycleResumeEffect
@@ -108,6 +112,7 @@ fun SettingsScreenBody(
108112
PreferenceCategory(stringResource(Res.string.theme), Icons.Default.Brightness4)
109113
ThemePreferenceItem(onClick = { onAction(SettingsAction.ChangeTheme) })
110114
Preference(
115+
modifier = Modifier.testTag(LANGUAGE_BUTTON_TAG),
111116
title = stringResource(Res.string.language),
112117
vector = Icons.Default.Language,
113118
onClick = { onAction(SettingsAction.ChangeLanguage) },
@@ -118,7 +123,7 @@ fun SettingsScreenBody(
118123
modifier = Modifier.semantics {
119124
contentDescription = enableEncryptionPrefTitle
120125
toggleableState = ToggleableState(result.encryption)
121-
testTag = enableEncryptionPrefTitle
126+
semanticsTestTag = ENABLE_ENCRYPTION_SWITCH_TAG
122127
},
123128
title = enableEncryptionPrefTitle,
124129
vector = Icons.Default.Lock,
@@ -127,6 +132,7 @@ fun SettingsScreenBody(
127132
Switch(checked = result.encryption, onCheckedChange = { onAction(SettingsAction.ChangeEncryption(it)) })
128133
}
129134
Preference(
135+
modifier = Modifier.testTag(SET_PASSWORD_BUTTON_TAG),
130136
title = stringResource(Res.string.pref_title_set_password),
131137
vector = Icons.Default.Password,
132138
onClick = { onAction(SettingsAction.ChangePassword) }

0 commit comments

Comments
 (0)