DEV Community

Gustavo Fão Valvassori for Touchlab

Posted on • Originally published at touchlab.co

Understanding and Configuring your Kotlin Multiplatform Mobile Test Suite

Writing tests is part of every developer's day-to-day routine. They help you write better and more reliable code. In addition, they can verify that your code does what it was supposed to do, and your changes haven't introduced bugs. In this article, I'll show you how to configure the test suite from your KMM project.

Understanding a test suite

Before configuring the test suite, we need to understand the different testing environments in the mobile ecosystem.

On Android, we have two different test suites. They can be Unit Tests and run in the JVM without the Android Framework or Instrumented Tests (having access to the framework) running in an Android device or emulator. To handle these different types of tests, you have different source sets on your project.

On the iOS side, things are more straightforward. Here, you can implement both Unit Tests and Instrumented Tests in the same target. That is allowed because all of them will run on the iOS Simulator or a physical device (You can change that, but by default, it's how things work). On iOS, the difference between Unit and Instrumentation is in the concept, while in Android, it's in where your code is and how you run them.

To understand a Unit Test or an Instrumented Test, we need to discuss the test pyramid briefly. It's a visual representation of the difference between the test types (or levels) described by Mike Cohn in the book "Succeeding with agile."

Image from The Practical Test Pyramid by Ham Vocke

I do not intend to go deep into the meaning of each level. If you want a better explanation, take a look at this blog post from Ham Vocke. But to help your understanding, here is their definition in short:

  • Unit tests: This type tries to test your software in the tiniest portion (or unit) that they can. When you implement unit tests, you will probably use mocks and stubs to provide these unit dependencies, creating a controlled environment. So, you can think of a Unit as a class or function on your project.
  • Service Tests: Most projects will have at least one integration. It can be an SQLite database to store data or an HTTP client to fetch network data. The service test is responsible for testing these types of integration. When you are also testing your integration with the platform (using some class/method provided by the Android/iOS SDK), you are also writing integration tests.
  • UI Test: Last but not least, this type is responsible for testing your UI. Here, you will implement code that will perform actions on your project (like clicking, typing, swiping, and other operations). You can also implement Snapshot Tests to guarantee that all screens are pixel-perfect.

When considering how many of each type to write, keep in mind that each project will have different needs, but service tests run faster than UI tests, and unit tests are faster than both. In other words, opt to have more tests in the lower part of the pyramid and less in the top part

Deciding which one you should implement will depend on your requirements. But you can use this rule for most cases:

  • If you want to test business logic isolated, create Unit Tests;
  • If you're going to test the integration of more than one component (from your project or the platform framework), create Integration Tests;
  • If you are going to test your UI itself or some UI behavior, implement UI Tests.

Understanding the KMM Test Suite

First of all, let's review a KMM project created by the Jetbrains KMM plugin. It is composed of three different types of projects:

  • the first one is an Android Application inside the androidApp folder;
  • the next one is an iOS Application inside the iosApp folder;
  • and last, we have a KMP library inside the shared folder.

Basic KMM project structure

This image represents the basic structure, and you will probably have something that looks like this. With that in mind, we now know that there are three different types of testing configurations that we need to make:

  • The Android test suite;
  • The iOS test suite;
  • The Shared test suite;

Configuring your Android Suite

As we discussed earlier, on Android, you can have Instrumented Tests (that requires an emulator) and Unit Tests (that will run in the JVM on your machine). The android project will have two different directories for that. So, inside your androidApp/src dir, you will have the androidTest for instrumented tests and the test folder for Unit tests.

Android source sets

First, we need to add some dependencies that the KMM plugin doesn't include as default. For the Unit Tests, we will require just the JUnit. For the Instrumented tests, we will need the JUnit Extensions and the Espresso testing library. Adds this to your androidApp/build.gradle file.

dependencies {
    // All other dependencies from your application
    testImplementation("junit:junit:4.13.2")

    androidTestImplementation("androidx.test.ext:junit:1.1.3")
    androidTestImplementation("androidx.test.ext:junit-ktx:1.1.3")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
}
Enter fullscreen mode Exit fullscreen mode

You also have to define your test instrumentation runner. For this, also add this line to your grade file:

android {
    defaultConfig {
        // All other app configs

        // Add this
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
}
Enter fullscreen mode Exit fullscreen mode

After syncing the project, we can finally implement our tests. Let's start with UnitTests. Here I'm going to create an ExampleUnitTest class inside the test/kotlin/path/to/your/package/. In that, I'll check if the platform name returned from the shared code is what we expect

class ExampleUnitTest {

    private val sut = Platform()

    @Test
    fun platform_returnsCorrectName() {
        val name = sut.platform
        assertEquals(name, "Android")
    }
}
Enter fullscreen mode Exit fullscreen mode

To run this test, you can run this command on your terminal ./gradlew :androidApp:testDebugUnitTest, or use the play button on the side of your test class name. If everything is okay, you will receive a "Build Successful" message.

For the Instrumented Tests, it's pretty similar, but instead of testing a method, we will check if our code is rendering the label with the correct text. To do that, we need to create our test inside the androidTest/kotlin/path/to/your/package/. For this test, I'll name it as ExampleInstrumentedTest.

@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {

    @get:Rule
    var activityScenarioRule = activityScenarioRule<MainActivity>()

    @Test
    fun textView_rendersHelloAndroid() {
        Espresso.onView(withId(R.id.text_view))
            .check(ViewAssertions.matches(isDisplayed()))
            .check(ViewAssertions.matches(withText("Hello, Android!")))
    }
}
Enter fullscreen mode Exit fullscreen mode

The process for running Instrumentation tests is very similar to Unit tests, but before running, remember to start an emulator (or connect a device) and wait for it to complete the boot. Otherwise, your tests are going to fail. The command for running in the terminal is ./gradlew :androidApp:connectedCheck.

This is the basic Android setup. For more details about testing an Android Application, check out the official android testing documentation here.

Configuring your iOS Suite

To configure the iOS test suite, we need to open the project in the XCode. After opening your project, click on the root node from your project on the left, and you will see that your project has only one target.

Adding iOS test target

To add the test target, click on the + button at the bottom, search for the Unit Testing Bundle, and add to your project.

Adding iOS Test Target

After creating it, you will have a new target, and you will notice a new group on your project navigator on the left. It's probably named iosAppTests, but this can change according to what you defined while creating the testing bundle.

With the testing target created, link the shared module to our test target. In this project, I'm using Cocoapods, and to add this dependency, edit your Podfile. Inside the iosApp target, add the iosAppTests target. In the end, it should look like this:

target 'iosApp' do
  use_frameworks!
  platform :ios, '14.1'
  pod 'shared', :path => '../shared'

  # Add this
  target 'iosAppTests' do
    inherit! :search_paths
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, quit Xcode, run pod install inside your project, and open it again. With that ready, we can start writing some unit tests.

First, right-click in this new group, and select New File. It will prompt you with a box for choosing which file template you want. Select Unit Test Case Class. We can name it ExampleUnitTest to reproduce what we did on the Android side. In the final step, Xcode will launch a prompt to select the saving location. For now, you can save it in this same folder. Just make sure that the iosAppTests is selected in the targets list.

Creating test class

You can delete all that boilerplate code inside the class. Add a testable import from the shared module, create the subject under test, and a testing function.

import XCTest
@testable import shared

class ExampleUnitTest: XCTestCase {

    private let sut = Platform()

    func testPlatform_returnsCorrectName() {
        let name = sut.platform
        XCTAssertEqual(name, "iOS")
    }
}
Enter fullscreen mode Exit fullscreen mode

To run this test, you can click on the "diamond" icon on the side of the class name or run the following command on your terminal.

xcodebuild \
  -workspace iosApp.xcworkspace \
  -scheme iosApp \
  -sdk iphonesimulator \
  -destination 'platform=iOS Simulator,name=iPhone 13,OS=15.0' \
  build test
Enter fullscreen mode Exit fullscreen mode

For UI Tests, you need to add another target. Search, and add the UI Testing Bundle to your project.

Adding iOS UI Test Target

And then, add the target to your Podfile, as shown below.

target 'iosApp' do
  use_frameworks!
  platform :ios, '14.1'
  pod 'shared', :path => '../shared'

  target 'iosAppTests' do
    inherit! :search_paths
  end

  # Add this
  target 'iosAppUITests' do
    inherit! :search_paths
  end
end
Enter fullscreen mode Exit fullscreen mode

The process is very similar to what we've done for the Unit Test to create a UI test, but this time we will add a UI Test Case Class. The name can be ExampleUITest, and we will do the same as we did on Android Instrumented Tests. Don't forget to check the target at the end of the creation. It should have the iosAppUITests selected.

Creating UI Test class

Here, you can delete everything except the setupWithError. It has some code responsible for launching the application under tests and stopping the test suite if some error was found in this class. Knowing that, let's start working on the test. We will reproduce what we did on the Android side and check if the text Hello, iOS! is present. Here is the code for that:

import XCTest

class ExampleUITest: XCTestCase {

    lazy var application = XCUIApplication()

    override func setUpWithError() throws {
        continueAfterFailure = false
        application.launch()
    }

    func testApplication_shouldRenderHelloText() {
        let text = application.staticTexts["Hello, iOS!"]
        XCTAssert(text.exists)
    }
}
Enter fullscreen mode Exit fullscreen mode

To run the tests, the process is the same as Unit Tests. You can run from the diamond icon or with the same command line command.

For more details about iOS testing, check the official docs page.

Configuring your Shared Test Suite

Finally, we will set up tests for the shared module. This module is a mix of what we've seen before. The KMP project created by the KMM plugin is composed of two different platforms (Android + iOS) and a shared source set that allows you to write (and test) code that works on both platforms. This module will have a lot of source sets. It will have the implementation folders (commonMain, androidMain, and iosMain) and at least one test folder (commonTest, androidTest, and iosTest).

Be aware that we have a small trap. As I've mentioned before, Android tests can be divided into Integrated and Unit tests. The same still happens in the shared module. For example, an Android project lets you implement the integration tests inside the androidTest folder, but in the KMP project, this folder is for unit tests. Therefore, we should implement android instrumentation tests inside the androidAndroidTest source set. This is only required if you need access to the context or any other Android SDK class.

Another quick thing that I need to mention is that we will not require a simulator for the iOS tests. Here, your tests will run directly on your machine, but you will not lose access to the platform classes. So everything should still work fine.

To start writing tests, if you hadn't checked the "Add shared tests" checkbox while creating a new project, you would need to add the testing dependencies to the shared module. Here they are:

kotlin {
    sourceSets {
        val commonTest by getting {
            dependencies {
                implementation(kotlin("test-common"))
                implementation(kotlin("test-annotations-common"))
            }
        }

        val androidTest by getting {
            dependencies {
                implementation(kotlin("test-junit"))
                implementation("junit:junit:4.13.2")
            }
        }

        val androidAndroidTest by getting {
            dependencies {
                implementation("androidx.test.ext:junit:1.1.3")
                implementation("androidx.test.ext:junit-ktx:1.1.3")
                implementation("androidx.test.espresso:espresso-core:3.4.0")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

After syncing the project, you should be good to go. Let's start writing some tests. To make things quick here, we will use the default Greeting class from the project setup.

class Greeting {
    fun greeting(): String {
        return "Hello, ${Platform().platform}!"
    }
}
Enter fullscreen mode Exit fullscreen mode

For the Shared tests, we can check if the Hello is mentioned, as it is the same for both parts:

class CommonGreetingTest {
    @Test
    fun testExample() {
        assertTrue(
            Greeting().greeting().contains("Hello"),
            "Check 'Hello' is mentioned"
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

For the Android tests, we can check if the Android name is mentioned in the text:

class AndroidGreetingTest {
    @Test
    fun testExample() {
        assertTrue(
            Greeting().greeting().contains("Android"),
            "Check Android is mentioned"
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

iOS is the same as Android. We check that the platform name was mentioned:


class IosGreetingTest {
    @Test
    fun testExample() {
        assertTrue(
            Greeting().greeting().contains("iOS"),
            "Check iOS is mentioned"
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Now that all tests are implemented, we can run them. You can run using the IDE button on the side of each class name or using gradlew from the command line. The command is ./gradlew :shared:allTests. If the build succeeds, all tests have passed. If it fails, there are some issues with them.

The tricky part comes next, the Instrumentation Tests. As I said before, if your subject needs access to the context (or any SDK class) on Android, you will have to implement it on the androidAndroidTest source set. If you implement them on the androidTest source set, you will receive a Runtime Exception.

As we don't have any android instrumentation tests for this project, I implement a test to ensure that the context is available and that the SDK classes work correctly.

@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
    @Test
    fun useAppContext() {
        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
        assertEquals(
            "dev.valvassori.kmp.test.test",
            appContext.packageName
        )
    }

    @Test
    fun parseUri() {
        val uri = Uri.parse("https://google.com")
        assertEquals("https", uri.scheme)
        assertEquals("google.com", uri.host)
    }
}
Enter fullscreen mode Exit fullscreen mode

The command is the same as for the Android app: ./gradlew :shared:connectedCheck and will require the android emulator to run instrumented tests.

Final Thoughts

KMM projects have a complete and robust test suite. You can have Unit and Integration tests for both the shared module and the platform code. There are some tricky parts, like the naming in the common code, but now that you know them, it will be easy. Last, If you want to configure a CI environment for your project, all required build and test commands are mentioned here in this article.

If you want to see the whole project, it's available in this Github project. Check it out if you need to review something.


Thanks for reading! Let me know in the comments if you have questions. Also, you can reach out to me at @faogustavo on Twitter, the Kotlin Slack, or AndroidDevBr Slack. And if you find all this interesting, maybe you'd like to work with or work at Touchlab.

Discussion (0)