Victor Brandalise Victor is very curious and likes understanding how and why things work; this is what drives him to learn new things. Most of his time is spent learning Android and Kotlin, but he also leaves some time for other subjects.

Test advanced coroutines with Kotlin Playground

7 min read 2048

Kotlin Logo

Testing is an essential part of a developer’s life. Code that’s tested is easier to maintain, and test also often serve as documentation.

If you’ve been working with Kotlin you’ve probably written plenty of tests before, but have you ever tried doing so with Kotlin Playground?

In this article, we’ll demonstrate how to use Kotlin Playground to test your code. We’ll also share some advanced coroutines concepts that can be used to simplify asynchronous code.

Jump ahead:

What is Kotlin Playground?

Sometimes we just want a quick way to test something, but opening Android Studio or another code editor takes some time. What if there was a way to quickly test your ideas? Well, that’s what Kotlin Playground is all about.

Playground is an editor capable of running Kotlin code and, most importantly, it runs on your browser. It’s developed and maintained by JetBrains. To access it head over to play.kotlinlang.org.

Kotlin Playground comes with all standard Kotlin libraries (collections, reflection, coroutines, etc.) but doesn’t support adding new libraries, meaning you can only use it to prototype or test something that depends on standard Kotlin libraries.

One great feature of Kotlin Playground is that it enables you to easily share the code you wrote with other people. Just copy the page URL and send it to others or use the “Share code” button to get an embeddable version of the code.

There’s also a hidden feature in Kotlin Playground. If you hold the Ctrl (Windows) or Command (Mac) key while clicking around, it will create more cursors. This is very useful if you need to edit multiple lines of code simultaneously:

Creating Multiple Cursors with Kotlin Playground

Since May 2022, mobile devices are also supported in Playground, so you don’t even need a computer to run Kotlin code. An actions toolbar has also been added that allows you to select the Kotlin version, select the compiler, and set program arguments.

Getting started with Playground

In order to run code in Playground we need to create a main method; otherwise, we’ll get a “No main method found in project” error.

For this tutorial, I’ll be using Kotlin v1.7.21 with the JVM compiler. Be sure to also add the following imports to the top of your file:

import kotlin.test.*
import kotlinx.coroutines.*

After doing so you should have a Playground that looks like this:

Playground Example, Running Kotlin Code

If you press the purple run button, the code should compile and you should not see any errors.

Understanding advanced Kotlin coroutines

Before we dive into testing, let’s review some Kotlin coroutines concepts.

CoroutineContext is an essential piece of coroutines, it defines how coroutines should behave and has four common elements:

  • Job: Controls the lifecycle of the coroutine; this element is a Job by default but you can also specify a SupervisorJob
  • CoroutineDispatcher: Defines which thread group the coroutine should execute in; most of the time you’ll use the Main or IO dispatchers
  • CoroutineExceptionHandler: Defines what should happen to uncaught exceptions
  • CoroutineName: Defines a name for the coroutine (more about this later in the article)

If you’ve used the withContext scope function, you might be wondering about its performance. If you have a long chain of operations and many of them use withContext to make sure the work is executed in the right thread, doesn’t that add a big performance overhead?

If we take a look at its implementation we’ll see there are two fast paths. The first compares the current context to the new context. If they are the same, there’s no need to change the thread. The second path compares the dispatchers. If they’re the same, there’s also no need to change the thread.

So if the dispatcher is the same we don’t have to worry about the thread, the overhead is minimal.

Flows are streams of values. In case you need to merge them there’s a neat operator for that, the combine operator allows you to merge as many flows as you need. Here’s its most simple signature:

fun <T1, T2, R> Flow<T1>.combine(flow: Flow<T2>, transform: suspend (T1, T2) -> R): Flow<R>

Let’s suppose you have flowA and flowB and you want to merge them. To do that, use the following code:

flowA.combine(flowB) { a, b ->
    // combine them and emit a new value
}

Or, you can use the top-level version of the above code:

combine(flowA, flowB) { a, b ->
    // combine them and emit a new value
}

Testing advanced coroutines

Now, let’s move on to testing. We’ll take a look at how to test normal suspend functions, how to switch/inject test dispatchers, and how to test launch/async, Job/SupervisorJob, and flows.



Testing normal suspend functions

Let’s start by defining a suspend function so that we’ll have something to write our tests against. Let’s pretend we have a function that returns whether or not a feature is enabled:

suspend fun isEnabled(): Boolean {
    delay(1_000)
    return true
}

Now, let’s write our test in the main function. assertTrue is provided by kotlin.test and asserts that the variable we pass in is true; otherwise, it throws an exception:

fun main() {
    runBlocking {
        val result = isEnabled()
          assertTrue(result)
    }
}

If we run this code we’ll get no output because the test passed, but if we change isEnabled to return false we’ll get the following error:

Exception in thread "main" java.lang.AssertionError: Expected value to be true.

If you’d like to add a custom message to assertTrue, use the following:

assertTrue(result, "result should be true but wasn't")

This will result in the following output:

Exception in thread "DefaultDispatcher-worker-1 @coroutine#1" java.lang.AssertionError: result should be true but wasn't

Switching/Injecting test dispatchers

It’s not good practice to hardcode dispatchers in your code. Whenever possible, you should accept the dispatcher as a parameter in your class.

Take a look at the following code:

class Database {    
    private val scope = CoroutineScope(Dispatchers.IO)


    fun saveToDisk() {
        scope.launch {
            ...
          }
    }
}

It’s very difficult to test this. To make things simpler, we can change it as follows:

class Database(private val scope: CoroutineScope) {
    fun saveToDisk() {
        scope.launch {
            ...
          }
    }
}

This way, the scope can be injected during testing.

Testing launch/async

launch and async are probably among the most used features in Compose, especially for Android developers. So, how do we test them?

Let’s start by defining a simple function that saves some state. You can’t call a suspend function since you don’t have a scope in your Activity or Fragment. But, using coroutines can help you avoid blocking the main thread, which can occur when you save things to the backend:

private val scope = CoroutineScope(Dispatchers.IO)

fun saveState() {
    scope.launch {
        // save state to backend, disk, ...
    }
}

Given that we don’t have a database or server, let’s just create a variable to pretend we did something:

private val state: Any? = null

fun saveState() {
    scope.launch {
        state = "application state"
    }
}

Now that we have something that is testable, let’s write our test:

fun main() {
    runBlocking {
        saveState()
          assertNotNull(state)
    }
}

This should work, right? Well, if we run this code we’ll get an error saying state is null. The problem with this code is that it’s calling saveState but it’s not waiting for it to execute, so we’re checking the result before our operation completes.

To fix that we can simply add a small delay before checking state, like so:

fun main() {
    runBlocking {
        saveState()
          delay(100)
          assertNotNull(state)
    }
}

This way, saveState has time to execute before we check the variable. However, using delay to test your code is not best practice. Why exactly 100ms and not 200ms? What if the code takes more than 100ms to execute? That’s why we should avoid this practice. A little later in the article, I’ll show you a better way to test this.

Testing async is a similar process; let’s modify saveState so it uses async:

fun saveState() {
    scope.launch {
        async { state = "application state" }.await()
    }
}

fun main() {
    runBlocking {
        saveState()
      delay(100)
          assertNotNull(state)
    }
}

Now, run this code; you’ll see that it works as expected.

Testing Job/SupervisorJob

Next, let’s explore how to test Jobs. If you use the GlobalScope it’s very difficult to test your code because you can’t replace or mock that. Also, since you can’t cancel the GlobalScope if you use it you basically lose the control over the job. Instead, we’ll define a custom scope for our tests that we can control if needed:

private val scope = CoroutineScope(Dispatchers.Default)

We can define a variable to keep track of the Job and also modify saveState to assign the result of launch to that variable:

private var job: Job? = null
private var state: Any? = null

fun saveState() {
    job = scope.launch {
        println("application state")
    }
}

Now, in the main function we can test the saveState function:

fun main() {
    runBlocking {
        saveState()
          job?.join()
          assertNotNull(state)
    }
}

Run this and you should not get any errors.


More great articles from LogRocket:


You may ask, “Why do we need to use join?” Well, calling launch doesn’t block the code execution, so we need to use join to prevent the main function from exiting.

Now that we know how to test a Job, let’s learn how to test a SupervisorJob.

A SupervisorJob is similar to a Job, except its children can fail independently of one another. Let’s start by changing our scope to have a SupervisorJob:

val scope = CoroutineScope(SupervisorJob())

Now in our main function, let’s add another launch that just throws an error:

fun main() {
    runBlocking {
        scope.launch { throw error("launch1") }.join()
      saveState().join()
          assertNotNull(state)
    }
}

Run this and you’ll see an error in the output. Well, shouldn’t SupervisorJob prevent that? If we analyze it in more detail we’ll see that it actually does prevent the error from bubbling up but it doesn’t prevent it from being logged. If you add a println statement below the assertion you’ll see that it actually gets printed; therefore even though the first launch threw an error, the second one was able to run.

Testing flows

To test flows, we’ll start by adding a new import:

import kotlinx.coroutines.flow.*

Next, let’s create a function called observeData that just returns a flow:

fun observeData(): Flow<String> {
    return flowOf("a", "b", "c")
}

Now, in our main method we can use the assertEquals function to compare the expected value and the actual value:

suspend fun main() = runBlocking<Unit> {
    val expected = listOf("a", "b", "c")
    assertEquals(expected, observeData().toList())
}

Strategies to improve testing

Now that we have a better understanding of how to test advanced coroutines, let’s look at some strategies to make coroutine testing and debugging easier.

Naming your CoroutineScope

If you have a lot of coroutine scopes it might be difficult to debug them because they are all using a naming convention similar to: @coroutine#1, @coroutine#2, etc.

To make debugging easier, we can add CoroutineName(...) to the CoroutineScope, like so:

private val scope = CoroutineScope(Dispatchers.Default + CoroutineName("database"))

If something fails in that scope we’ll get an error like the following:

Exception in thread "DefaultDispatcher-worker-1 @database#2" java.lang.IllegalStateException: …

Following coroutines best practices

To make testing easier, follow these coroutines best practices:

  • Inject dispatchers into your classes: Avoid hardcoding the dispatcher in your classes. Injecting it simplifies testing by allowing you to replace them
  • Avoid GlobalScope: It makes testing very hard, and that by itself is already a good reason to avoid it. It also makes it more difficult to control the lifecycle of your jobs

Conclusion

In this article, we explored how to use the Kotlin Playground to test coroutines. We also looked at some coroutines concepts like CoroutineContext and Flow. Finally, we discussed some strategies to make testing easier.

Now it’s your turn; the best way to learn something is by practicing. See you next time!

LogRocket: Instantly recreate issues in your Android apps.

LogRocket is an Android monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your Android apps.

LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.

Start proactively monitoring your Android apps — .

Victor Brandalise Victor is very curious and likes understanding how and why things work; this is what drives him to learn new things. Most of his time is spent learning Android and Kotlin, but he also leaves some time for other subjects.

Leave a Reply