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:
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:
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.
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:
If you press the purple run button, the code should compile and you should not see any errors.
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
dispatchersCoroutineExceptionHandler
: Defines what should happen to uncaught exceptionsCoroutineName
: 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 }
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.
suspend
functionsLet’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
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.
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.
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.
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.
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()) }
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.
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: …
To make testing easier, follow these coroutines best practices:
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 jobsIn 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 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 — try LogRocket for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowReact Native’s New Architecture offers significant performance advantages. In this article, you’ll explore synchronous and asynchronous rendering in React Native through practical use cases.
Build scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.