KMP for Mobile Native Developers — Part.5: Testing

Santiago Mattiauda
19 min readMay 14, 2024

--

Kotlin Multiplatform’s main goal is to allow developers to write code once and run it on multiple platforms. However, a mistake in this shared code can affect all platforms.

As Uncle Ben said: “With great power comes great responsibility.” This is where testing on our shared code is fundamental.

Software development requires testing to ensure the quality and reliability of the code. Kotlin Multiplatform offers various options and tools to carry out effective testing on all compatible platforms.

Kotlin Multiplatform not only allows us to share code written once, but also to write our tests for the different platforms we support.

Benefits of Testing in Kotlin Multiplatform

Testing in Kotlin Multiplatform offers several important benefits:

  1. Consistency across multiple platforms: Tests written in Kotlin Multiplatform can be run on all supported platforms, ensuring consistent software quality across the application’s ecosystem.
  2. Development efficiency: By writing tests once for all platforms, time and effort are saved compared to writing individual tests for each platform.
  3. Early error detection: Automated tests allow for early error identification in the code, which facilitates their correction before they become bigger problems in the development process.
  4. Increased confidence in changes: With a solid set of tests, developers can make changes to the code with more confidence, knowing that tests will identify potential issues.

Tools for testing in Kotlin Multiplatform

Kotlin Multiplatform provides a series of tools and libraries for performing tests on all compatible platforms.

The available libraries for writing tests are as follows. You can find references in the kmp-awesome repository.

  • Kotest — test framework: Powerful, elegant and flexible test framework for Kotlin with additional assertions, property testing and data driven testing
  • Turbine — test library: A small testing library for kotlinx.coroutines Flow
  • MockingBird — test framework: A Koltin multiplatform library that provides an easier way to mock and write unit tests for a multiplatform project
  • Mockative — Mocking with KSP: Mocking for Kotlin/Native and Kotlin Multiplatform using the Kotlin Symbol Processing API (KSP)
  • MocKMP — Mocking with KSP: A Kotlin/Multiplatform Kotlin Symbol Processor that generates Mocks & Fakes.
  • Mokkery — Mocking library: Mokkery is a mocking library for Kotlin Multiplatform, easy to use, boilerplate-free and compiler plugin driven. Highly inspired by MockK.
  • KLIP — Snapshot ((c|k)lip) manager for tests. Kotlin Multiplatform snapshot ((c|k)lip) manager for tests. Automatically generates and asserts against a persistent Any::toString() representation of the object until you explicitly trigger an update. Powered by kotlin compiler plugin to inject relevant keys and paths.
  • Assertk — Fluent assertions library

Even though many of these tools are created by the community, some have gained considerable adoption due to their popularity.

In this article, we will use some of these tools as examples.

Types of tests

When we discuss tests, we usually refer to the types of tests, which we represent in a pyramid as shown below.

As shown on the axes of the pyramid, the logic behind this distribution is based on two important aspects of the tests: the Speed of execution and the Coverage they offer.

As we have seen, the objective of Kotlin multiplatform is to share business logic across multiple platforms. We won’t consider UI tests, as these will depend on each platform’s UI framework. Therefore, we will focus on unit tests and integration tests.

Essential Unit Tests

When following best practices, make sure to implement unit tests in the following cases:

  • For ViewModels or presenters.
  • For the data layer, especially the repositories. Most of this layer should be platform-independent, allowing test doubles to replace the database modules and remote data sources in tests.
  • For other platform-independent layers, such as the Domain layer. This applies to use cases and interactions.
  • For utility classes, such as string manipulation and mathematical operations.

Let’s see what we mean when we mention the concept of unit.

What do we understand as a unit in our unit tests (Subject Under Test — SUT)?

What do we understand by unit? 🤔 In this case, we are going to define what we consider a unit and the reasons that support this concept.

A 1–1 relationship between tests and classes is often assumed, however, this practice can lead to having very fragile tests by making them too dependent on the implementation of each class. The goal should not be to achieve 100% coverage by force, but to trust in the tests to verify that the code works correctly.

Two useful tips for developing our tests are: these should not be modified unless the business specifications change, and any refactoring we perform on the code should not affect the tests 👌

Integration

In this type of test, we will focus on validating the interaction of the components of our application in a broader context. That is, the test requires a more complex scenario and includes interactions with parts external to our application, such as http request libraries, storage libraries, among others.

💡 It’s important to keep this in mind when choosing an external library. If it includes testing tools, it will facilitate this type of tests (we will see it in more detail in the example).

How to identify the scope of our tests

We will apply the concepts in an example. In this case, we have a simple application that displays a list of Rick and Morty characters.

This application is composed of the following components

For the implementation, we use a ViewModel as a state container for the views. We have two use cases: GetAllCharacters and RefreshCharacters, responsible for managing the information. Additionally, CharacterRepository implements the repository pattern. This class acts as a source of truth for our application, interacting with two data sources: a local source, CharacterLocalDataSource, and a network or remote source, CharacterNetworkDataSources. These latter components are defined as contracts, since we depend on concrete implementations (infrastructure), such as a database and an Http client to make a request to a server in this case.

Well, but how do we determine the scope of our tests? To determine the scope, let’s define what each type of test represents for us:

  • Unit Test: Tests the use case (1 test per use case) from the use case to the interfaces, therefore, it will not include infrastructure implementations.
  • Integration Test: Tests the interaction (integration) with the infrastructure implementation.

Once the concept has been defined for us and our application, let’s see what the scope is in the following image.

Before going to the code and implementing the tests in our example, let’s see how to set up and run these tests in our project.

How to set up and run our tests

Just like in the shared code, we will have dependencies that are applicable only for the tests. Therefore, we will establish a specific sourceset/target for the tests. In our case, this is what we will have for Android and iOS.

kotlin{
sourceSets {
commonTest.dependencies {
implementation(kotlin("test-common"))
implementation(kotlin("test-annotations-common"))
implementation(libs.resource.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
implementation(libs.kotest.framework.engine)

implementation(libs.ktor.client.mock)

implementation(libs.koin.test)
}

val androidTest = sourceSets.getByName("androidUnitTest") {
dependencies {
implementation(kotlin("test-junit"))
implementation(libs.junit)
implementation(libs.sqldelight.jvm)
}
}


iosTest.dependencies {
//ios testing dependencies
}

}

}

We will not always have to configure the specific dependencies for each platform, as we will need to validate the shared code.

Once the dependencies are set up in their respective sources, we can also define the directory for the tests, as seen in the following image.

How to run our tests

To perform our tests, we can choose to run each test individually or all the tests in our project at once.

To run a single test, our IDE provides a playback button next to our function annotated with @Test.

where by pressing said button we will see a dropdown that indicates on which platform we want to run our tests.

The alternative to run all the tests is by using the Gradle verification task, either from the IDE or command lines.

./gradlew :shared:allTests

This task will run our entire test suite for the different platforms we have configured in the project.

Once we know how to configure and run our tests, let’s see how to code them and what approaches we have in Kotlin Multiplatform.

Let’s get to the code

Let’s start with unit tests for our use cases. However, before we start, we must familiarize ourselves with some concepts to ensure that our unit tests will be fast and will provide us with early feedback.

How to avoid slow and coupled tests

Fakes

Fakes are nothing more than false implementations that we develop “in parallel” to the real implementation.

Stubs

On the other hand, Stubs bear some resemblance to Fakes, but unlike them, in this case the values are already predefined and we wouldn’t have to pass it as an argument when instantiating it.

Mocks

In the case of Mocks, we find a conceptually important difference, and that is unlike the previous ones, what we will do is model the response that the element is going to give as well as validate its interaction, that is, we verify the behavior and collaboration between the classes.

First unit test

Let’s start with the use case to update the characters in the local data source. The following sequence diagram will allow us to visualize the update flow in our code.

In our example, we will need to generate test doubles for our data sources (DB and API). First, we will do it manually and then, we will use a mock library.

Let’s start, the implementation of CharacterLocalDataSource will be as follows:

class InMemoryCharacterLocalDataSource : CharacterLocalDataSource {
//....
}

An in-memory implementation that will store the information only in the context of the test.

And for our CharacterNetworkDataSource we will have a Fake implementation.

class FakeCharacterNetworkDataSource : CharacterNetworkDataSource {
private val jsonLoader = JsonLoader()

override suspend fun find(id: Long): Result<NetworkCharacter> {
return all().fold(onSuccess = { characters ->
runCatching { characters.first { it.id == id } }
}, onFailure = { Result.failure(it) })
}

override suspend fun all(): Result<List<NetworkCharacter>> {
return runCatching {
jsonLoader.load<CharactersResponse>("characters.json").results
}
}
}

In this implementation, we use a class called JsonLoader that loads a json file (a file that contains a copy of the actual API response). This class makes it easier to work with data that is close to reality.

To implement this JsonLoader, we use kotlinx-resources, a library that will be useful in the upcoming tests. This library makes it easier to load files from our local project directory.

class JsonLoader {

private val json = Json {
ignoreUnknownKeys = true
}

fun load(file: String): String {
val loader = Resource("src/commonTest/resources/${file}")
return loader.readText()
}

internal inline fun <reified R : Any> load(file: String) =
this.load(file).convertToDataClass<R>()

internal inline fun <reified R : Any> String.convertToDataClass(): R {
return json.decodeFromString<R>(this)
}

}

The objective of this is to avoid complex instantiations and the generation of data for tests.

💡 Tips: We could also apply the Object Mother Pattern to the previous point so that, in addition to being more readable, they have greater maintainability and quick generation 👌

We can also rely on certain strategies to manage instances:

  • Traditional
  • Builder Pattern
  • ObjectMother
  • Named Arguments

Once our test doubles are ready, let’s proceed to examine the happy case in the first test.

class RefreshCharactersTest {

private val networkDataSource = FakeCharacterNetworkDataSource()
private val localDataSource = InMemoryCharacterLocalDataSource()
private val repository = CharacterRepository(localDataSource, networkDataSource)

private val refreshCharacters = RefreshCharacters(repository)

@AfterTest
fun tearDown() {
localDataSource.clear()
}

@Test
fun `When I call refresh update the local storage`() = runTest {
// test code
}
}

First, we generate the instances of our test object and then the repository that will be used in the use case. Both CharacterRepository and RefreshCharacters are instances of our classes.

💡 Another approach could have been to generate a test double for our repository but as we saw before we are stable test doubles of those components that have external dependencies such as I/O operations.

Once the instances have been generated, we define the test. Here we can use the Given-When-Then pattern to structure the test, we invoke the use case and make the assertion on the datasource. In this case, as we are using Flow, we are using Turbine to interact with the flows at test time.

class RefreshCharactersTest {

private val networkDataSource = FakeCharacterNetworkDataSource()
private val localDataSource = InMemoryCharacterLocalDataSource()
private val repository = CharacterRepository(localDataSource, networkDataSource)

private val refreshCharacters = RefreshCharacters(repository)

@AfterTest
fun tearDown() {
localDataSource.clear()
}

@Test
fun `When I call refresh update the local storage`() = runTest {
//Given
//When
refreshCharacters.invoke()
//Then
localDataSource.all.test {
assertEquals(true, awaitItem().isNotEmpty())
}
}
}

But how do I validate if the response from the NetworkDataSource is empty? Here we would have to modify our data source for that.

In this case, we would implement a stub.

class StubCharacterNetworkDataSource(
private val characters: MutableList<NetworkCharacter> = mutableListOf()
) : CharacterNetworkDataSource {

fun setCharacters(characters: List<NetworkCharacter>) {
this.characters.clear()
this.characters.addAll(characters)
}
override suspend fun find(id: Long): Result<NetworkCharacter> {
return all().fold(onSuccess = { characters ->
runCatching { characters.first { it.id == id } }
}, onFailure = { Result.failure(it) })
}

override suspend fun all(): Result<List<NetworkCharacter>> {
return runCatching { characters }
}
}
class StubCharacterNetworkDataSource(
private val characters: MutableList<NetworkCharacter> = mutableListOf()
) : CharacterNetworkDataSource {

fun setCharacters(characters: List<NetworkCharacter>) {
this.characters.clear()
this.characters.addAll(characters)
}
override suspend fun find(id: Long): Result<NetworkCharacter> {
return all().fold(onSuccess = { characters ->
runCatching { characters.first { it.id == id } }
}, onFailure = { Result.failure(it) })
}

override suspend fun all(): Result<List<NetworkCharacter>> {
return runCatching { characters }
}
}

And the test would look something like this

class RefreshCharactersTest {

private val characters = JsonLoader.load<CharactersResponse>("characters.json").results
private val networkDataSource = StubCharacterNetworkDataSource(characters.toMutableList())
private val localDataSource = InMemoryCharacterLocalDataSource()
private val repository = CharacterRepository(localDataSource, networkDataSource)

private val refreshCharacters = RefreshCharacters(repository)

@Test
fun `When I call refresh update the local storage`() = runTest {
refreshCharacters.invoke()
localDataSource.all.test {
assertEquals(true, awaitItem().isNotEmpty())
}
}

@Test
fun `When the service returns an empty response`() = runTest {
networkDataSource.setCharacters(emptyList())
refreshCharacters.invoke()
localDataSource.all.test {
assertEquals(true, awaitItem().isEmpty())
}
}
}

As we need to generate different cases based on the required information, it is crucial to add more flexibility to our mocks. We must remember that this is code that we also need to maintain. This is where the need to use mocking libraries such as Mockk or Mockito arises, which will probably sound familiar if you come from the Android world. In Kotlin Multiplatform, there are not yet solutions as popular as the ones mentioned, but they have been a source of inspiration for the community. In our example, we will use Mokkery, which, as its documentation indicates, is inspired by Mockk.

💡 Mockk is a mocking library implemented purely in Kotlin, so according to its documentation, it has multiplatform support but still has some issues with native targets such as iOS and macOS.

Let’s see how to replace our mocks and use Mokkery

import dev.mokkery.answering.returns
import dev.mokkery.everySuspend
import dev.mokkery.mock

class RefreshCharactersTest {

private val characters = CharactersResponseMother.characters()
private val networkDataSource = mock<CharacterNetworkDataSource>()
private val localDataSource = InMemoryCharacterLocalDataSource()
private val repository = CharacterRepository(localDataSource, networkDataSource)

private val refreshCharacters = RefreshCharacters(repository)

@Test
fun `When I call refresh update the local storage`() = runTest {
//Given
everySuspend {
networkDataSource.all()
} returns Result.success(characters)
//When
refreshCharacters.invoke()
//Then
localDataSource.all.test {
assertEquals(true, awaitItem().isNotEmpty())
}
}

@Test
fun `When the service returns an empty response`() = runTest {
//Given
everySuspend {
networkDataSource.all()
} returns Result.success(emptyList())
//When
refreshCharacters.invoke()
//Then
localDataSource.all.test {
assertEquals(true, awaitItem().isEmpty())
}
}
}

With Mokkery, we can create our mock of the CharacterNetworkDataSource interface.

private val networkDataSource = mock<CharacterNetworkDataSource>()

and give behavior to their functions

  everySuspend {
networkDataSource.all()
} returns Result.success(characters)

Another type of assertions that we can easily make with a mocking library is to verify that, for example, the ‘all’ method of the NetworkDataSource was called only once.

@Test
fun `When I call refresh update the local storage`() = runTest {
//Given
everySuspend {
networkDataSource.all()
} returns Result.success(characters)
//When
refreshCharacters.invoke()
//Then
verifySuspend(mode = exactly(1)) {
networkDataSource.all()
}
localDataSource.all.test {
assertEquals(true, awaitItem().isNotEmpty())
}
}

Well, now with Mokkery, we can test the rest of the cases in a simpler way.

💡 I recommend generating mocks of interfaces that are coupled to external data sources as we saw in the example.

Integration Tests

As shown in the initial diagram, we are going to validate the integration of the components and their behavior.

According to the defined scope regarding what integration tests validate, this is where infrastructure comes into play. This refers to the external libraries that we choose to manage our data. For example, we are using Ktor as an HTTP client and SQLDelight for local storage. Both libraries provide infrastructure for testing.

Testing with Ktor

Ktor offers us an “Engine” to create mock-ups of our services. To do this, we simply need to define an “Engine” of the “MockEngine” type and use it in our client definition.

val mockEngine = MockEngine { request ->
respond(
content = ByteReadChannel("""{"ip":"127.0.0.1"}"""),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}

In our example, we will create some small abstractions to reuse the settings in all tests involving Ktor. To do this, we will write the following code that sets up the Mock engine for testing.

fun testKtorClient(mockClient: MockClient = MockClient()): HttpClient {
val engine = testKtorEngine(mockClient)
return HttpClient(engine) {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
}

private fun testKtorEngine(interceptor: ResponseInterceptor) = MockEngine { request ->
val response = interceptor(request)
respond(
content = ByteReadChannel(response.content),
status = response.status,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}

You can see the complete code here.

Let’s see how our integration tests look

class RefreshCharactersIntegrationTest {

private val jsonResponse = JsonLoader.load("characters.json")
//KtorClient setup
private val mockClient = MockClient()
private val ktorClient = testKtorClient(mockClient)
private val networkDataSource = KtorCharacterNetworkDataSource(ktorClient)

private val localDataSource = InMemoryCharacterLocalDataSource()
private val repository = CharacterRepository(localDataSource, networkDataSource)

private val refreshCharacters = RefreshCharacters(repository)

@AfterTest
fun tearDown() {
localDataSource.clear()
}

@Test
fun `When I call refresh update the local storage`() = runTest {
//Given
val response = DefaultMockResponse(jsonResponse, HttpStatusCode.OK)
mockClient.setResponse(response)
//When
refreshCharacters.invoke()
//Then
localDataSource.all.test {
assertEquals(true, awaitItem().isNotEmpty())
}
}
}

In the test, we will generate an instance of our KtorCharacterNetworkDataSource, which is a concrete implementation of our CharacterNetworkDataSource interface. However, this time, we will initialize it with a test HttpClient using MockEngine.

Let’s do the same now for our CharacterLocalDataSource.

Testing with SQLDelight

SQLDelight can also be used during testing, but it requires a specific setup for each platform. When reviewing the implementation of SQLDelight, it is necessary to define the driver according to the platform.

// database.common.kt
expect class DriverFactory {
fun createDriver(): SqlDriver
}

fun createDatabase(driver: SqlDriver): CharactersDatabase {
return CharactersDatabase(driver)
}

// database.android.kt
actual class DriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(CharactersDatabase.Schema, context, "app_database.db")
}
}

//database.ios.kt
actual class DriverFactory {
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(CharactersDatabase.Schema, "app_database.db")
}
}

In our case, we have a DriverFactory class that is implemented in both Android and iOS with the corresponding drivers. For testing, the process is the same, but it is carried out in the test source sets.

//test.database.common.kt
expect fun testDbDriver(): SqlDriver

//test.database.android.kt
actual fun testDbDriver(): SqlDriver {
return JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
.also {
CharactersDatabase.Schema.create(it)
}
}
//test.database.ios.kt
actual fun testDbDriver(): SqlDriver {
return inMemoryDriver(CharactersDatabase.Schema)
}

As we can see, the concept used by this library is similar to the one we implemented in our InMemoryCharacterLocalDataSource, which is an in-memory implementation.

Let’s make the change in our test.

class RefreshCharactersIntegrationTest {

private val jsonResponse = JsonLoader.load("characters.json")

//KtorClient setup
private val mockClient = MockClient()
private val ktorClient = testKtorClient(mockClient)
private val networkDataSource = KtorCharacterNetworkDataSource(ktorClient)

//SQLDelight setup
private val db = createDatabase(driver = testDbDriver())
private val localDataSource = SQLDelightCharacterLocalDataSource(db)
private val repository = CharacterRepository(localDataSource, networkDataSource)

private val refreshCharacters = RefreshCharacters(repository)

@Test
fun `When I call refresh update the local storage`() = runTest {
//Given
val response = DefaultMockResponse(jsonResponse, HttpStatusCode.OK)
mockClient.setResponse(response)
//When
refreshCharacters.invoke()
//Then
localDataSource.all.test {
assertEquals(true, awaitItem().isNotEmpty())
}
}

@Test
fun `When the service returns an empty response`() = runTest {
//Given
val response = DefaultMockResponse("{}", HttpStatusCode.OK)
mockClient.setResponse(response)
//When
refreshCharacters.invoke()

localDataSource.all.test {
assertEquals(true, awaitItem().isEmpty())
}
}
}

Just like with Ktor, we also use instances of our implementation of the local data source, SQLDelightCharacterLocalDataSource.

So far, we have validated all the components of the use case with our tests. However, let’s see how we can improve the code in our project and how to measure such coverage.

Validation of our dependency injection.

In integration tests, we minimally adjust the configuration of external libraries to adapt it to the needs of the tests. However, doing this for each use case can be repetitive. This is where dependency injection and Koin allow us to optimize these configurations.

The first step is to configure our test dependencies with Koin

val testPlatformModule: Module = module {
single<SqlDriver> { testDbDriver() }
single<MockClient> { MockClient() }
single<HttpClient> { testKtorClient(get()) }
}

Once we have defined our dependencies, we set up our test to use Koin.

class RefreshCharactersIntegrationTest : KoinTest {

private val jsonResponse = JsonLoader.load("characters.json")

//KtorClient setup
private val mockClient: MockClient by inject()

@BeforeTest
fun setUp() {
startKoin {
modules(
testPlatformModule,
sharedModule
)
}
}

@AfterTest
fun tearDown() {
stopKoin()
}

@Test
fun `When I call refresh update the local storage`() = runTest {
//.....
}
}

and in our test we request the instance of the object under test, in this case RefreshCharacters.

@Test
fun `When I call refresh update the local storage`() = runTest {
//Given
val useCase = get<RefreshCharacters>() //from koin
val localDataSource = get<CharacterLocalDataSource>()
val response = MockResponse.ok(jsonResponse)
mockClient.setResponse(response)
//When
useCase.invoke()
//Then
localDataSource.all.test {
assertEquals(true, awaitItem().isNotEmpty())
}
}

Check our dependency graph with Koin

With Koin, we can verify the correct creation of our dependency graph. To do this, we must call the checkModules() function within a simple test. This will load our modules and try to run every possible definition for us.

// koin test functions
fun startTestKoin(testModule: Module): KoinApplication {
return startKoinApplication(listOf(testModule, sharedModule))
}

fun stopTestKoin() {
stopKoin()
}
class CheckModulesTest : KoinTest {

@AfterTest
fun tearDown() {
stopTestKoin()
}

@Test
fun `validate modules`() {
startTestKoin(testPlatformModule)
.checkModules()
}
}

In the event that we are missing a definition within our dependencies, the test will fail indicating which definition is missing. Something essential that we must check when we use Koin.

Coverage Metrics

Test coverage metrics show the percentage of tested code, essential for assessing the quality of tests and detecting untested areas. However, they do not guarantee the absence of errors. There are tools like Jacoco and Slather to calculate these metrics, which can be integrated into the development cycle. In Kotlin Multiplatform, we will use Kover, a Gradle plugin like Jacoco.

Kover

Kover is a set of solutions for collecting test coverage of Kotlin code compiled for JVM and Android platforms. One of the solutions is a Gradle plugin that we will see next.

Kover Features

  • Collection of code coverage through JVM tests (JS and native targets are not yet supported, this is important for our case).
  • Generation of HTML and XML reports.
  • Support for Kotlin JVM, Kotlin Multiplatform projects.
  • Support for Kotlin Android projects with build variants (instrumentation tests running on the Android device are not yet supported).
  • Support for mixed Kotlin and Java sources.
  • Verification rules with limits in the Gradle plugin to follow coverage.
  • Use of the JaCoCo library in the Gradle plugin as an alternative for coverage measurement and report generation.

To use Kover in our project we simply have to add its Gradle plugin.

plugins {
id("org.jetbrains.kotlinx.kover") version "0.7.6"
}

Once the plugin is added, we will be able to run the Kover tasks of gradle.

In this case we will execute

./gradlew :shared:koverHtmlReportDebug

To generate the HTML report that we see below

We can configure Kover to have coverage limits in our projects, adding different rules according to what we need. For example:

koverReport {
verify {
rule("Basic Line Coverage") {
isEnabled = true
bound {
minValue = 80 // Minimum coverage percentage
maxValue = 100 // Maximum coverage percentage (optional)
metric = MetricType.LINE
aggregation = AggregationType.COVERED_PERCENTAGE
}
}

rule("Branch Coverage") {
isEnabled = true
bound {
minValue = 70 // Minimum coverage percentage for branches
metric = MetricType.BRANCH
}
}
}
}

Even though Kover is still in Alpha and does not yet have support for Kotlin Native, it will be useful to us since we have shared code that we can validate.

Best Practices for Testing in Kotlin Multiplatform

To guarantee effective testing in Kotlin Multiplatform, some best practices that are applicable to software development in general can be followed.

  1. Write tests from the beginning: Starting to write tests at the beginning of development helps to quickly detect problems and build a solid testing base over time.
  2. Automate the tests: Test automation ensures their regular execution and reduces the possibility of human errors when running them manually.
  3. Use parameterized tests: Parameterized tests allow testing various data sets with a single test case, which makes them more concise and easier to maintain. For this point, we could use Kotest.
  4. Separate the tests from the implementations: Keeping the tests separate from the production code promotes better organization and facilitates future modifications.

Rules for Using Tests in Cross-Platform Projects

You have now created, configured, and run tests in Kotlin cross-platform applications. When working with tests in your future projects, keep the following in mind:

  • When writing tests for common code, use only cross-platform libraries, such as kotlin.test. You should add the dependencies to the commonTest source set.
  • The Asserter type of the kotlin.test API should only be used indirectly. Although the Asserter instance is visible, you don't need to use it directly in your tests.
  • Always stay within the test library API. Fortunately, the compiler and the IDE prevent you from using specific framework features.
  • Regardless of the framework you use to run tests in commonTest, it is recommended to run your tests with each framework you plan to use to ensure your development environment is properly configured.
  • When writing tests for platform-specific code, you can use the features of the corresponding framework, such as annotations and extensions.
  • You can run tests both from the IDE and using Gradle tasks.
  • When you run tests, HTML test reports are automatically generated.

Conclusion

Tests are a crucial part of the overall software development process. By leveraging available tools and best practices, developers can ensure the quality and reliability of code across all supported platforms. Effective testing not only detects errors early but also provides the necessary confidence to iterate and continuously improve the code. With a solid testing strategy, development teams can build robust and reliable cross-platform applications in Kotlin.

References

--

--