Skip to content

Commit 248e4bd

Browse files
committed
feat(testing): add shared Compose UI dispatcher handling
(cherry picked from commit 852700df057f1d3c7c564304880439fa0df9aa1f)
1 parent 99183ce commit 248e4bd

6 files changed

Lines changed: 156 additions & 15 deletions

File tree

core/ui/contract/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ kotlin {
1616

1717
commonTest.dependencies {
1818
implementation(projects.core.testing)
19+
implementation(projects.core.ui.testing)
1920
}
2021

2122
jvmTest.dependencies {

core/ui/contract/src/commonTest/kotlin/net/thunderbird/core/ui/contract/mvi/UnidirectionalViewModelKtTest.kt

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package net.thunderbird.core.ui.contract.mvi
22

33
import androidx.compose.ui.test.ExperimentalTestApi
4-
import androidx.compose.ui.test.runComposeUiTest
54
import androidx.lifecycle.ViewModel
65
import androidx.lifecycle.viewModelScope
76
import assertk.assertThat
@@ -18,13 +17,14 @@ import kotlinx.coroutines.flow.asSharedFlow
1817
import kotlinx.coroutines.flow.asStateFlow
1918
import kotlinx.coroutines.flow.update
2019
import kotlinx.coroutines.launch
21-
import kotlinx.coroutines.test.UnconfinedTestDispatcher
20+
import kotlinx.coroutines.test.StandardTestDispatcher
2221
import net.thunderbird.core.testing.coroutines.MainDispatcherHelper
22+
import net.thunderbird.core.ui.testing.ComposeUiTestHarness
2323

24-
class UnidirectionalViewModelKtTest {
24+
@OptIn(ExperimentalCoroutinesApi::class)
25+
class UnidirectionalViewModelKtTest : ComposeUiTestHarness() {
2526

26-
@OptIn(ExperimentalCoroutinesApi::class)
27-
val mainDispatcher = MainDispatcherHelper(UnconfinedTestDispatcher())
27+
private val mainDispatcher = MainDispatcherHelper(StandardTestDispatcher())
2828

2929
@BeforeTest
3030
fun setUp() {
@@ -38,7 +38,9 @@ class UnidirectionalViewModelKtTest {
3838

3939
@OptIn(ExperimentalTestApi::class)
4040
@Test
41-
fun `observe should emit state changes, allow event dispatch and expose effects`() = runComposeUiTest {
41+
fun `observe should emit state changes, allow event dispatch and expose effects`() = runComposeTest(
42+
effectContext = mainDispatcher.testDispatcher,
43+
) {
4244
val viewModel = TestViewModel()
4345
val effects = mutableListOf<TestEffect>()
4446
lateinit var stateDispatch: StateDispatch<TestState, TestEvent>
@@ -56,12 +58,14 @@ class UnidirectionalViewModelKtTest {
5658

5759
// Dispatch an event
5860
dispatch(TestEvent("Event 1"))
61+
waitForIdle()
5962

6063
assertThat(state.value.data).isEqualTo("TestState: Event 1")
6164
assertThat(effects.last().result).isEqualTo("TestEffect: Event 1")
6265

6366
// Dispatch another event
6467
dispatch(TestEvent("Event 2"))
68+
waitForIdle()
6569

6670
assertThat(state.value.data).isEqualTo("TestState: Event 2")
6771
assertThat(effects.last().result).isEqualTo("TestEffect: Event 2")

core/ui/testing/README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Core UI Testing
2+
3+
This module provides shared test utilities for Compose UI tests.
4+
5+
## Compose UI Test Harness
6+
7+
Use `ComposeUiTestHarness` for tests that exercise Compose UI from common test code.
8+
9+
```kotlin
10+
class ExampleTest : ComposeUiTestHarness() {
11+
12+
@Test
13+
fun `content is shown`() = runComposeTest {
14+
setContent {
15+
ExampleContent()
16+
}
17+
18+
onNodeWithText("Example").assertExists()
19+
}
20+
}
21+
```
22+
23+
The harness wraps the platform-specific `runComposeUiTest` implementation and exposes a common
24+
`ComposeUiTestScope`.
25+
26+
## Dispatcher Handling
27+
28+
`runComposeTest` mirrors Compose's `runComposeUiTest` dispatcher API:
29+
30+
```kotlin
31+
fun runComposeTest(
32+
effectContext: CoroutineContext = EmptyCoroutineContext,
33+
runTestContext: CoroutineContext = EmptyCoroutineContext,
34+
testTimeout: Duration = 60.seconds,
35+
block: suspend ComposeUiTestScope.() -> Unit,
36+
)
37+
```
38+
39+
The harness passes these values to Compose unchanged:
40+
41+
- `effectContext` is used for composition, `LaunchedEffect`, `rememberCoroutineScope`, and the main test clock.
42+
- `runTestContext` is used for the test block.
43+
- `testTimeout` controls the timeout for the Compose test.
44+
45+
Do not pass the same `TestDispatcher` or scheduler to both `effectContext` and `runTestContext`. Compose requires these
46+
contexts to not share a `TestCoroutineScheduler`.
47+
48+
## Standard Dispatcher
49+
50+
Use `StandardTestDispatcher` when the test needs explicit scheduler control. Work is queued, so call `waitForIdle()`
51+
before asserting results that depend on queued coroutine or Compose work.
52+
53+
```kotlin
54+
@Test
55+
fun `event updates state`() = runComposeTest(
56+
effectContext = StandardTestDispatcher(),
57+
) {
58+
setContent {
59+
Screen()
60+
}
61+
62+
onNodeWithText("Submit").performClick()
63+
waitForIdle()
64+
65+
onNodeWithText("Submitted").assertExists()
66+
}
67+
```
68+
69+
## Unconfined Dispatcher
70+
71+
Use `UnconfinedTestDispatcher` when the test benefits from eager execution and does not require explicit scheduler
72+
control. With this dispatcher, immediate assertions may not need `waitForIdle()`.
73+
74+
```kotlin
75+
@Test
76+
fun `event updates state eagerly`() = runComposeTest(
77+
effectContext = UnconfinedTestDispatcher(),
78+
) {
79+
setContent {
80+
Screen()
81+
}
82+
83+
onNodeWithText("Submit").performClick()
84+
85+
onNodeWithText("Submitted").assertExists()
86+
}
87+
```
88+
89+
## Verification
90+
91+
Run the narrow module checks after changing this module:
92+
93+
```shell
94+
./gradlew :core:ui:testing:jvmTest :core:ui:testing:testAndroidHostTest
95+
```
96+

core/ui/testing/src/androidMain/kotlin/net/thunderbird/core/ui/testing/ComposeUiTestHarness.android.kt

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,34 @@ package net.thunderbird.core.ui.testing
22

33
import androidx.compose.ui.test.ExperimentalTestApi
44
import androidx.compose.ui.test.v2.runComposeUiTest
5+
import kotlin.coroutines.CoroutineContext
6+
import kotlin.time.Duration
57
import org.junit.runner.RunWith
68
import org.robolectric.RobolectricTestRunner
79

810
/**
911
* Android implementation of [ComposeUiTestHarness].
1012
*
11-
* It uses Robolectric to run the tests and pro.
13+
* It runs Compose UI tests with Robolectric.
1214
*/
1315
@OptIn(ExperimentalTestApi::class)
1416
@RunWith(RobolectricTestRunner::class)
1517
public actual abstract class ComposeUiTestHarness actual constructor() {
1618

19+
/**
20+
* Runs [block] inside `runComposeUiTest`.
21+
*/
1722
public actual fun runComposeTest(
18-
block: ComposeUiTestScope.() -> Unit,
23+
effectContext: CoroutineContext,
24+
runTestContext: CoroutineContext,
25+
testTimeout: Duration,
26+
block: suspend ComposeUiTestScope.() -> Unit,
1927
) {
20-
runComposeUiTest {
28+
runComposeUiTest(
29+
effectContext = effectContext,
30+
runTestContext = runTestContext,
31+
testTimeout = testTimeout,
32+
) {
2133
AndroidComposeUiTestScope(this).block()
2234
}
2335
}
Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,34 @@
11
package net.thunderbird.core.ui.testing
22

3+
import kotlin.coroutines.CoroutineContext
4+
import kotlin.coroutines.EmptyCoroutineContext
5+
import kotlin.time.Duration
6+
import kotlin.time.Duration.Companion.seconds
7+
38
/**
49
* Platform-agnostic test harness for Compose UI tests.
510
*
6-
* It wraps the platform-specific implementation of the test harness to allow for consistent testing across platforms.
11+
* The harness wraps the platform-specific [androidx.compose.ui.test.v2.runComposeUiTest] implementation and exposes a
12+
* common [ComposeUiTestScope].
713
*/
814
public expect abstract class ComposeUiTestHarness() {
915

1016
/**
11-
* Run a compose UI test harness with the provided block.
17+
* Runs a Compose UI test.
18+
*
19+
* The parameters mirror [androidx.compose.ui.test.v2.runComposeUiTest]. [effectContext] is used for composition,
20+
* `LaunchedEffect`, `rememberCoroutineScope`, and the main test clock. [runTestContext] is used for the test block.
21+
* Compose requires these contexts to not share a [kotlinx.coroutines.test.TestCoroutineScheduler].
1222
*
23+
* @param effectContext The [CoroutineContext] to use for the [androidx.compose.ui.test.v2.runComposeUiTest] implementation.
24+
* @param runTestContext The [kotlinx.coroutines.test.StandardTestDispatcher] to use for the [androidx.compose.ui.test.v2.runComposeUiTest] implementation.
25+
* @param testTimeout The timeout for the test, defaults to 60 seconds.
1326
* @param block The block of code to execute within the Compose UI test harness.
1427
*/
1528
public fun runComposeTest(
16-
block: ComposeUiTestScope.() -> Unit,
29+
effectContext: CoroutineContext = EmptyCoroutineContext,
30+
runTestContext: CoroutineContext = EmptyCoroutineContext,
31+
testTimeout: Duration = 60.seconds,
32+
block: suspend ComposeUiTestScope.() -> Unit,
1733
)
1834
}

core/ui/testing/src/jvmMain/kotlin/net/thunderbird/core/ui/testing/ComposeUiTestHarness.jvm.kt

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,31 @@ package net.thunderbird.core.ui.testing
22

33
import androidx.compose.ui.test.ExperimentalTestApi
44
import androidx.compose.ui.test.v2.runComposeUiTest
5+
import kotlin.coroutines.CoroutineContext
6+
import kotlin.time.Duration
57

68
/**
79
* JVM implementation of [ComposeUiTestHarness].
810
*
9-
* It uses JUnit to run the tests.
11+
* It runs Compose UI tests with JUnit.
1012
*/
1113
@OptIn(ExperimentalTestApi::class)
1214
public actual abstract class ComposeUiTestHarness actual constructor() {
1315

16+
/**
17+
* Runs [block] inside `runComposeUiTest`.
18+
*/
1419
public actual fun runComposeTest(
15-
block: ComposeUiTestScope.() -> Unit,
20+
effectContext: CoroutineContext,
21+
runTestContext: CoroutineContext,
22+
testTimeout: Duration,
23+
block: suspend ComposeUiTestScope.() -> Unit,
1624
) {
17-
runComposeUiTest {
25+
runComposeUiTest(
26+
effectContext = effectContext,
27+
runTestContext = runTestContext,
28+
testTimeout = testTimeout,
29+
) {
1830
JvmComposeUiTestScope(this).block()
1931
}
2032
}

0 commit comments

Comments
 (0)