4

I have a suspendible function that I want to assert does NOT complete with a result under certain conditions. I have tried to write the following extension, which aims to wait 5 seconds before asserting whether the Job is complete (I deem this check sufficient for knowing that the suspendible is still hanging):

First Approach:

// Extension Function
fun <T> TestScope.assertNotCompleted(block: suspend CoroutineScope.() -> T) {
    val result = async { block }
    advanceTimeBy(5000L)
    val isBlockComplete = result.isCompleted
    assertThat(isBlockComplete, equalTo(false))
}

// Usage
@Test
fun `Given no value is ready, when waitForValue is called, then the suspendable function is not complete`() =
    runTest {
        assertNotCompleted {
            someClass.waitForValue() 
        }
    }

In this scenario, however, result.isCompleted is always returning true whenever it should be false. And if I remove the advanceTimeBy(5000L), then result.isCompleted always returns false even if I modify the test to actually return something.

I have tried another approach, which throws an IllegalStateException by using getCompletionExceptionOrNull(). This does actually work, but it results in a strange interface whereby we need to annotate every test that uses it with an 'expected' property. I would like to avoid this if possible as it is possible that an exception is thrown elsewhere in the test and thus the test might pass incorrectly.

Second Approach:

// Extension Function
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> TestScope.assertNotCompleted(block: suspend CoroutineScope.() -> T) {
    async(block = block).run {
        this@assertNotCompleted.advanceTimeBy(5000L)
        getCompletionExceptionOrNull()
    }
}

// Usage - wanting to avoid the need for expected property
@Test(expected = IllegalStateException::class)
fun `Given no value is ready, when waitForValue is called, then the suspendable function is not complete`() =
    runTest {
        assertNotCompleted {
            someClass.waitForValue() 
        }
    }

I did try to catch this exception, however the tests either always pass or always fail, depending on the location of advanceTimeBy(5000L) - irrelevant if the suspendible can complete or not.

Thrid Approach:

// Extension Function
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> TestScope.assertNotCompleted(block: suspend CoroutineScope.() -> T) {
    runCatching {
        val result = async { 
            block
            // advanceTimeBy(5000L) Test ALWAYS passes
        }
        // advanceTimeBy(5000L) Test ALWAYS fails
        result.getCompletionExceptionOrNull()
    }.also { 
        assertThat(it.isFailure, equalTo(true))
    }
}

// Usage is same as first approach

Thanks in advance.

James Olrog
  • 344
  • 2
  • 10
  • 1
    Just looking, I don't think you are actually invoking block in the first attempt, just returning the function "block" from the async block, so it returns immediately when the dispatcher runs it. Should probably be `val result = async { block() }` instead. – FatalCatharsis Apr 20 '23 at 03:35

1 Answers1

2

Given a function that alwaysCompletes and a function that neverCompletes

suspend fun alwaysCompletes(): Int {
    delay(100)
    return 42
}

suspend fun neverCompletes() {
    while(true) {
        delay(1000)
    }
}

These tests pass:

@Test
fun testAlwaysCompletes() {
    runBlocking {
        val job = launch {
            someClass.alwaysCompletes()
        }
        delay(5000) // wait for 5 seconds
        assertThat(true, equalTo(job.isCompleted ))
    }
}

@Test
fun testNeverCompletes() {
    runBlocking {
        val job = launch {
            someClass.neverCompletes()
        }
        delay(5000) // wait for 5 seconds
        assertThat(false, equalTo(job.isCompleted ))
        job.cancelAndJoin() // cancel the job to avoid leaking resources
    }
}


or simulating the time with advanceTimeBy:



@Test
fun testAlwaysCompletes() = runTest {
    val job = launch {
        someClass.alwaysCompletes()
    }

    advanceTimeBy(10_000) // advance time by 10,000ms
    assertFalse(job.isActive) // job should not be active
    assertTrue(job.isCompleted) // job should now be completed

}

@Test
fun testNeverCompletes() = runTest {
    val job = launch {
        someClass.neverCompletes()
    }

    advanceTimeBy(10_000) // advance time by 10,000ms
    assertTrue(job.isActive) // job should still be active

    job.cancelAndJoin() // cancel the job to avoid leaking resources
    assertFalse(job.isActive) // job should now be cancelled and not active
}

To verify these tests assert that the suspend function has or hasn't completed, you can switch the functions over and both tests will fail

Jake Graham Arnold
  • 1,416
  • 2
  • 19
  • 40