In Kotlin multiplatform project with good architecture, we have whole business logic in common-client common module. This way it can be shared among clients.

The thing is that business logic needs to be unit tested and common modules testing is not like regular modules testing. In this article, I will show that it is not only possible but also really convenient, thanks to tools that Kotlin team gave us.

We will work on examples from Kt. Academy application.

Tools

To support common modules unit testing, Kotlin team made kotlin.test library. To use it, we need to add following dependencies to your common module build.gradle : (add it to common-client )

testCompile "org.jetbrains.kotlin:kotlin-test-common:$kotlin_version"

testCompile "org.jetbrains.kotlin:kotlin-test-annotations-common:$kotlin_version"

Once you have it, you can write tests in this module. Here is an example:

@Test

fun twoSideConversionTest() {

val dateFormatted = "2018-10-12T12:00:01"

assertEquals(

dateFormatted,

dateFormatted.parseDate().toDateFormatString()

)

}

As you can see, we mark test case using @Test annotation. Similar as in JUnit. There are also other annotations you can use:

@Test — use to a mark function as a test.

— use to a mark function as a test. @BeforeTest — Mark function to invoke it before every test in class.

— Mark function to invoke it before every test in class. @AfterTest — Mark function to invoke it after every test in class.

— Mark function to invoke it after every test in class. @Ignore — Marks to ignore defined test.

You can also use some of the predefined functions to make assertions:

assertTrue / assertFalse —asserts that predicate is true.

/ —asserts that predicate is true. assertEquals / assertNotEquals — asserts that two values are equal (checks using equality operator == )

/ — asserts that two values are equal (checks using equality operator ) assertSame / assertNotSame —asserts that two values are referentially equal (checks using referential equality operator === )

/ —asserts that two values are referentially equal (checks using referential equality operator ) assertNull / assertNotNull — asserts that value is equal to null .

/ — asserts that value is equal to . assertFails / assertFailsWith — asserts that block of code returns exception as it should.

/ — asserts that block of code returns exception as it should. expect — asserts that function block returns expected value.

— asserts that function block returns expected value. fail — used to mark part of your code that should never run during test execution.

Nearly all above functions accept either value ( assertTrue(users.number == 1) ) or function ( assertTrue { users.number == 1 } ).

Using above functions, we can easily write unit tests to our business logic. But how this is running if the common module is not exceptionable itself and it needs to be compiled to one of the platforms (JVM, JS or Native)? The answer is that we actually need to run tests on platform modules ( common-client-js and common-client-jvm ). This is why we also need to add additional dependencies to platform modules. Add following dependencies in JVM platform:

testCompile "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" testCompile "org.jetbrains.kotlin:kotlin-test:$kotlin_version"

Now you can run tests on JVM using test command on common-client-jvm :

./gradlew :common-client-jvm:test

JavaScript testing is currently not so much out-of-the-box and it needs more configuration. Check out this configuration. It is close to the minimal configuration for unit testing with Mocha framework. Once it is set-up, you can run tests using Gradle:

./gradlew :common-client-js:test

Remember that it is important to run tests on all platforms. It is because they have different implementations and they can have different actual declarations, so the fact that test passes on a single platform doesn’t mean they will also run on others.

Naming tests

It is really nice to name tests with fully descriptive names. Like in this examples:

@Test

fun `When onCreate, loads and displays list of news`() {

val view = NewsView()

overrideNewsRepository { NewsData(FAKE_NEWS_LIST_1) }

val presenter = NewsPresenter(view)

// When

presenter.onCreate()

// Then

assertEquals(FAKE_NEWS_LIST_1, view.newsList)

view.assertNoErrors()

} @Test

fun `BasePresenter is cancelling all jobs during onDestroy`() {

val jobs = (1..10).map { makeJob() }

val presenter = object : BasePresenter() {

fun addJobs(jobs: List<Job>) {

this.jobs += jobs

}

}

presenter.addJobs(jobs)

// When

presenter.onDestroy()

// Then

assertTrue(jobs.all { it.cancelled })

}

Only problem is that JavaScript doesn’t allow such names and we would get an error once we would try to run it in JS module. There is a simple solution: Kotlin/JS provides annotation JsName , that can be used to specify actual name of a function in compiled code. It is Kotlin/JS annotation, so it cannot be used in common module. Although, we can define expected declaration in test sources of common module common-client :

expect annotation class JsName constructor(val name: String)

Make it kotlin.js.JsName annotation on common-client-js module:

actual typealias JsName = kotlin.js.JsName

Define some annotation that will be ignored on common-client-js :

actual annotation class JsName actual constructor(

actual val name: String

)

Now, we need to annotate our tests:

@JsName("gettingAndDisplayingTest")

@Test

fun `When onCreate, loads and displays list of news`() {

// ...

} @JsName("cancellingJobTest")

@Test

fun `BasePresenter is cancelling all jobs during onDestroy`() {

// ...

}

With such declarations, we can freely run our tests on both platforms. Thanks to that, our test reports will display full names. Both for JVM and JS! This is head of my report from Mocha:

BasePresenterUnitTest

✓ BasePresenter is canceling all jobs during onDestroy

DateTimeUnitTest

✓ Two-way conversion should give the same result

✓ Ordering is correct after parse

FeedbackPresenterUnitTest

✓ Sends all data provided in form

✓ When sending feedback, loader is displayed

✓ When repository returns error, it is shown on view

✓ After data are sent, view is switching back to news list

Asynchronous unit testing

Sometimes we need to block current thread for a while to check if other processes take place. We have such situation in PeriodicCaller test. It is the class that supposes to call provided function once in every given number of milliseconds. Here is its actual implementation:

class PeriodicCallerImpl : PeriodicCaller {

override fun start(timeMillis: Long, callback: () -> Unit): Job {

return launchUI {

while (true) {

delay(timeMillis)

callback()

}

}

}

}

But how can we know that it works how it is supposed? A simple solution is to set it up to call a function every 50ms, wait for 1s and check if function was called around 20 times. Here is an implementation of the following test:

@Test

fun `Periodic caller for 50ms is called around 20 times during 1 second`() = runBlocking {

val caller = PeriodicCaller.PeriodicCallerImpl()

var count = 0

val job = caller.start(50) { count++ }

delay(1000)

job.cancel()

assertTrue(count in 18..22)

}

How does it work? First, we are running our test in coroutine what means that delay is not Thread.delay and it does not stop thread. Instead, it suspends coroutine (you can read here about the difference). Although, if we use launch instead or runBlocking , then test execution will be finished before the first assertion. runBlocking is a very special type of coroutine builder, that blocks thread once coroutine is suspended. (I imagine it sounds really complicated, but it is not when you understand how coroutines work. Soon I will write much more about coroutines for Kt. Academy, so subscribe if you want to be notified.)

The above test works for Kotlin/JVM perfectly. Bigger problem is with Kotlin/JS. The reason is that JavaScript is single threaded and this one thread cannot be blocked. Without blocking, we cannot stop test from ending before delay time is finished. It looks like pat, but Kotlin team provided the solution.

As you can see in this issue, Kotlin team is planning to support suspending tests. They also provided a workaround: We can make a function that uses runBlocking for Kotlin/JVM, and returns JavaScript Promise for Kotlin/JS. The Promise is treated like suspending tests by some frameworks, like Mocha, and code inside Promise can be freely suspended and the test will wait until it is finished. To implement it, we need following expected declaration: (you can define it in test source set of common-client )

expect fun <T> runTest(block: suspend () -> T)

This is its actual declaration for JVM:

actual fun <T> runTest(block: suspend () -> T) {

runBlocking { block() }

}

This is its actual declaration for JS:

actual fun <T> runTest(block: suspend () -> T): dynamic

= promise { block() }

Note, that we’ve used dynamic to trick expected declaration. It expects Unit , but in Kotlin/JS there can be dynamic returned instead even though it is actually Promise .

Now, we need to use this function to surround the whole test, and it will work correctly on both JVM and JS tests:

@Test

@JsName("numberOfCallsInTimeTest")

fun `Periodic caller for 50ms is called around 20 times during 1 second`() = runTest {

val caller = PeriodicCaller.PeriodicCallerImpl()

var count = 0

val job = caller.start(50) { count++ }

delay(1000)

job.cancel()

assertTrue(count in 18..22)

}

This way we can easily write multiplatform concurrent tests.

Summary

As you can see, common modules unit testing is really mature. Soon we will show you how you can use fully featured mocking library MockK in common modules. For now, we can confidently say that you can unit test everything. In this article I’ve shown how you can:

Use kotlin.test annotations and functions to improve testing.

annotations and functions to improve testing. Use Kotlin descriptive function names to make tests more self-explanatory.

Implement concurrent tests.

Check out Kt. Academy application project and its tests. Feel free to experiment with them and with adding some new tests. We will be happy to accept your contribution or to give you feedback :)