Skip to content

Commit 6facc2a

Browse files
committed
feat: add support for main dispatcher in ComposeUiTestHarness
1 parent 248e4bd commit 6facc2a

8 files changed

Lines changed: 137 additions & 41 deletions

File tree

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

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,7 @@ import androidx.lifecycle.ViewModel
55
import androidx.lifecycle.viewModelScope
66
import assertk.assertThat
77
import assertk.assertions.isEqualTo
8-
import kotlin.test.AfterTest
9-
import kotlin.test.BeforeTest
108
import kotlin.test.Test
11-
import kotlinx.coroutines.ExperimentalCoroutinesApi
129
import kotlinx.coroutines.flow.MutableSharedFlow
1310
import kotlinx.coroutines.flow.MutableStateFlow
1411
import kotlinx.coroutines.flow.SharedFlow
@@ -18,29 +15,15 @@ import kotlinx.coroutines.flow.asStateFlow
1815
import kotlinx.coroutines.flow.update
1916
import kotlinx.coroutines.launch
2017
import kotlinx.coroutines.test.StandardTestDispatcher
21-
import net.thunderbird.core.testing.coroutines.MainDispatcherHelper
2218
import net.thunderbird.core.ui.testing.ComposeUiTestHarness
2319

24-
@OptIn(ExperimentalCoroutinesApi::class)
25-
class UnidirectionalViewModelKtTest : ComposeUiTestHarness() {
26-
27-
private val mainDispatcher = MainDispatcherHelper(StandardTestDispatcher())
28-
29-
@BeforeTest
30-
fun setUp() {
31-
mainDispatcher.setUp()
32-
}
33-
34-
@AfterTest
35-
fun tearDown() {
36-
mainDispatcher.tearDown()
37-
}
20+
class UnidirectionalViewModelKtTest : ComposeUiTestHarness(
21+
mainDispatcher = StandardTestDispatcher(),
22+
) {
3823

3924
@OptIn(ExperimentalTestApi::class)
4025
@Test
41-
fun `observe should emit state changes, allow event dispatch and expose effects`() = runComposeTest(
42-
effectContext = mainDispatcher.testDispatcher,
43-
) {
26+
fun `observe should emit state changes, allow event dispatch and expose effects`() = runComposeTest {
4427
val viewModel = TestViewModel()
4528
val effects = mutableListOf<TestEffect>()
4629
lateinit var stateDispatch: StateDispatch<TestState, TestEvent>

core/ui/testing/README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ fun runComposeTest(
3636
)
3737
```
3838

39-
The harness passes these values to Compose unchanged:
39+
The harness passes these values to Compose:
4040

4141
- `effectContext` is used for composition, `LaunchedEffect`, `rememberCoroutineScope`, and the main test clock.
4242
- `runTestContext` is used for the test block.
@@ -45,6 +45,29 @@ The harness passes these values to Compose unchanged:
4545
Do not pass the same `TestDispatcher` or scheduler to both `effectContext` and `runTestContext`. Compose requires these
4646
contexts to not share a `TestCoroutineScheduler`.
4747

48+
If the code under test uses `Dispatchers.Main`, `viewModelScope`, or lifecycle APIs that access
49+
`Dispatchers.Main.immediate`, configure a main dispatcher once on the test class. When `effectContext` is left as
50+
`EmptyCoroutineContext`, the harness uses that same dispatcher as Compose's effect context.
51+
52+
```kotlin
53+
class ExampleTest : ComposeUiTestHarness(
54+
mainDispatcher = StandardTestDispatcher(),
55+
) {
56+
57+
@Test
58+
fun `event updates state`() = runComposeTest {
59+
setContent {
60+
Screen()
61+
}
62+
63+
onNodeWithText("Submit").performClick()
64+
waitForIdle()
65+
66+
onNodeWithText("Submitted").assertExists()
67+
}
68+
}
69+
```
70+
4871
## Standard Dispatcher
4972

5073
Use `StandardTestDispatcher` when the test needs explicit scheduler control. Work is queued, so call `waitForIdle()`
@@ -66,6 +89,8 @@ fun `event updates state`() = runComposeTest(
6689
}
6790
```
6891

92+
If the harness already has a `mainDispatcher`, leave `effectContext` as its default to reuse the class-level dispatcher.
93+
6994
## Unconfined Dispatcher
7095

7196
Use `UnconfinedTestDispatcher` when the test benefits from eager execution and does not require explicit scheduler

core/ui/testing/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ kotlin {
1515

1616
sourceSets {
1717
commonMain.dependencies {
18+
api(libs.kotlinx.coroutines.test)
19+
20+
implementation(projects.core.testing)
1821
implementation(libs.jetbrains.compose.ui.test)
1922
}
2023

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

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import androidx.compose.ui.test.ExperimentalTestApi
44
import androidx.compose.ui.test.v2.runComposeUiTest
55
import kotlin.coroutines.CoroutineContext
66
import kotlin.time.Duration
7+
import kotlinx.coroutines.test.TestDispatcher
8+
import net.thunderbird.core.testing.coroutines.MainDispatcherHelper
79
import org.junit.runner.RunWith
810
import org.robolectric.RobolectricTestRunner
911

@@ -14,7 +16,9 @@ import org.robolectric.RobolectricTestRunner
1416
*/
1517
@OptIn(ExperimentalTestApi::class)
1618
@RunWith(RobolectricTestRunner::class)
17-
public actual abstract class ComposeUiTestHarness actual constructor() {
19+
public actual abstract class ComposeUiTestHarness actual constructor(
20+
private val mainDispatcher: TestDispatcher?,
21+
) {
1822

1923
/**
2024
* Runs [block] inside `runComposeUiTest`.
@@ -25,12 +29,20 @@ public actual abstract class ComposeUiTestHarness actual constructor() {
2529
testTimeout: Duration,
2630
block: suspend ComposeUiTestScope.() -> Unit,
2731
) {
28-
runComposeUiTest(
29-
effectContext = effectContext,
30-
runTestContext = runTestContext,
31-
testTimeout = testTimeout,
32-
) {
33-
AndroidComposeUiTestScope(this).block()
32+
val mainDispatcherHelper = mainDispatcher?.let(::MainDispatcherHelper)
33+
val resolvedEffectContext = resolveEffectContext(effectContext, mainDispatcher)
34+
35+
try {
36+
mainDispatcherHelper?.setUp()
37+
runComposeUiTest(
38+
effectContext = resolvedEffectContext,
39+
runTestContext = runTestContext,
40+
testTimeout = testTimeout,
41+
) {
42+
AndroidComposeUiTestScope(this).block()
43+
}
44+
} finally {
45+
mainDispatcherHelper?.tearDown()
3446
}
3547
}
3648
}

core/ui/testing/src/commonMain/kotlin/net/thunderbird/core/ui/testing/ComposeUiTestHarness.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,33 @@ import kotlin.coroutines.CoroutineContext
44
import kotlin.coroutines.EmptyCoroutineContext
55
import kotlin.time.Duration
66
import kotlin.time.Duration.Companion.seconds
7+
import kotlinx.coroutines.test.TestDispatcher
78

89
/**
910
* Platform-agnostic test harness for Compose UI tests.
1011
*
1112
* The harness wraps the platform-specific [androidx.compose.ui.test.v2.runComposeUiTest] implementation and exposes a
1213
* common [ComposeUiTestScope].
14+
*
15+
* @param mainDispatcher Optional dispatcher to install as [kotlinx.coroutines.Dispatchers.Main] for each
16+
* [runComposeTest] call. When set and [runComposeTest] uses the default [effectContext], the same dispatcher is also
17+
* used as Compose's effect context.
1318
*/
14-
public expect abstract class ComposeUiTestHarness() {
19+
public expect abstract class ComposeUiTestHarness(
20+
mainDispatcher: TestDispatcher? = null,
21+
) {
1522

1623
/**
1724
* Runs a Compose UI test.
1825
*
1926
* The parameters mirror [androidx.compose.ui.test.v2.runComposeUiTest]. [effectContext] is used for composition,
2027
* `LaunchedEffect`, `rememberCoroutineScope`, and the main test clock. [runTestContext] is used for the test block.
2128
* Compose requires these contexts to not share a [kotlinx.coroutines.test.TestCoroutineScheduler].
29+
* If this harness was created with a main dispatcher and [effectContext] is left as [EmptyCoroutineContext], the main
30+
* dispatcher is used as [effectContext].
2231
*
2332
* @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.
33+
* @param runTestContext The [CoroutineContext] to use for the [androidx.compose.ui.test.v2.runComposeUiTest] implementation.
2534
* @param testTimeout The timeout for the test, defaults to 60 seconds.
2635
* @param block The block of code to execute within the Compose UI test harness.
2736
*/
@@ -32,3 +41,12 @@ public expect abstract class ComposeUiTestHarness() {
3241
block: suspend ComposeUiTestScope.() -> Unit,
3342
)
3443
}
44+
45+
internal fun resolveEffectContext(
46+
effectContext: CoroutineContext,
47+
mainDispatcher: TestDispatcher?,
48+
): CoroutineContext = if (effectContext == EmptyCoroutineContext && mainDispatcher != null) {
49+
mainDispatcher
50+
} else {
51+
effectContext
52+
}

core/ui/testing/src/commonTest/kotlin/net/thunderbird/core/ui/testing/ValidateComposeUiTestHarness.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ import androidx.compose.foundation.layout.Column
44
import androidx.compose.foundation.text.BasicText
55
import androidx.compose.ui.Modifier
66
import androidx.compose.ui.platform.testTag
7-
import androidx.compose.ui.test.SemanticsNodeInteraction
8-
import assertk.assertThat
9-
import assertk.assertions.isInstanceOf
107
import kotlin.test.Test
118

129
class ValidateComposeUiTestHarness : ComposeUiTestHarness() {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package net.thunderbird.core.ui.testing
2+
3+
import assertk.assertThat
4+
import assertk.assertions.isSameInstanceAs
5+
import kotlin.coroutines.EmptyCoroutineContext
6+
import kotlin.test.Test
7+
import kotlinx.coroutines.ExperimentalCoroutinesApi
8+
import kotlinx.coroutines.test.StandardTestDispatcher
9+
import kotlinx.coroutines.test.TestCoroutineScheduler
10+
11+
private val mainDispatcher = StandardTestDispatcher(TestCoroutineScheduler())
12+
private val explicitEffectDispatcher = StandardTestDispatcher(TestCoroutineScheduler())
13+
14+
@OptIn(ExperimentalCoroutinesApi::class)
15+
class ValidateComposeUiTestHarnessMainDispatcher {
16+
17+
@Test
18+
fun `resolveEffectContext should use main dispatcher for default effect context`() {
19+
val result = resolveEffectContext(
20+
effectContext = EmptyCoroutineContext,
21+
mainDispatcher = mainDispatcher,
22+
)
23+
24+
assertThat(result).isSameInstanceAs(mainDispatcher)
25+
}
26+
27+
@Test
28+
fun `resolveEffectContext should preserve default effect context when main dispatcher is absent`() {
29+
val result = resolveEffectContext(
30+
effectContext = EmptyCoroutineContext,
31+
mainDispatcher = null,
32+
)
33+
34+
assertThat(result).isSameInstanceAs(EmptyCoroutineContext)
35+
}
36+
37+
@Test
38+
fun `resolveEffectContext should use explicit effect context over main dispatcher`() {
39+
val result = resolveEffectContext(
40+
effectContext = explicitEffectDispatcher,
41+
mainDispatcher = mainDispatcher,
42+
)
43+
44+
assertThat(result).isSameInstanceAs(explicitEffectDispatcher)
45+
}
46+
}

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

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ import androidx.compose.ui.test.ExperimentalTestApi
44
import androidx.compose.ui.test.v2.runComposeUiTest
55
import kotlin.coroutines.CoroutineContext
66
import kotlin.time.Duration
7+
import kotlinx.coroutines.test.TestDispatcher
8+
import net.thunderbird.core.testing.coroutines.MainDispatcherHelper
79

810
/**
911
* JVM implementation of [ComposeUiTestHarness].
1012
*
1113
* It runs Compose UI tests with JUnit.
1214
*/
1315
@OptIn(ExperimentalTestApi::class)
14-
public actual abstract class ComposeUiTestHarness actual constructor() {
16+
public actual abstract class ComposeUiTestHarness actual constructor(
17+
private val mainDispatcher: TestDispatcher?,
18+
) {
1519

1620
/**
1721
* Runs [block] inside `runComposeUiTest`.
@@ -22,12 +26,20 @@ public actual abstract class ComposeUiTestHarness actual constructor() {
2226
testTimeout: Duration,
2327
block: suspend ComposeUiTestScope.() -> Unit,
2428
) {
25-
runComposeUiTest(
26-
effectContext = effectContext,
27-
runTestContext = runTestContext,
28-
testTimeout = testTimeout,
29-
) {
30-
JvmComposeUiTestScope(this).block()
29+
val mainDispatcherHelper = mainDispatcher?.let(::MainDispatcherHelper)
30+
val resolvedEffectContext = resolveEffectContext(effectContext, mainDispatcher)
31+
32+
try {
33+
mainDispatcherHelper?.setUp()
34+
runComposeUiTest(
35+
effectContext = resolvedEffectContext,
36+
runTestContext = runTestContext,
37+
testTimeout = testTimeout,
38+
) {
39+
JvmComposeUiTestScope(this).block()
40+
}
41+
} finally {
42+
mainDispatcherHelper?.tearDown()
3143
}
3244
}
3345
}

0 commit comments

Comments
 (0)