Skip to content

Introduce test module with testable main dispatcher #749

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Dec 17, 2018
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ GlobalScope.launch {
* [core](core/README.md) — Kotlin/JVM implementation of common coroutines with additional features:
* `Dispatchers.IO` dispatcher for blocking coroutines;
* `Executor.asCoroutineDispatcher()` extension, custom thread pools, and more.
* [test](core/README.md) — test utilities for coroutines, currently with one feature:
* `MainDispatcherInjector.inject()` to override `Dispatchers.Main` in tests.
* [debug](core/README.md) — debug utilities for coroutines.
* `DebugProbes` API to probe, keep track of, print and dump active coroutines.
* [js](js/README.md) — Kotlin/JS implementation of common coroutines with `Promise` support.
Expand Down
1 change: 1 addition & 0 deletions binary-compatibility-validator/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies {
testCompile "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"

testArtifacts project(':kotlinx-coroutines-core')
testArtifacts project(':kotlinx-coroutines-test')
testArtifacts project(':kotlinx-coroutines-debug')

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

2 changes: 1 addition & 1 deletion binary-compatibility-validator/resources/api.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
#

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

Expand Down
3 changes: 1 addition & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,7 @@ configure(subprojects.findAll { !internal.contains(it.name) && it.name != 'kotli

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

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

def core_docs_url = "https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/"
def core_docs_file = "$projectDir/core/kotlinx-coroutines-core/build/dokka/kotlinx-coroutines-core/package-list"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,14 @@ import kotlinx.coroutines.*
public interface MainDispatcherFactory {
val loadPriority: Int // higher priority wins

fun createDispatcher(): MainCoroutineDispatcher
/**
* Creates the main dispatcher. [allFactories] parameter contains all factories found by service loader.
* This method is not guaranteed to be idempotent.
*/
fun createDispatcher(allFactories: List<MainDispatcherFactory>): MainCoroutineDispatcher

/**
* Hint used along with error message when the factory failed to create a dispatcher.
*/
fun hintOnError(): String? = null
}
4 changes: 3 additions & 1 deletion core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ Module name below corresponds to the artifact name in Maven/Gradle.
## Modules

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

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# ServiceLoader support
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepnames class kotlinx.coroutines.test.internal.InjectableDispatcherFactory {}

# Most of volatile fields are updated with AFU and should not be mangled
-keepclassmembernames class kotlinx.** {
Expand Down
44 changes: 0 additions & 44 deletions core/kotlinx-coroutines-core/src/Dispatchers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -89,47 +89,3 @@ public actual object Dispatchers {
@JvmStatic
public val IO: CoroutineDispatcher = DefaultScheduler.IO
}

// Lazy loader for the main dispatcher
private object MainDispatcherLoader {
@JvmField
val dispatcher: MainCoroutineDispatcher =
MainDispatcherFactory::class.java.let { clz ->
ServiceLoader.load(clz, clz.classLoader).toList()
}.maxBy { it.loadPriority }?.tryCreateDispatcher() ?: MissingMainCoroutineDispatcher(null)

/**
* If anything goes wrong while trying to create main dispatcher (class not found,
* initialization failed, etc), then replace the main dispatcher with a special
* stub that throws an error message on any attempt to actually use it.
*/
private fun MainDispatcherFactory.tryCreateDispatcher(): MainCoroutineDispatcher =
try {
createDispatcher()
} catch (cause: Throwable) {
MissingMainCoroutineDispatcher(cause)
}
}

private class MissingMainCoroutineDispatcher(val cause: Throwable?) : MainCoroutineDispatcher(), Delay {
override val immediate: MainCoroutineDispatcher get() = this

override fun dispatch(context: CoroutineContext, block: Runnable) =
missing()

override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) =
missing()

private fun missing() {
if (cause == null) {
throw IllegalStateException(
"Module with the Main dispatcher is missing. " +
"Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android'"
)
} else {
throw IllegalStateException("Module with the Main dispatcher had failed to initialize", cause)
}
}

override fun toString(): String = "Main[missing${if (cause != null) ", cause=$cause" else ""}]"
}
92 changes: 92 additions & 0 deletions core/kotlinx-coroutines-core/src/internal/MainDispatchers.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package kotlinx.coroutines.internal

import kotlinx.coroutines.*
import java.util.*
import kotlin.coroutines.*

// Lazy loader for the main dispatcher
internal object MainDispatcherLoader {
@JvmField
val dispatcher: MainCoroutineDispatcher = loadMainDispatcher()

private fun loadMainDispatcher(): MainCoroutineDispatcher {
return try {
val factories = MainDispatcherFactory::class.java.let { clz ->
ServiceLoader.load(clz, clz.classLoader).toList()
}

factories.maxBy { it.loadPriority }?.tryCreateDispatcher(factories)
?: MissingMainCoroutineDispatcher(null)
} catch (e: Throwable) {
// Service loader can throw an exception as well
MissingMainCoroutineDispatcher(e)
}
}
}

/**
* If anything goes wrong while trying to create main dispatcher (class not found,
* initialization failed, etc), then replace the main dispatcher with a special
* stub that throws an error message on any attempt to actually use it.
*/
@InternalCoroutinesApi
public fun MainDispatcherFactory.tryCreateDispatcher(factories: List<MainDispatcherFactory>): MainCoroutineDispatcher =
try {
createDispatcher(factories)
} catch (cause: Throwable) {
MissingMainCoroutineDispatcher(cause, hintOnError())
}

/** @suppress */
@InternalCoroutinesApi
public fun MainCoroutineDispatcher.isMissing(): Boolean = this is MissingMainCoroutineDispatcher

private class MissingMainCoroutineDispatcher(
private val cause: Throwable?,
private val errorHint: String? = null
) : MainCoroutineDispatcher(), Delay {

override val immediate: MainCoroutineDispatcher get() = this

override fun isDispatchNeeded(context: CoroutineContext): Boolean {
missing()
}

override suspend fun delay(time: Long) {
missing()
}

override fun invokeOnTimeout(timeMillis: Long, block: Runnable): DisposableHandle {
missing()
}

override fun dispatch(context: CoroutineContext, block: Runnable) =
missing()

override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) =
missing()

private fun missing(): Nothing {
if (cause == null) {
throw IllegalStateException(
"Module with the Main dispatcher is missing. " +
"Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android'"
)
} else {
val message = "Module with the Main dispatcher had failed to initialize" + (errorHint?.let { ". $it" } ?: "")
throw IllegalStateException(message, cause)
}
}

override fun toString(): String = "Main[missing${if (cause != null) ", cause=$cause" else ""}]"
}

@InternalCoroutinesApi
public object MissingMainCoroutineDispatcherFactory : MainDispatcherFactory {
override val loadPriority: Int
get() = -1

override fun createDispatcher(allFactories: List<MainDispatcherFactory>): MainCoroutineDispatcher {
return MissingMainCoroutineDispatcher(null)
}
}
54 changes: 54 additions & 0 deletions core/kotlinx-coroutines-test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Module kotlinx-coroutines-test

Test utilities for `kotlinx.coroutines`. Provides `Dispatchers.setMain` to override the `Main` dispatcher.

## Using in your project

Add `kotlinx-coroutines-test` to your project test dependencies:
```
dependencies {
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.1.0'
}
```

**Do not** depend on this project in your main sources, all utilities are intended and designed to be used only from tests.

Once you have this dependency in the runtime, [`ServiceLoader`](https://docs.oracle.com/javase/8/docs/api/java/util/ServiceLoader.html) mechanism will
overwrite [Dispatchers.Main] with a testable implementation.

You can override the `Main` implementation using [setMain][setMain] method with any [CoroutineDispatcher] implementation, e.g.:

```kotlin

class SomeTest {

private val mainThreadSurrogate = newSingleThreadContext("UI thread")

@Before
fun setUp() {
Dispatchers.setMain(mainThreadSurrogate)
}

@After
fun tearDown() {
Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher
mainThreadSurrogate.close()
}

@Test
fun testSomeUI() = runBlocking {
launch(Dispatchers.Main) { // Will be launched in the mainThreadSurrogate dispatcher
...
}
}
}
```

<!--- MODULE kotlinx-coroutines-core -->
<!--- INDEX kotlinx.coroutines -->
[Dispatchers.Main]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/-main.html
[CoroutineDispatcher]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-dispatcher/index.html
<!--- MODULE kotlinx-coroutines-test -->
<!--- INDEX kotlinx.coroutines.test -->
[setMain]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/kotlinx.coroutines.-dispatchers/set-main.html
<!--- END -->
3 changes: 3 additions & 0 deletions core/kotlinx-coroutines-test/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dependencies {
implementation project(":kotlinx-coroutines-debug")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# ServiceLoader support
-keepnames class kotlinx.coroutines.test.internal.TestMainDispatcherFactory {}

# Most of volatile fields are updated with AFU and should not be mangled
-keepclassmembernames class kotlinx.** {
volatile <fields>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
kotlinx.coroutines.test.internal.TestMainDispatcherFactory
38 changes: 38 additions & 0 deletions core/kotlinx-coroutines-test/src/TestDispatchers.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/
@file:Suppress("unused")
@file:JvmName("TestDispatchers")

package kotlinx.coroutines.test

import kotlinx.coroutines.*
import kotlinx.coroutines.test.internal.*

/**
* Sets the given [dispatcher] as an underlying dispatcher of [Dispatchers.Main].
* All consecutive usages of [Dispatchers.Main] will use given [dispatcher] under the hood.
*
* It is unsafe to call this method if alive coroutines launched in [Dispatchers.Main] exist.
*/
@ExperimentalCoroutinesApi
public fun Dispatchers.setMain(dispatcher: CoroutineDispatcher) {
require(dispatcher !is TestMainDispatcher) { "Dispatchers.setMain(Dispatchers.Main) is prohibited, probably Dispatchers.resetMain() should be used instead" }
val mainDispatcher = Dispatchers.Main
require(mainDispatcher is TestMainDispatcher) { "TestMainDispatcher is not set as main dispatcher, have $mainDispatcher instead." }
mainDispatcher.setDispatcher(dispatcher)
}

/**
* Resets state of the [Dispatchers.Main] to the original main dispatcher.
* For example, in Android Main thread dispatcher will be set as [Dispatchers.Main].
* Used to clean up all possible dependencies, should be used in tear down (`@After`) methods.
*
* It is unsafe to call this method if alive coroutines launched in [Dispatchers.Main] exist.
*/
@ExperimentalCoroutinesApi
public fun Dispatchers.resetMain() {
val mainDispatcher = Dispatchers.Main
require(mainDispatcher is TestMainDispatcher) { "TestMainDispatcher is not set as main dispatcher, have $mainDispatcher instead." }
mainDispatcher.resetDispatcher()
}
Loading