Skip to content

Commit fc7a70e

Browse files
committed
fix: Stabilize UI tests with Idling Resources
- Introduce `CountingIdlingRes`, a new idling resource to track ongoing asynchronous operations in ViewModels, preventing flaky tests. - Wrap async calls in `SignInViewModel`, `SplashViewModel`, `SettingsViewModel`, and security-related ViewModels (`Enter`, `Confirm`, `Change`) with `increment()` and `decrement()` from `CountingIdlingRes`. - Create `ComposeCountingIdlingResource` to integrate `CountingIdlingRes` with the Compose UI test framework. - Register and unregister the `ComposeCountingIdlingResource` in the `setUp()` and `tearDown()` methods of `AbstractJvmUiTests` to automatically wait for async operations to complete. - Add `composeUiTest.awaitIdle()` in `EditTitleAfterCreateTestCase` to ensure UI is stable before interaction. - Add a new `TESTING_GUIDE.md` section explaining the usage and purpose of the new idling resource implementation.
1 parent 0997b06 commit fc7a70e

File tree

13 files changed

+200
-1
lines changed

13 files changed

+200
-1
lines changed

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import androidx.test.filters.LargeTest
88
import com.softartdev.notedelight.MainActivity
99
import leakcanary.DetectLeaksAfterTestSuccess
1010
import leakcanary.TestDescriptionHolder
11+
import org.junit.After
12+
import org.junit.Before
1113
import org.junit.Rule
1214
import org.junit.Test
1315
import org.junit.rules.RuleChain
@@ -25,6 +27,12 @@ class AndroidUiTests : AbstractJvmUiTests() {
2527
.around(DetectLeaksAfterTestSuccess())
2628
.around(composeTestRule)
2729

30+
@Before
31+
override fun setUp() = super.setUp()
32+
33+
@After
34+
override fun tearDown() = super.tearDown()
35+
2836
@Test
2937
override fun crudNoteTest() = super.crudNoteTest()
3038

app/desktop/src/jvmTest/kotlin/com/softartdev/notedelight/ui/DesktopUiTests.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class DesktopUiTests : AbstractJvmUiTests() {
4343

4444
@Before
4545
override fun setUp() = runTest {
46+
super.setUp()
4647
Logger.setLogWriters(platformLogWriter())
4748
when (GlobalContext.getKoinApplicationOrNull()) {
4849
null -> startKoin {
@@ -57,7 +58,6 @@ class DesktopUiTests : AbstractJvmUiTests() {
5758
safeRepo.buildDbIfNeed()
5859
val noteDAO: NoteDAO = get(NoteDAO::class.java)
5960
noteDAO.deleteAll()
60-
super.setUp()
6161
val lifecycleOwner = TestLifecycleOwner(
6262
initialState = Lifecycle.State.RESUMED,
6363
coroutineDispatcher = Dispatchers.Swing
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.softartdev.notedelight.util
2+
3+
import co.touchlab.kermit.Logger
4+
import kotlinx.coroutines.sync.Mutex
5+
import kotlinx.coroutines.sync.withLock
6+
7+
/**
8+
* An object that determines idleness by maintaining an internal counter.
9+
* When the counter is 0 - it is considered to be idle, when it is non-zero it is not idle.
10+
* This is similar to the way a [kotlinx.coroutines.sync.Semaphore] behaves.
11+
*
12+
* The counter may be incremented or decremented from any coroutine. Thread-safe operations
13+
* are ensured using a [Mutex]. If the counter reaches an illogical state (like counter less than zero),
14+
* an error is logged but no exception is thrown to prevent test failures.
15+
*
16+
* This object is used to wrap up operations that while in progress should block UI tests
17+
* from accessing the UI. It integrates with [com.softartdev.notedelight.util.ComposeCountingIdlingResource]
18+
* for Compose UI testing.
19+
*
20+
* ## Usage in ViewModels
21+
*
22+
* Wrap async operations in ViewModels with increment/decrement calls:
23+
*
24+
* ```kotlin
25+
* private fun loadData() = viewModelScope.launch {
26+
* CountingIdlingRes.increment()
27+
* try {
28+
* mutableStateFlow.update(Result::showLoading)
29+
* val data = withContext(Dispatchers.IO) {
30+
* repository.loadData()
31+
* }
32+
* mutableStateFlow.update { Result.Success(data) }
33+
* } catch (e: Throwable) {
34+
* handleError(e)
35+
* } finally {
36+
* mutableStateFlow.update(Result::hideLoading)
37+
* CountingIdlingRes.decrement()
38+
* }
39+
* }
40+
* ```
41+
*
42+
* ## Integration with UI Tests
43+
*
44+
* The [ComposeCountingIdlingResource] wraps this object and implements [androidx.compose.ui.test.IdlingResource]
45+
* for Compose UI tests. Tests automatically wait for the counter to reach zero before proceeding.
46+
*
47+
* ```kotlin
48+
* // In test setup
49+
* composeTestRule.registerIdlingResource(ComposeCountingIdlingResource)
50+
* ```
51+
*
52+
* @see [ComposeCountingIdlingResource] for Compose UI test integration
53+
*/
54+
object CountingIdlingRes {
55+
private val logger = Logger.withTag("CountingIdlingRes")
56+
private val mutex = Mutex()
57+
58+
var counter: Int = 0
59+
private set
60+
61+
val isIdleNow: Boolean
62+
get() = counter == 0
63+
64+
suspend fun increment() = mutex.withLock(action = ::unsafeIncrement)
65+
66+
suspend fun decrement() = mutex.withLock(action = ::unsafeDecrement)
67+
68+
private fun unsafeIncrement() {
69+
counter++
70+
logger.d { "IdlingResource incremented, counter = $counter" }
71+
}
72+
73+
private fun unsafeDecrement() {
74+
if (counter <= 0) {
75+
logger.e { "IdlingResource counter has been corrupted! Counter = $counter" }
76+
return
77+
}
78+
counter--
79+
logger.d { "IdlingResource decremented, counter = $counter" }
80+
}
81+
}

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModel.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import com.softartdev.notedelight.navigation.Router
1212
import com.softartdev.notedelight.repository.SafeRepo
1313
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
1414
import com.softartdev.notedelight.usecase.settings.RevealFileListUseCase
15+
import com.softartdev.notedelight.util.CountingIdlingRes
1516
import kotlinx.coroutines.flow.MutableStateFlow
1617
import kotlinx.coroutines.flow.StateFlow
1718
import kotlinx.coroutines.flow.update
@@ -58,6 +59,7 @@ class SettingsViewModel(
5859
private fun changeLanguage() = router.navigate(route = AppNavGraph.LanguageDialog)
5960

6061
private fun checkEncryption() = viewModelScope.launch {
62+
CountingIdlingRes.increment()
6163
mutableStateFlow.update(SecurityResult::showLoading)
6264
try {
6365
mutableStateFlow.update { result ->
@@ -70,10 +72,12 @@ class SettingsViewModel(
7072
handleError(e) { "error checking encryption" }
7173
} finally {
7274
mutableStateFlow.update(SecurityResult::hideLoading)
75+
CountingIdlingRes.decrement()
7376
}
7477
}
7578

7679
private fun changeEncryption(checked: Boolean) = viewModelScope.launch {
80+
CountingIdlingRes.increment()
7781
mutableStateFlow.update(SecurityResult::showLoading)
7882
try {
7983
when {
@@ -87,10 +91,12 @@ class SettingsViewModel(
8791
handleError(e) { "error changing encryption" }
8892
} finally {
8993
mutableStateFlow.update(SecurityResult::hideLoading)
94+
CountingIdlingRes.decrement()
9095
}
9196
}
9297

9398
private fun changePassword() = viewModelScope.launch {
99+
CountingIdlingRes.increment()
94100
mutableStateFlow.update(SecurityResult::showLoading)
95101
try {
96102
when {
@@ -101,10 +107,12 @@ class SettingsViewModel(
101107
handleError(e) { "error changing password" }
102108
} finally {
103109
mutableStateFlow.update(SecurityResult::hideLoading)
110+
CountingIdlingRes.decrement()
104111
}
105112
}
106113

107114
private fun showCipherVersion() = viewModelScope.launch {
115+
CountingIdlingRes.increment()
108116
mutableStateFlow.update(SecurityResult::showLoading)
109117
try {
110118
val cipherVersion: String? = checkSqlCipherVersionUseCase.invoke()
@@ -113,10 +121,12 @@ class SettingsViewModel(
113121
handleError(e) { "error checking sqlcipher version" }
114122
} finally {
115123
mutableStateFlow.update(SecurityResult::hideLoading)
124+
CountingIdlingRes.decrement()
116125
}
117126
}
118127

119128
private fun showDatabasePath() = viewModelScope.launch {
129+
CountingIdlingRes.increment()
120130
mutableStateFlow.update(SecurityResult::showLoading)
121131
try {
122132
val dbPath: String = safeRepo.dbPath
@@ -125,6 +135,7 @@ class SettingsViewModel(
125135
handleError(e) { "error getting database path" }
126136
} finally {
127137
mutableStateFlow.update(SecurityResult::hideLoading)
138+
CountingIdlingRes.decrement()
128139
}
129140
}
130141

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/change/ChangeViewModel.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.softartdev.notedelight.presentation.settings.security.FieldLabel
1111
import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase
1212
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
1313
import com.softartdev.notedelight.util.CoroutineDispatchers
14+
import com.softartdev.notedelight.util.CountingIdlingRes
1415
import kotlinx.coroutines.flow.MutableStateFlow
1516
import kotlinx.coroutines.flow.StateFlow
1617
import kotlinx.coroutines.flow.update
@@ -53,6 +54,7 @@ class ChangeViewModel(
5354
}
5455

5556
private fun change() = viewModelScope.launch(context = coroutineDispatchers.io) {
57+
CountingIdlingRes.increment()
5658
mutableStateFlow.update(ChangeResult::showLoading)
5759
try {
5860
val oldPassword = mutableStateFlow.value.oldPassword
@@ -89,6 +91,7 @@ class ChangeViewModel(
8991
e.message?.let { snackbarInteractor.showMessage(SnackbarMessage.Simple(it)) }
9092
} finally {
9193
mutableStateFlow.update(ChangeResult::hideLoading)
94+
CountingIdlingRes.decrement()
9295
}
9396
}
9497

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/confirm/ConfirmViewModel.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.softartdev.notedelight.navigation.Router
1010
import com.softartdev.notedelight.presentation.settings.security.FieldLabel
1111
import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase
1212
import com.softartdev.notedelight.util.CoroutineDispatchers
13+
import com.softartdev.notedelight.util.CountingIdlingRes
1314
import kotlinx.coroutines.flow.MutableStateFlow
1415
import kotlinx.coroutines.flow.StateFlow
1516
import kotlinx.coroutines.flow.update
@@ -47,6 +48,7 @@ class ConfirmViewModel(
4748
}
4849

4950
private fun confirm() = viewModelScope.launch(context = coroutineDispatchers.io) {
51+
CountingIdlingRes.increment()
5052
mutableStateFlow.update(ConfirmResult::showLoading)
5153
try {
5254
val password = mutableStateFlow.value.password
@@ -78,6 +80,7 @@ class ConfirmViewModel(
7880
e.message?.let { snackbarInteractor.showMessage(SnackbarMessage.Simple(it)) }
7981
} finally {
8082
mutableStateFlow.update(ConfirmResult::hideLoading)
83+
CountingIdlingRes.decrement()
8184
}
8285
}
8386

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/enter/EnterViewModel.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.softartdev.notedelight.presentation.settings.security.FieldLabel
1111
import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase
1212
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
1313
import com.softartdev.notedelight.util.CoroutineDispatchers
14+
import com.softartdev.notedelight.util.CountingIdlingRes
1415
import kotlinx.coroutines.flow.MutableStateFlow
1516
import kotlinx.coroutines.flow.StateFlow
1617
import kotlinx.coroutines.flow.update
@@ -46,6 +47,7 @@ class EnterViewModel(
4647
}
4748

4849
private fun enterCheck() = viewModelScope.launch(context = coroutineDispatchers.io) {
50+
CountingIdlingRes.increment()
4951
mutableStateFlow.update(EnterResult::showLoading)
5052
try {
5153
val password = mutableStateFlow.value.password
@@ -70,6 +72,7 @@ class EnterViewModel(
7072
e.message?.let { snackbarInteractor.showMessage(SnackbarMessage.Simple(it)) }
7173
} finally {
7274
mutableStateFlow.update(EnterResult::hideLoading)
75+
CountingIdlingRes.decrement()
7376
}
7477
}
7578

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModel.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import co.touchlab.kermit.Logger
77
import com.softartdev.notedelight.navigation.AppNavGraph
88
import com.softartdev.notedelight.navigation.Router
99
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
10+
import com.softartdev.notedelight.util.CountingIdlingRes
1011
import kotlinx.coroutines.flow.MutableStateFlow
1112
import kotlinx.coroutines.flow.StateFlow
1213
import kotlinx.coroutines.launch
@@ -28,6 +29,7 @@ class SignInViewModel(
2829
}
2930

3031
private fun signIn(pass: CharSequence) = viewModelScope.launch {
32+
CountingIdlingRes.increment()
3133
mutableStateFlow.value = SignInResult.ShowProgress
3234
try {
3335
mutableStateFlow.value = when {
@@ -44,6 +46,8 @@ class SignInViewModel(
4446
autofillManager?.cancel()
4547
router.navigate(route = AppNavGraph.ErrorDialog(message = error.message))
4648
mutableStateFlow.value = SignInResult.ShowSignInForm
49+
} finally {
50+
CountingIdlingRes.decrement()
4751
}
4852
}
4953
}

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/splash/SplashViewModel.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.softartdev.notedelight.model.PlatformSQLiteState
77
import com.softartdev.notedelight.navigation.AppNavGraph
88
import com.softartdev.notedelight.navigation.Router
99
import com.softartdev.notedelight.repository.SafeRepo
10+
import com.softartdev.notedelight.util.CountingIdlingRes
1011
import kotlinx.coroutines.flow.MutableStateFlow
1112
import kotlinx.coroutines.flow.StateFlow
1213
import kotlinx.coroutines.launch
@@ -20,6 +21,7 @@ class SplashViewModel(
2021
val stateFlow: StateFlow<Boolean> = mutableStateFlow
2122

2223
fun checkEncryption() = viewModelScope.launch {
24+
CountingIdlingRes.increment()
2325
try {
2426
logger.d { "Building database if need..." }
2527
safeRepo.buildDbIfNeed()
@@ -39,6 +41,7 @@ class SplashViewModel(
3941
router.navigate(route = AppNavGraph.ErrorDialog(message = error.message))
4042
} finally {
4143
mutableStateFlow.value = false
44+
CountingIdlingRes.decrement()
4245
}
4346
}
4447
}

docs/TESTING_GUIDE.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,65 @@ The `ui/test` module provides multiplatform UI tests that can run on all platfor
196196
**Platform-Specific Test Utilities**:
197197
The `ui/test-jvm` module provides JVM-specific utilities and abstractions for Android and Desktop tests, including platform-specific implementations of `runOnUiThread` and test setup.
198198

199+
### Idling Resources for UI Tests
200+
201+
UI tests need to wait for async operations to complete before making assertions. The project uses `CountingIdlingRes` to track ongoing async operations in ViewModels.
202+
203+
**Purpose**:
204+
- Prevents flaky tests by ensuring async operations complete before assertions
205+
- Allows tests to wait for ViewModel operations (database queries, network calls, etc.)
206+
- Integrates with Compose UI test framework via `ComposeCountingIdlingResource`
207+
208+
**Usage in ViewModels**:
209+
210+
All async operations in ViewModels should be wrapped with `CountingIdlingRes`:
211+
212+
```kotlin
213+
private fun loadData() = viewModelScope.launch {
214+
CountingIdlingRes.increment()
215+
mutableStateFlow.update(Result::showLoading)
216+
try {
217+
val data = withContext(Dispatchers.IO) {
218+
repository.loadData()
219+
}
220+
mutableStateFlow.update { Result.Success(data) }
221+
} catch (e: Throwable) {
222+
handleError(e)
223+
} finally {
224+
mutableStateFlow.update(Result::hideLoading)
225+
CountingIdlingRes.decrement()
226+
}
227+
}
228+
```
229+
230+
**Pattern**:
231+
1. Call `CountingIdlingRes.increment()` at the start of the coroutine
232+
2. Perform the async operation
233+
3. Call `CountingIdlingRes.decrement()` in the `finally` block
234+
235+
**Integration with Tests**:
236+
237+
The `ComposeCountingIdlingResource` wraps `CountingIdlingRes` and implements `IdlingResource` for Compose UI tests:
238+
239+
```kotlin
240+
// In AbstractJvmUiTests (ui/test-jvm)
241+
override fun setUp() {
242+
super.setUp()
243+
composeTestRule.registerIdlingResource(ComposeCountingIdlingResource)
244+
}
245+
246+
override fun tearDown() {
247+
super.tearDown()
248+
composeTestRule.unregisterIdlingResource(ComposeCountingIdlingResource)
249+
}
250+
```
251+
252+
Tests automatically wait for `CountingIdlingRes.counter` to reach zero before proceeding, ensuring all ViewModel operations complete.
253+
254+
**See also**:
255+
- [CountingIdlingRes](../core/domain/src/commonMain/kotlin/com/softartdev/notedelight/util/CountingIdlingRes.kt) - Core idling resource implementation
256+
- [ComposeCountingIdlingResource](../ui/test-jvm/src/main/kotlin/com/softartdev/notedelight/util/ComposeCountingIdlingResource.kt) - Compose UI test integration
257+
199258
## Testing by Layer
200259

201260
### Domain Layer Testing

0 commit comments

Comments
 (0)