Skip to content

Commit 4327b21

Browse files
authored
Introduce test module with testable main dispatcher (#749)
Introduce kotlinx-coroutines-test module with API to set Dispatchers.Main * Dispatchers.setMain and Dispatchers.resetMain extensions * Use real factory in HandlerDispatcher instead of using initialized-once statics (prevent ExceptionInInitializerError) * Improve MissingCoroutineDispatcher diagnostic * Do not cache failed results in test dispatcher to allow reusing it between tests * Deprecate HandlerDispatcher.Main (was accessible only fom Java API) Fixes #746 Fixed #810
1 parent 054fcc2 commit 4327b21

File tree

29 files changed

+616
-70
lines changed

29 files changed

+616
-70
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ GlobalScope.launch {
3232
* [core](core/README.md) — Kotlin/JVM implementation of common coroutines with additional features:
3333
* `Dispatchers.IO` dispatcher for blocking coroutines;
3434
* `Executor.asCoroutineDispatcher()` extension, custom thread pools, and more.
35+
* [test](core/README.md) — test utilities for coroutines, currently with one feature:
36+
* `MainDispatcherInjector.inject()` to override `Dispatchers.Main` in tests.
3537
* [debug](core/README.md) — debug utilities for coroutines.
3638
* `DebugProbes` API to probe, keep track of, print and dump active coroutines.
3739
* [js](js/README.md) — Kotlin/JS implementation of common coroutines with `Promise` support.

binary-compatibility-validator/build.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies {
1313
testCompile "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
1414

1515
testArtifacts project(':kotlinx-coroutines-core')
16+
testArtifacts project(':kotlinx-coroutines-test')
1617
testArtifacts project(':kotlinx-coroutines-debug')
1718

1819
testArtifacts project(':kotlinx-coroutines-reactive')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
public final class kotlinx/coroutines/test/TestDispatchers {
2+
public static final fun resetMain (Lkotlinx/coroutines/Dispatchers;)V
3+
public static final fun setMain (Lkotlinx/coroutines/Dispatchers;Lkotlinx/coroutines/CoroutineDispatcher;)V
4+
}
5+

binary-compatibility-validator/resources/api.properties

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
33
#
44

5-
module.roots=core integration native reactive ui
5+
module.roots=core integration native reactive ui test
66
module.marker=build.gradle
77
module.ignore=kotlinx-coroutines-rx-example stdlib-stubs
88

build.gradle

+1-2
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,7 @@ configure(subprojects.findAll { !internal.contains(it.name) && it.name != 'kotli
155155

156156
// --------------- Configure sub-projects that are published ---------------
157157

158-
// todo: native is not published yet
159-
def unpublished = internal + ['kotlinx-coroutines-rx-example', 'example-frontend-js']
158+
def unpublished = internal + ['kotlinx-coroutines-rx-example', 'example-frontend-js', 'android-unit-tests']
160159

161160
def core_docs_url = "https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/"
162161
def core_docs_file = "$projectDir/core/kotlinx-coroutines-core/build/dokka/kotlinx-coroutines-core/package-list"

common/kotlinx-coroutines-core-common/src/internal/MainDispatcherFactory.kt

+10-1
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,14 @@ import kotlinx.coroutines.*
1111
public interface MainDispatcherFactory {
1212
val loadPriority: Int // higher priority wins
1313

14-
fun createDispatcher(): MainCoroutineDispatcher
14+
/**
15+
* Creates the main dispatcher. [allFactories] parameter contains all factories found by service loader.
16+
* This method is not guaranteed to be idempotent.
17+
*/
18+
fun createDispatcher(allFactories: List<MainDispatcherFactory>): MainCoroutineDispatcher
19+
20+
/**
21+
* Hint used along with error message when the factory failed to create a dispatcher.
22+
*/
23+
fun hintOnError(): String? = null
1524
}

core/README.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ Module name below corresponds to the artifact name in Maven/Gradle.
66
## Modules
77

88
* [kotlinx-coroutines-core](kotlinx-coroutines-core/README.md) &mdash; core coroutine builders and synchronization primitives.
9-
* [kotlinx-coroutines-debug](kotlinx-coroutines-debug/README.md) &mdash; coroutines debug utilities.
9+
* [kotlinx-coroutines-debug](kotlinx-coroutines-debug/README.md) &mdash; coroutines debug utilities.
10+
* [kotlinx-coroutines-test](kotlinx-coroutines-test/README.md) &mdash; coroutines test utilities such as settable Main dispatcher.
11+

core/kotlinx-coroutines-core/resources/META-INF/proguard/coroutines.pro

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# ServiceLoader support
22
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
33
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
4+
-keepnames class kotlinx.coroutines.test.internal.InjectableDispatcherFactory {}
45

56
# Most of volatile fields are updated with AFU and should not be mangled
67
-keepclassmembernames class kotlinx.** {

core/kotlinx-coroutines-core/src/Dispatchers.kt

-44
Original file line numberDiff line numberDiff line change
@@ -89,47 +89,3 @@ public actual object Dispatchers {
8989
@JvmStatic
9090
public val IO: CoroutineDispatcher = DefaultScheduler.IO
9191
}
92-
93-
// Lazy loader for the main dispatcher
94-
private object MainDispatcherLoader {
95-
@JvmField
96-
val dispatcher: MainCoroutineDispatcher =
97-
MainDispatcherFactory::class.java.let { clz ->
98-
ServiceLoader.load(clz, clz.classLoader).toList()
99-
}.maxBy { it.loadPriority }?.tryCreateDispatcher() ?: MissingMainCoroutineDispatcher(null)
100-
101-
/**
102-
* If anything goes wrong while trying to create main dispatcher (class not found,
103-
* initialization failed, etc), then replace the main dispatcher with a special
104-
* stub that throws an error message on any attempt to actually use it.
105-
*/
106-
private fun MainDispatcherFactory.tryCreateDispatcher(): MainCoroutineDispatcher =
107-
try {
108-
createDispatcher()
109-
} catch (cause: Throwable) {
110-
MissingMainCoroutineDispatcher(cause)
111-
}
112-
}
113-
114-
private class MissingMainCoroutineDispatcher(val cause: Throwable?) : MainCoroutineDispatcher(), Delay {
115-
override val immediate: MainCoroutineDispatcher get() = this
116-
117-
override fun dispatch(context: CoroutineContext, block: Runnable) =
118-
missing()
119-
120-
override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) =
121-
missing()
122-
123-
private fun missing() {
124-
if (cause == null) {
125-
throw IllegalStateException(
126-
"Module with the Main dispatcher is missing. " +
127-
"Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android'"
128-
)
129-
} else {
130-
throw IllegalStateException("Module with the Main dispatcher had failed to initialize", cause)
131-
}
132-
}
133-
134-
override fun toString(): String = "Main[missing${if (cause != null) ", cause=$cause" else ""}]"
135-
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package kotlinx.coroutines.internal
2+
3+
import kotlinx.coroutines.*
4+
import java.util.*
5+
import kotlin.coroutines.*
6+
7+
// Lazy loader for the main dispatcher
8+
internal object MainDispatcherLoader {
9+
@JvmField
10+
val dispatcher: MainCoroutineDispatcher = loadMainDispatcher()
11+
12+
private fun loadMainDispatcher(): MainCoroutineDispatcher {
13+
return try {
14+
val factories = MainDispatcherFactory::class.java.let { clz ->
15+
ServiceLoader.load(clz, clz.classLoader).toList()
16+
}
17+
18+
factories.maxBy { it.loadPriority }?.tryCreateDispatcher(factories)
19+
?: MissingMainCoroutineDispatcher(null)
20+
} catch (e: Throwable) {
21+
// Service loader can throw an exception as well
22+
MissingMainCoroutineDispatcher(e)
23+
}
24+
}
25+
}
26+
27+
/**
28+
* If anything goes wrong while trying to create main dispatcher (class not found,
29+
* initialization failed, etc), then replace the main dispatcher with a special
30+
* stub that throws an error message on any attempt to actually use it.
31+
*/
32+
@InternalCoroutinesApi
33+
public fun MainDispatcherFactory.tryCreateDispatcher(factories: List<MainDispatcherFactory>): MainCoroutineDispatcher =
34+
try {
35+
createDispatcher(factories)
36+
} catch (cause: Throwable) {
37+
MissingMainCoroutineDispatcher(cause, hintOnError())
38+
}
39+
40+
/** @suppress */
41+
@InternalCoroutinesApi
42+
public fun MainCoroutineDispatcher.isMissing(): Boolean = this is MissingMainCoroutineDispatcher
43+
44+
private class MissingMainCoroutineDispatcher(
45+
private val cause: Throwable?,
46+
private val errorHint: String? = null
47+
) : MainCoroutineDispatcher(), Delay {
48+
49+
override val immediate: MainCoroutineDispatcher get() = this
50+
51+
override fun isDispatchNeeded(context: CoroutineContext): Boolean {
52+
missing()
53+
}
54+
55+
override suspend fun delay(time: Long) {
56+
missing()
57+
}
58+
59+
override fun invokeOnTimeout(timeMillis: Long, block: Runnable): DisposableHandle {
60+
missing()
61+
}
62+
63+
override fun dispatch(context: CoroutineContext, block: Runnable) =
64+
missing()
65+
66+
override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) =
67+
missing()
68+
69+
private fun missing(): Nothing {
70+
if (cause == null) {
71+
throw IllegalStateException(
72+
"Module with the Main dispatcher is missing. " +
73+
"Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android'"
74+
)
75+
} else {
76+
val message = "Module with the Main dispatcher had failed to initialize" + (errorHint?.let { ". $it" } ?: "")
77+
throw IllegalStateException(message, cause)
78+
}
79+
}
80+
81+
override fun toString(): String = "Main[missing${if (cause != null) ", cause=$cause" else ""}]"
82+
}
83+
84+
@InternalCoroutinesApi
85+
public object MissingMainCoroutineDispatcherFactory : MainDispatcherFactory {
86+
override val loadPriority: Int
87+
get() = -1
88+
89+
override fun createDispatcher(allFactories: List<MainDispatcherFactory>): MainCoroutineDispatcher {
90+
return MissingMainCoroutineDispatcher(null)
91+
}
92+
}
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Module kotlinx-coroutines-test
2+
3+
Test utilities for `kotlinx.coroutines`. Provides `Dispatchers.setMain` to override the `Main` dispatcher.
4+
5+
## Using in your project
6+
7+
Add `kotlinx-coroutines-test` to your project test dependencies:
8+
```
9+
dependencies {
10+
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.1.0'
11+
}
12+
```
13+
14+
**Do not** depend on this project in your main sources, all utilities are intended and designed to be used only from tests.
15+
16+
Once you have this dependency in the runtime, [`ServiceLoader`](https://docs.oracle.com/javase/8/docs/api/java/util/ServiceLoader.html) mechanism will
17+
overwrite [Dispatchers.Main] with a testable implementation.
18+
19+
You can override the `Main` implementation using [setMain][setMain] method with any [CoroutineDispatcher] implementation, e.g.:
20+
21+
```kotlin
22+
23+
class SomeTest {
24+
25+
private val mainThreadSurrogate = newSingleThreadContext("UI thread")
26+
27+
@Before
28+
fun setUp() {
29+
Dispatchers.setMain(mainThreadSurrogate)
30+
}
31+
32+
@After
33+
fun tearDown() {
34+
Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher
35+
mainThreadSurrogate.close()
36+
}
37+
38+
@Test
39+
fun testSomeUI() = runBlocking {
40+
launch(Dispatchers.Main) { // Will be launched in the mainThreadSurrogate dispatcher
41+
...
42+
}
43+
}
44+
}
45+
```
46+
47+
<!--- MODULE kotlinx-coroutines-core -->
48+
<!--- INDEX kotlinx.coroutines -->
49+
[Dispatchers.Main]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/-main.html
50+
[CoroutineDispatcher]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-dispatcher/index.html
51+
<!--- MODULE kotlinx-coroutines-test -->
52+
<!--- INDEX kotlinx.coroutines.test -->
53+
[setMain]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/kotlinx.coroutines.-dispatchers/set-main.html
54+
<!--- END -->
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dependencies {
2+
implementation project(":kotlinx-coroutines-debug")
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# ServiceLoader support
2+
-keepnames class kotlinx.coroutines.test.internal.TestMainDispatcherFactory {}
3+
4+
# Most of volatile fields are updated with AFU and should not be mangled
5+
-keepclassmembernames class kotlinx.** {
6+
volatile <fields>;
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
kotlinx.coroutines.test.internal.TestMainDispatcherFactory
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
@file:Suppress("unused")
5+
@file:JvmName("TestDispatchers")
6+
7+
package kotlinx.coroutines.test
8+
9+
import kotlinx.coroutines.*
10+
import kotlinx.coroutines.test.internal.*
11+
12+
/**
13+
* Sets the given [dispatcher] as an underlying dispatcher of [Dispatchers.Main].
14+
* All consecutive usages of [Dispatchers.Main] will use given [dispatcher] under the hood.
15+
*
16+
* It is unsafe to call this method if alive coroutines launched in [Dispatchers.Main] exist.
17+
*/
18+
@ExperimentalCoroutinesApi
19+
public fun Dispatchers.setMain(dispatcher: CoroutineDispatcher) {
20+
require(dispatcher !is TestMainDispatcher) { "Dispatchers.setMain(Dispatchers.Main) is prohibited, probably Dispatchers.resetMain() should be used instead" }
21+
val mainDispatcher = Dispatchers.Main
22+
require(mainDispatcher is TestMainDispatcher) { "TestMainDispatcher is not set as main dispatcher, have $mainDispatcher instead." }
23+
mainDispatcher.setDispatcher(dispatcher)
24+
}
25+
26+
/**
27+
* Resets state of the [Dispatchers.Main] to the original main dispatcher.
28+
* For example, in Android Main thread dispatcher will be set as [Dispatchers.Main].
29+
* Used to clean up all possible dependencies, should be used in tear down (`@After`) methods.
30+
*
31+
* It is unsafe to call this method if alive coroutines launched in [Dispatchers.Main] exist.
32+
*/
33+
@ExperimentalCoroutinesApi
34+
public fun Dispatchers.resetMain() {
35+
val mainDispatcher = Dispatchers.Main
36+
require(mainDispatcher is TestMainDispatcher) { "TestMainDispatcher is not set as main dispatcher, have $mainDispatcher instead." }
37+
mainDispatcher.resetDispatcher()
38+
}

0 commit comments

Comments
 (0)