Skip to content
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

Update TestCoroutineContext to support structured concurrency. #3

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

villelaitila
Copy link

Updates TestCoroutineContext to support structured concurrency.

Issue: Kotlin#541

After Kotlin#810 lands this should move into kotlinx-coroutines-test. For now, putting up for API/code review.

Changes:

  • Slit up TestCoroutineContext into TestCoroutineDispatcher, TestCoroutineExceptionHandler and TestCoroutineScope
  • Added DelayController interface for testing libraries to expose Dispatcher control
  • Added ExceptionCaptor inerface for testing libraries to expose uncaught exceptions
  • Added builders for testing coroutines runBlockingTest and asyncTest
  • Removed old builder withTestContext

Usage (main entry points)

runBlockingTest

runBlockingTest is an immediate executor, delays will always auto-progress from either dispatch or scheduleResumeAfterDelay. Note that it provides a suspend lambda.

fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend CoroutineScope.() -> Unit)

@Test
fun blockingTest() = runBlockingTest {
    delay(1_000)
    assertTrue(true)
}

asyncTest

asyncTest is a lazy executor, any dispatch or scheduled events will be stored in a queue that must be manually progressed. Note, it does not provide a suspend lambda (see discussion below).

It is intended to be an advanced API that offers full control over dispatcher execution. In addition to offering detailed control of execution, it also provides extra test safety by ensuring all uncaught exceptions are handled and all launched coroutines are not active at the end of the test.

fun asyncTest(context: CoroutineContext? = null, testBody: TestCoroutineScope.() -> Unit) {

@Test
fun anotherTest() = asyncTest {
    launch { delay(1_000) }
    runCurrent() // run launch, but not delay
    advanceTimeToNextDelayed() // run delay (and any pending launches)
}

TestCoroutineDispatcher / TestCoroutineScope

When used in conjunction with Kotlin#810 most tests will declare a dispatcher as part of the setup method. As a convenience extension functions are provided

@Before
fun setup() {
    val dispatcher = TestCoroutineDispatcher()
    Dispatchers.setMain(dispatcher)
    scope = TestCoroutineScope(dispatcher)
}

@Test
fun oneTest() = scope.asyncTest {
   // for running an async test with control over execution
}

@Test
fun twoTests() = scope.runBlockingTest {
    // for running a test with an immediate executor
}

needs review: Tests that only have a dispatcher are allowed access to runBlockingTest as well without constructing a scope

val dispatcher: TestCoroutineDispatcher 

@Test
fun threeTests() = dispatcher.runBlockingTest {
   // for running a test with an immediate executor
}

runBlocking with time control

Tests can use runBlocking with time control if they pass the dispatcher directly:

@Test
fun foo() {
    val dispatcher = TestCoroutineDispatcher()
    runBlocking(dispatcher) {
          // use time control in runBlocking
    }
}

Library friendly interfaces

To help testing libraries expose test control, the time control and exception handling are split into interfaces. This allows a testing library to expose a smaller interface to tests than TestCoroutineDispatcher as well as combine the two interfaces onto a control object. In the current impl., TestCoroutineScope delegates both interfaces.

Ideally, testing libraries would prefer to expose these smaller interfaces. For example a JUnit4 rule might expose DelayController and delegate it to an instance of TestCoroutineDispatcher.

val scope = TestCoroutineScope()
scope.runCurrent() // delegated through DelayController

(discussion) suspend lambda on builders

Initially, it would provide a suspend lambda to asyncTest, however it turned out to be impossible to reconcile a dispatcher that never executed immediately and a test written like this:

@Test
fun suspendingTest() = asyncTest {
    val deferred = launch {
        withContext(Dispatchers.IO) { 3 }
    }
    deferred.await()
}

It is expected this test is not correct, since it does not call runCurrent or another function that would execute the pending tasks. However, since it is driven by a non-suspend function that is manually manipulating the dispatcher, it is not possible to both auto-advance through main body while not auto-advancing into the launch body.

Auto-advancing the main body would require that the dispatcher run any immediately scheduled task in the order they were scheduled, which will break the contract of runCurrent.

@softagram-bot
Copy link

Softagram Impact Report for pull/3 (head commit: 17d329f)

⭐ Visual Overview

Changed elements and changed dependencies.
Changed dependencies - click for full size
Graph legend
(Open in Softagram Desktop for full details)

⭐ Change Impact

How the changed files are used by the rest of the project
Impacted files - click for full size
Graph legend
(Open in Softagram Desktop for full details)

📄 Full report

Give feedback of this report to support@softagram.com

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants