I’m having a hard time with runTest because it mak...
# coroutines
p
I’m having a hard time with runTest because it makes things pass that should not pass. For example it just runs everything in sequence:
Copy code
@Test
  fun test() = runTest {
    repeat(5) {
      launch {
        println("enter")
        Thread.sleep(Random.nextLong(10))
        println("exit")
      }
    }
    println("start yield")
    yield()
  }
Will print:
Copy code
start yield
enter
exit
enter
exit
...
Is there any way to use runTest and having parallelism?
@Guilherme Almeida cc ☝️
g
Haven't really used coroutines till now but I think the problem is calling Thread.sleep() instead of delay(..) Delay is a suspend function, whereas thread.sleep hogs the current thread and blocks it.
👍 2
p
I do not think that’s the problem. Its just there to demonstrate that the coroutines run in sequence. In production I have an IO dispatcher and the test behavior is completely different than the production behavior due to the test scope.
@Dmitry Khalanskiy [JB] (ccing you as you voted that comment up)
d
runTest
uses a single-threaded dispatcher. It still allows concurrency, so if you use
delay
in your example, you'll see interleavings. However, there's no parallelism, as no additional threads are created. We do consider adding multi-threaded test dispatchers, but didn't collect enough use cases yet: typically, code that relies on coroutines doesn't have complex parallel interactions, so there's not much demand.
p
If you like I can show you a use case
d
Please do.
p
d
Sorry, can't accept the call now, another call happens in the same room right now (and will for several hours).
p
All right I’ll try to describe it. We we have a class for updating bi tracking user properties:
In it’s core it takes an object from the database and transforms it:
Now if that runs in a test, due to the database access blocking there might be a bug that if you call one of the tracking functions too often in sequence, one will not read the latest value but a stale value and do the copy based on it
message has been deleted
But this does not show up in test because it just runs one coroutine after another
d
One option: have
ioContext
be some multithreaded dispatcher, like
<http://Dispatchers.IO|Dispatchers.IO>
. I don't see any calls to
delay
, so the delay-skipping behavior of the test dispatcher is not needed.
runTest
supports waiting for computations forked off to other dispatchers.
Another option: instead of using a real database in tests, mock the database. In the mocked code for storing a value,
delay
so that the code that should execute in parallel has control of the test thread.
p
If I just use Dispatchers.IO then the runCurrent won’t ensure that the coroutines finished
This is the full test case
So in
allHeaders
the database has no values yet
d
That's an interesting use case, thanks! You have a need to await completion of forked-off tasks and then check their results, which is something
runCurrent
is capable of, but only because the tests are single-threaded and
runCurrent
blocks trying to execute the tasks. This is something that we'll have to carefully consider when designing multithreaded tests, this feels fairly common.
If there were some escape hatches, like
.age(
and
.appVersion(
functions also accepting a function that they should run on completion (to notify that the update finished), the test could be fixed by doing something like
Copy code
val deferred1 = CompletableDeferred<Unit>()
val deferred2 = CompletableDeferred<Unit>()
<http://updateUserProperties.app|updateUserProperties.app>(42) { deferred1.complete() }
updateUserProperties.appVersion("version") { deferred2.complete() }
deferred1.await()
deferred2.await()
However, adding this end-of-operation notification capability only for tests may be too much.
So, it looks to me like mocking the database (which, it looks like, you're already doing) in such a way that it `delay`s or `yield`s before storing the value is the way to go.
Copy code
launch {
  delay(Random.nextLong(10))
  actuallyUpdate(age = 42)
}
launch {
  delay(Random.nextLong(10))
  actuallyUpdate(version = "version")
}
Here, the second
actuallyUpdate
will run before the first one nearly 50% of the time.
p
Ha, I have an idea 🙂
That seems to do the job
d
Yeah, this should work reliably.
I'm struggling to understand whether this is idiomatic, and if not, whether this shows an area where our design could be improved. In any case, I did get a couple of takeaways from our discussion, thank you!
Here's another suggestion, maybe the best of both worlds:
buddyCount(
etc don't return anything. What if they returned the
Job
?
g
@Paul Woitaschek If we replace the context with IO + our own job then we might as well just run the tests on a
runBlocking(<http://Dispatchers.IO|Dispatchers.IO>)
right? Since the core of the test runs on a separate context we are barely using the delay skipping mechanism from the TestScope and the code looks a bit more confusing having the custom job 🤔 But I do think there is a space for a parallelism capable runTest 🤞
h
Just a random thought: if you have a problem with the blocking DB, how about suspending it? and use an extra coroutinecontext and job parameter/return to use structured concurrency? Just using launch without parameters for the testscope sounds dangerous to me.
p
runBlocking worked as injecting the test context into the HeaderRepo made things non flaky that should be flaky
This is the final result that discovered the flakyness which I now happily fixed by using a mutex
So +1 on a parallel test dispatcher 😉