Starting a kmp project - The one with unit testing - Episode 5

Generated by Gemini

TL;DR for unit testing

If you’d like to dive straight into the code, check out my PR where I set up unit tests with TestBalloon fixtures and Koin test configuration.

The story

Welcome back! Since having my daughter (Lucy Ada ❤️), my priorities have changed a bit over the last year. But here I am again after a year later since the last article, trying to keep my promise to myself in this fast-changing tech environment.

Unit testing in a Kotlin Multiplatform project brings its own set of unique challenges. Not only do we have to deal with platform-specific APIs, but we also have to decide how we want to handle our test doubles. Should we use mocks? Fakes? Stubs? And how does that work when our code is shared across Android, iOS, and beyond?

Test Doubles

Choosing the test double kind does also depend on whether you are a classicist or a mockist. See ClassicalAndMockistTesting. In the KMP world, this choice is even more significant because of the tooling limitations on non-JVM platforms.

Usage of Mocks

Then we have Mocks.

“Mocks are pre-programmed with expectations which form a specification of the calls they are expected to receive.” by Martin Fowler

So if you mock too much, you may wind up with test suites that are slow, fragile, and complicated; and you may also damage the design of your application. Mock across architecturally significant boundaries, but not within those boundaries. — Uncle Bob

Nothing much to add to that. I’m a classicist when I’m testing within the boundaries of the application. I prefer testing the real objects and using Fakes for external dependencies. However, I become a mockist when it’s outside those boundaries—testing the interaction with third-party services or complex platform APIs where a Fake would be too much effort to maintain.

In KMP, mocking is tricky. Coming from an Android background, most of us are used to MockK, but MockK relies heavily on reflection, which doesn’t work on Kotlin/Native (iOS, web). This leads to a choice: do we use a KMP mocking library like MocKMP, KMock, Mockative, Mokkery which many of them had issues with KSP and some not maintained anymore, or do we stick to MockK only for our JVM/Android tests?

For this template project, I decided to stay with MockK for the androidHostTest while targeting the JVM/Android side primarily. (Most of the unit tests are decoupled from platform-specific logics, so ideally, it shouldn’t mostly matter on which platform they run.)

Usage of Fake & Stub

I generally prefer using Fakes and Stubs whenever possible. See here for the definitions.

One of the pain points in testing is creating the data objects. If your data classes have many fields, creating a new instance in every test can be cumbersome and time spent unwisely.

In KMP, Fakes are great for this kind of issues because they are just Kotlin classes. They don’t require any compiler magic or reflection, making them highly portable across all targets. Currently, the project uses a layered architecture (data, model, and UI). I provide fakes for the data and model layers by using separate modules suffixed with testfixtures for each.

Koin TestConfig with TestBalloon Fixtures

Since we are using Koin for Dependency Injection, we need a way to swap out real implementations for our Test Doubles during tests. Koin provides KoinTest, which is great for starting and stopping the Koin lifecycle between tests. TestBalloon provides fixtures, a way to provide SUT objects to each single test.

I’ve integrated TestBalloon with my Koin configuration so that my tests can easily inject faked dependencies. By using a specific TestModule, I can ensure that the code under test is provided with the right doubles without manually wiring everything.

// Example of a fixture
testFixture {
    object : KoinTest {
        val repository: TestMoviesRepository by inject()
        val viewModel = MovieDetailViewModel(1L, repository)
    }
} asContextForEach {
    test("initial state is Loading") {
        // Then
        assertEquals(
            expected = MovieDetailUiState.Loading,
            actual = viewModel.uiState.value,
        )
        assertEquals(
            expected = 1L,
            actual = repository.requestedMovieId,
        )
    }
}

Check out the GitHub PR to see more on TestBalloon fixtures with Koin test configuration.

References and articles;

And an update on the Checklist 🎉 ;

  • Core
    • ✅ Dependency management: Renovate
    • ✅ Lint
    • ✅ Static code analysis: Detekt
    • ✅ Build info
    • ✅ Logging
    • Analytics
    • Tracing
    • ✅ Error reporting
    • ✅ DI
    • ✅ Network
    • ✅ Build conventions
    • ✅ ADRs
    • Benchmarking
    • Flavors
    • ✅ Mocks
    • ✅ Test fixtures
    • Preferences
    • Storage
    • Feature flags (local & remote)
    • Deep linking
    • Push notifications
    • TimeProvider
    • Local Formatters
    • Coroutine Dispatchers & Test helper
    • ✅ Unit testing
    • Test coverage
    • Obfuscation & Shrinking
    • Pipelines
    • Releasing
    • Force updates
  • UI
    • ✅ Design system
    • Gallery App
    • Navigation
    • Baseline profiles
    • Compose compiler metrics
    • Previews
    • Network image loading
    • supportsDynamicTheming → Won’t be supported…
    • ✅ Status bar color changing
    • App settings with Resource Environment
      • ✅ l10n
      • i18n
    • Testing
      • UI Testing
      • Compose Screenshot testing

Hope to see you in the next episode.

Until then may the force be with you… 🖖