Kotlin Multiplatform: Writing Platform Specific Implementations with Contract Testing

In Kotlin Multiplatform, writing platform-specific implementations is sometimes necessary. However, these implementations can introduce inconsistencies between platforms if not properly tested and verified. In this article, we will explore how to tackle this problem.

Even though The Kotlin Multiplatform ecosystem is getting bigger every day, there are cases where we need to write platform specific implementations by hand. Either because it's too specific to our domain, or because of legal reasons.

In these cases, we will need to write multiple implementations ourselves. Besides the implementation, we will also need to verify that all the implementations work in the same way between platforms. Otherwise, the shared code might be unpredictable and produce different behavior between platforms.

In this article, I'll cover how to write platform specific implementations for retrieving the device language and formatting days of the week depending on this language. To verify that both implementations work correctly, I'll show how you can write one test to cover all the implementations, reducing boilerplate and increasing alignment between platforms.

The project repository is on GitHub, however due to iOS testing issues with Compose Multiplatform, Compose was commented out in order to fix the tests. This branch enables the UI, but breaks tests.

How to write platform specific code

There are two main ways of achieving this, I'll try to summarize each briefly.

Expect Actual

This is the official way of providing platform specific implementations, it is nice for top level helper function or data structures with common properties, but also some platform specific ones (required by the platform).

The downside of this approach is that these implementations are final, and in most cases this makes them hard to replace with a Test Double when testing.

Shared Interface with platform specific implementations

In this case, there is an interface which establishes a "contract" that the platforms need to follow through their implementation.

The platform specific implementations can be written in two ways:

  1. Using the Native programming language, (e.g. Swift implementation that is passed in from the iOS platform to the shared code)
  2. Using Kotlin, while targeting a specific platform: Kotlin/JVM, Kotlin/Native, Kotlin/JS.

The first option might be valid for complex implementations which might be hard to write using Kotlin, like Cryptography. However, this solution is harder to maintain, because it requires separate tests in Kotlin and in the Native language.

The second option requires using "native APIs" (like platform.Foundation) from Kotlin, which might be difficult. The upside is that it is easier to test and all the code resides in the shared codebase, making it easier to maintain and reason about.

Because the shared code depends on an interface, it makes it easier to replace with a Test Double when testing. Production apps use the real implementations, and the test code can use a Fake implementation. Because of this, the article will use the latter approach.

Providing the device language

These implementations are pretty straight forward, so I won't get into the details too much. In the common code, we have an interface and a data class:

data class Language(val value: String)

interface LocaleProvider {

    fun getLanguage(): Language
}

And both platform implementations are one-liners:

import java.util.Locale

class AndroidLocaleProvider : LocaleProvider {

    override fun getLanguage(): Language = 
        Language(Locale.getDefault().language)
}
import platform.Foundation.NSLocale
import platform.Foundation.currentLocale
import platform.Foundation.languageCode

class IosLocaleProvider : LocaleProvider {

    override fun getLanguage(): Language =
        Language(NSLocale.currentLocale.languageCode)
}

These simple implementations allow us to retrieve the device language based on the phone settings.

However, we still need a way of instantiating these implementations, and currently, this can't be done in the common code. We can, however, create an expect actual function which will create "an instance" of the interface:

expect fun createLocaleProvider(): LocaleProvider
actual fun createLocaleProvider(): LocaleProvider = 
    AndroidLocaleProvider()
actual fun createLocaleProvider(): LocaleProvider = 
    IosLocaleProvider()

These functions can be used for creating the production implementation and then introducing it to the dependency graph, or for testing the production implementations.

Formatting the day of the week

Now that we have a way of retrieving the device language, we can format the day based on that language.  The next implementations are a little bit more complex, but should be still easy to understand.  Just like previously, we start out with an interface:

interface DayOfWeekNameProvider {

    fun getLongName(dayNumber: Int): String?
}

expect fun createDayOfWeekNameProvider(
    localeProvider: LocaleProvider
): DayOfWeekNameProvider

The Android implementation uses the java.util.Calendar, which is pretty much what would be done in native Android apps:

class AndroidDayOfWeekNameProvider(
    private val localeProvider: LocaleProvider
) : DayOfWeekNameProvider {

    override fun getLongName(dayNumber: Int): String? {
        val calendar = Calendar.getInstance()
        calendar.set(Calendar.DAY_OF_WEEK, dayNumber)
        val locale = Locale(localeProvider.getLanguage().value)
        return calendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, locale)
    }
}

actual fun createDayOfWeekNameProvider(
    localeProvider: LocaleProvider
): DayOfWeekNameProvider =
    AndroidDayOfWeekNameProvider(createLocaleProvider())

The Kotlin / Native implementation is more out of the ordinary, because we need to connect the Kotlin world with the iOS world:

class IosDayOfWeekNameProvider(
    private val localeProvider: LocaleProvider
) : DayOfWeekNameProvider {

    override fun getLongName(dayNumber: Int): String? {
        val dateFormatter = NSDateFormatter().apply {
            this.locale = NSLocale(localeProvider.getLanguage().value)
        }
        val index = dayNumber % 7
        return dateFormatter.weekdaySymbols[index].toString()
    }
}

actual fun createDayOfWeekNameProvider(
    localeProvider: LocaleProvider
): DayOfWeekNameProvider =
    IosDayOfWeekNameProvider(localeProvider)

As you can see, both implementations are still pretty easy to understand and are fully written in Kotlin. The best part is that they work:

However, there is a problem with the current implementation, can you spot it...?

On start-up, the both apps show a different day of the week. For Android, day 1 is Sunday and for iOS it is Monday. If we released this app to production, the user experience would be different between platforms. Additionally, it could cause unexpected behavior if we were to save the days to a database or send them to an API.

Verifying platform implementations

Test class for each implementation

The first solution for verifying these two implementations could be writing a test for each implementation. This is ok, but it has some drawbacks:

  • More code means more maintenance. When changing something, the work is multiplied for each platform (tests + implementation).
  • It is easy to forget about the other platforms, without due diligence, the tests could be misaligned and pass even though the platform implementations are different

Keeping in mind, we want those two implementations to behave in the same way, it would be best to have one test that verifies all the platforms. This can be achieved by writing a Contract test.

Contract test for verifying all platforms

A Contract test is written once in commonTest, and depending on the target, it uses the corresponding implementation for that platform. A contract test for the DayOfWeekNameProvider can be as follows:

class DayOfWeekNameProviderContractTest {

    @Test
    fun `Day number 1 is Monday`() =
        longNameTestCase(dayNumber = 1, "Monday")

    @Test
    fun `Day number 2 Tuesday`() =
        longNameTestCase(dayNumber = 2, "Tuesday")

    @Test
    fun `Day number 3 Wednesday`() =
        longNameTestCase(dayNumber = 3, "Wednesday")

    @Test
    fun `Day number 4 Thursday`() =
        longNameTestCase(dayNumber = 4, "Thursday")

    @Test
    fun `Day number 5 Friday`() =
        longNameTestCase(dayNumber = 5, "Friday")

    @Test
    fun `Day number 6 Saturday`() =
        longNameTestCase(dayNumber = 6, "Saturday")

    @Test
    fun `Day number 7 Sunday`() =
        longNameTestCase(dayNumber = 7, "Sunday")

    private fun longNameTestCase(dayNumber: Int, expectedName: String) {
        val sut =
            createDayOfWeekNameProvider(FakeLocaleProvider("en"))
        
        val result = sut.getLongName(dayNumber)
        
        assertEquals(expectedName, result)
    }
}

The above test could be improved by also verifying a different language and testing out some boundary case values like -1, 0 or 8. The test also uses a testCase function, which is one way of doing parameterized tests on Kotlin Multiplatform.

The test results for the current implementation look like this:

The iOS tests pass, however the Android implementation is shifted by one day, so fixing it is just a matter of adding + 1 to the day number:

override fun getLongName(dayNumber: Int): String? {
    val calendar = Calendar.getInstance()
    calendar.set(Calendar.DAY_OF_WEEK, dayNumber + 1)
    val locale = Locale(localeProvider.getLanguage().value)
    return calendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, locale)
}

Summary

Hopefully by now, you have an idea of how platform implementations can be created. Writing everything in Kotlin might seem awkward at first, but there are many benefits for doing it, as I hopefully laid out in this article.

Because everything is in Kotlin, we can verify all implementations with one contract test that ensure that all the implementations are aligned and work in the same expected way.

Such contract testing can be used anywhere where we have multiple implementations, like Production and Fakes or Flavor specific implementations.

You've successfully subscribed to AKJAW
Great! Now you have full access to all members content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.