Chapters

Hide chapters

Kotlin Multiplatform by Tutorials

First Edition · Android 12, iOS 15, Desktop · Kotlin 1.6.10 · Android Studio Bumblebee

8. Testing
Written by Saeed Taheri

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

Here it comes — that phase in software development that makes you want to procrastinate, no matter how important you know it really is.

Whether you like it or not, having a good set of tests — both automated and manual — ensures the quality of your software. When using Kotlin Multiplatform, you’ll have enough tools at your hand to write tests. So if you’re thinking of letting it slide this time, you’ll have to come up with another excuse. :]

Setting up the dependencies

Testing your code in the KMP world follows the same pattern you’re now familiar with. You test the code in the common module. You may also need to use the expect/actual mechanism as well. With this in mind, setting up the dependencies is structurally the same as it is with non-test code.

From the starter project, open the build.gradle.kts inside the shared module. In the sourceSets block, add a block for commonTest source set after val commonMain by getting:

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

You’re adding two modules from the kotlin.test library. This library provides annotations to mark test functions and a set of utility functions needed for assertions in tests — independent of the test framework you’re using. The -common in the name shows that you can use these inside your common multiplatform code. Do a Gradle sync.

As you declared above, your test codes will be inside the commonTest folder. Create it as a sibling directory to commonMain by right-clicking the src folder inside the shared module and choosing New ▸ Directory. Once you start typing commonTest, Android Studio will provide you with autocompletion. Choose commonTest/kotlin.

Fig. 8.1 - Create a new directory in Android Studio
Fig. 8.1 - Create a new directory in Android Studio

Fig. 8.2 - Android Studio suggests naming the test directory
Fig. 8.2 - Android Studio suggests naming the test directory

Note: Although not necessary, it’s a good practice to have your test files in the same package structure as your main code. If you want to do that, type commonTest/kotlin/com/raywenderlich/organize/presentation in the previous step, or create the nested directories manually afterward.

Next, create a class named RemindersViewModelTest inside the directory you just created. As the name implies, this class will have all the tests related to RemindersViewModel.

Now it’s time to create the very first test function for the app. Add this inside the newly created class:

@Test
fun testCreatingReminder() {
}

You’ll implement the function body later. The point to notice is the @Test annotation. It comes from the kotlin.test library you previously added as a dependency. Make sure to import the needed package at the top of the file if Android Studio didn’t do it automatically for you: import kotlin.test.Test.

As soon as you add a function with @Test annotation to the class, Android Studio shows run buttons in the code gutter to make it easier for you to run the tests.

Fig. 8.3 - Run button for tests in code gutter
Fig. 8.3 - Run button for tests in code gutter

You can run the tests by clicking on those buttons, using commands in the terminal, or by pressing the keyboard shortcut Control-Shift-R on Mac or Control-Shift-F10 on Windows and Linux.

Fig. 8.4 - Choosing test platform
Fig. 8.4 - Choosing test platform

Choose android (:testDebugUnitTest) to run the test in Debug mode on Android.

Congratulations! You ran your first test successfully…or did you?

Fig. 8.5 - First test failed
Fig. 8.5 - First test failed

If you read the logs carefully, you’ll notice that the compiler was unable to resolve the references to Test. Here’s why this happened:

As mentioned earlier, the kotlin.test library only provides the test annotations independently of the test library you’re using. When you ask the system to run the test on Android, it needs to find a test library on that platform to run your tests on. Since you hadn’t defined any test libraries for JVM targets, it couldn’t resolve the annotations, and the test failed. As a result, the next step would be to add test libraries to the app targets.

Once again, open build.gradle.kts in the shared module. Inside sourceSets block, make sure to add these items:

//1
val iosX64Test by getting
val iosArm64Test by getting
val iosSimulatorArm64Test by getting
val iosTest by creating {
  dependsOn(commonTest)
  iosX64Test.dependsOn(this)
  iosArm64Test.dependsOn(this)
  iosSimulatorArm64Test.dependsOn(this)
}

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

//3
val desktopTest by getting {
  dependencies {
    implementation(kotlin("test-junit"))
    implementation("junit:junit:4.13.2")
  }
}
  1. You create a source set for the iOS platform named iosTest by combining the platform’s various architectures. iOS doesn’t need any specific dependencies for testing. The needed libraries are already there in the system.
  2. For Android, you add a source set with dependencies to junit. This will make sure there’s a concrete implementation for provided annotations by kotlin.test library.
  3. Since desktop uses JVM like Android does, you add the same set of dependencies as Android.

Do a Gradle sync to download the dependencies. Now run the test again for Android. It’ll pass, and the system won’t throw any errors.

Writing tests for RemindersViewModel

With the dependencies for unit testing all in place, it’s time to create some useful test functions.

private lateinit var viewModel: RemindersViewModel
@BeforeTest
fun setup() {
  viewModel = RemindersViewModel()
}
@Test
fun testCreatingReminder() {
  //1
  val title = "New Title"

  //2
  viewModel.createReminder(title)

  //3
  val count = viewModel.reminders.count {
    it.title == title
  }

  //4
  assertTrue(
    actual = count == 1,
    message = "Reminder with title: $title wasn't created.",
  )
}
internal val reminders: List<Reminder>
  get() = repository.reminders
Fig. 8.6 - Choosing allTests from Gradle pane
Zad. 5.8 - Njuivumd ivbZavch zpot Bpeslo fuva

Fig. 8.7 - Successful test for creating a reminder
Dol. 1.2 - Sakdumtret magr tur pyauwedx e kopuppak

Writing tests for Platform

All implementation details of the RemindersViewModel class was inside the commonMain source set. However, the Platform class is a bit different. As you remember, Platform uses the expect/actual mechanism. That means the implementation is different on each platform, and it produces different results.

expect class PlatformTest {
  @Test
  fun testOperatingSystemName()
}

Android

Create PlatformTest.kt inside the directories you created earlier in androidTest and update as follows:

actual class PlatformTest {
  private val platform = Platform()

  @Test
  actual fun testOperatingSystemName() {
    assertEquals(
      expected = "Android",
      actual = platform.osName,
      message = "The OS name should be Android."
    )
  }
}

iOS

actual class PlatformTest {
  private val platform = Platform()

  @Test
  actual fun testOperatingSystemName() {
    assertTrue(
      actual = platform.osName.equals("iOS", ignoreCase = true)
        || platform.osName == "iPadOS",
      message = "The OS name should either be iOS or iPadOS."
    )
  }
}

You check if the OS name is either iOS or iPadOS.

Desktop

actual class PlatformTest {
  private val platform = Platform()

  @Test
  actual fun testOperatingSystemName() {
    assertTrue(
      actual = platform.osName.contains("Mac", ignoreCase = true)
        || platform.osName.contains("Windows", ignoreCase = true)
        || platform.osName.contains("Linux", ignoreCase = true)
        || platform.osName == "Desktop",
      message = "Non-supported operating system"
    )
  }
}

This is a bit difficult to test properly. For now, you can check if the reported OS name contains the app’s supported platforms. If not, let the test fail.

UI tests

Until now, the approach you’ve followed in this book is to share the business logic in the shared module using Kotlin Multiplatform and create the UI in each platform using the available native toolkit. Consequently, you’ve been able to share the tests for the business logic inside the shared module as well.

Android

You created the UI for Organize entirely using Jetpack Compose. Testing Compose layouts are different from testing a View-based UI. The View-based UI toolkit defines what properties a View has, such as the rectangle it’s occupying, its properties and so forth. In Compose, some composables may emit UI into the hierarchy. Hence, you need a new matching mechanism for UI elements.

androidTestImplementation(
  "androidx.compose.ui:ui-test-junit4:${rootProject.extra["composeVersion"]}"
)
debugImplementation(
  "androidx.compose.ui:ui-test-manifest:${rootProject.extra["composeVersion"]}"
)
androidTestImplementation("androidx.fragment:fragment-testing:1.4.0")
androidTestImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test:runner:1.4.0")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
class AppUITest {
  @get:Rule
  val composeTestRule = createAndroidComposeRule<MainActivity>()
}

Semantics

Semantics give meaning to a piece of UI — whether it’s a simple button or a whole set of composables. The semantics framework is primarily there for accessibility purposes. However, tests can take advantage of the information exposed by semantics about the UI hierarchy.

IconButton(
  onClick = onAboutButtonClick,
  modifier = Modifier.semantics { contentDescription = "aboutButton" },
) {
    Icon(
      imageVector = Icons.Outlined.Info,
      contentDescription = "About Device Button",
    )
}
@Test
fun testAboutButtonExistence() {
  composeTestRule
    .onNodeWithContentDescription("aboutButton")
    .assertIsDisplayed()
}
Fig. 8.8 - Successful test for about button existence
Hac. 4.5 - Vugtofkver noyr rik oseon rurdok etuwhuvci

@Test
fun testOpeningAndClosingAboutPage() {
  //1
  composeTestRule
    .onNodeWithContentDescription("aboutButton")
    .performClick()

  //2
  composeTestRule
    .onNodeWithText("About Device")
    .assertIsDisplayed()

  //3
  composeTestRule
    .onNodeWithContentDescription("Up Button")
    .performClick()

  //4
  composeTestRule
    .onNodeWithText("Reminders")
    .assertIsDisplayed()
}

Desktop

As the UI code for Android and desktop are essentially the same, the tests will be very similar. The setup is a bit different, though. The code is already there for you in the starter project. These are the differences you should consider:

named("jvmTest") {
  dependencies {
    @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
    implementation(compose.uiTestJUnit4)
    implementation(compose.desktop.currentOs)
  }
}
@Before
fun setUp() {
  composeTestRule.setContent {
    var screenState by remember { mutableStateOf(Screen.Reminders) }

    when (screenState) {
      Screen.Reminders ->
        RemindersView(
          onAboutButtonClick = { screenState = Screen.AboutDevice }
        )
      Screen.AboutDevice -> AboutView()
    }
  }
}
@Test
fun testOpeningAboutPage() {
  //1
  composeTestRule
    .onNodeWithText("Reminders")
    .assertExists()

  //2
  composeTestRule
    .onNodeWithContentDescription("aboutButton")
    .performClick()

  //3
  composeTestRule.waitForIdle()

  //4
  composeTestRule
    .onNodeWithContentDescription("aboutView")
    .assertExists()
}

iOS

To make the UI code testable in Xcode, you need to add a UI Test target to your project. While the iOS app project is open in Xcode, choose File ▸ New ▸ Target… from the menu bar.

Fig. 8.9 - Xcode New Target Template
Zid. 0.9 - Mgefa Got Huvliz Cacdqava

Fig. 8.10 - Xcode UI Test Target files
Saq. 0.88 - Lfuzo AI Qafj Kobfab fugab

private let app = XCUIApplication()
override func setUp() {
  continueAfterFailure = false
  app.launch()
}
func testAboutButtonExistence() {
  XCTAssert(app.buttons["About"].exists)
}
.accessibilityIdentifier("aboutButton")
Button {
  shouldOpenAbout = true
} label: {
  Label("About", systemImage: "info.circle")
    .labelStyle(.titleAndIcon)
}
.accessibilityIdentifier("aboutButton")
.padding(8)
.popover(isPresented: $shouldOpenAbout) {
  AboutView()
    .frame(
      idealWidth: 350,
      idealHeight: 450
    )
}
XCTAssert(app.buttons["aboutButton"].exists)

Recording UI tests

Xcode has a cool feature that you can take advantage of to make the process of creating UI tests easier.

func testOpeningAndClosingAboutPage() {
  // Put the cursor here
}
Fig. 8.11 - Xcode UI Test Record button
Pad. 0.94 - Ckosi EE Pujs Nekapw vepyas

func testOpeningAndClosingAboutPage() {
  let app = XCUIApplication()
  app.toolbars["Toolbar"].buttons["aboutButton"].tap()
  app.navigationBars["About Device"].buttons["Done"].tap()
}
func testOpeningAndClosingAboutPage() {
  //1
  app.buttons["aboutButton"].tap()

  //2
  let aboutPageTitle = app.staticTexts["About Device"]
  XCTAssertTrue(aboutPageTitle.exists)

  //3
  app.navigationBars["About Device"].buttons["Done"].tap()

  //4
  let remindersPageTitle = app.staticTexts["Reminders"]
  XCTAssertTrue(remindersPageTitle.exists)
}
Fig. 8.12 - Xcode UI Test Success - Console
Hin. 3.79 - Gxuqi EU Zevg Cofvirm - Tevrita

Fig. 8.13 - Xcode UI Test Success - Gutter
Yup. 3.29 - Dlino IU Nuyg Hirqevp - Qopkog

Challenge

Here is a challenge for you to see if you’ve got the idea. The solution is inside the materials for this chapter.

Challenge: Writing tests for RemindersRepository

Going one level deeper into the app’s architectural monument, it’s essential to have a bulletproof repository. After all, repositories are the backbone of the viewModels in Organize. Although it may seem effortless and similar to the viewModels for the time being, you’ll see how these tests will play a vital role when you connect a database to the repository as you move forward.

Key points

  • KMP will help you write less test code in the same way that it helped you write less business logic code.
  • You can write tests for your common code as well as for platform-specific code — all in Kotlin.
  • Declaring dependencies to a testing library for each platform is necessary. KMP will run your tests via the provided environment — such as JUnit on JVM.
  • Using expect/actual mechanisms in test codes is possible.
  • For UI tests, you consult each platform’s provided solution: Jetpack Compose Tests for UIs created with Jetpack Compose and XCUITest for UIs created with UIKit or SwiftUI.

Where to go from here?

This chapter barely scratched the surface of testing. It didn’t talk about mocks and stubs, and it tried not to use third-party libraries, for that matter. There are a few libraries worth mentioning, though:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now