iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

JUnit / kotest

Kotlin testing covers three layers: JUnit / KoTest for unit tests, MockK for mocking, and Coroutine test utilities for async code. On Android, Robolectric + Compose UI tests handle the framework integration. The shape that scales is many fast unit tests, fewer integration tests, a thin layer of UI smoke tests.

JUnit 5 + MockK + Coroutines + Compose tests

EXAMPLE
// build.gradle.kts (test deps)
// dependencies {
//   testImplementation('org.junit.jupiter:junit-jupiter:5.10.0')
//   testImplementation('io.mockk:mockk:1.13.10')
//   testImplementation('org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0')
//   testImplementation('app.cash.turbine:turbine:1.0.0')
//
//   androidTestImplementation('androidx.compose.ui:ui-test-junit4:1.6.0')
// }
// tasks.test { useJUnitPlatform() }

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.Dispatchers
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import io.mockk.*
import app.cash.turbine.test

// ===== 1) Plain unit test =====
class CalcTest {
    @Test fun adds_two_numbers() {
        assertEquals(5, 2 + 3)
    }
}

// ===== 2) MockK — minimal mocking =====
interface OrderRepo {
    suspend fun byId(id: String): Order?
    suspend fun save(o: Order)
}
data class Order(val id: String, val status: String)

class OrderService(private val repo: OrderRepo) {
    suspend fun cancel(id: String) {
        val o = repo.byId(id) ?: throw NoSuchElementException(id)
        if (o.status == 'cancelled') return
        repo.save(o.copy(status = 'cancelled'))
    }
}

class OrderServiceTest {
    @Test fun cancel_is_idempotent() = runTest {
        val repo = mockk<OrderRepo>()
        coEvery { repo.byId('o1') } returns Order('o1', 'cancelled')

        OrderService(repo).cancel('o1')

        coVerify(exactly = 0) { repo.save(any()) }    // already cancelled, no save
        confirmVerified(repo)
    }

    @Test fun cancel_sets_status() = runTest {
        val repo = mockk<OrderRepo>(relaxUnitFun = true)
        coEvery { repo.byId('o1') } returns Order('o1', 'paid')

        OrderService(repo).cancel('o1')

        coVerify { repo.save(Order('o1', 'cancelled')) }
    }
}

// ===== 3) Coroutines tests — control time with TestDispatcher =====
class TimeTravelTest {
    @Test fun ticks_three_times() = runTest {
        val ticks = flow {
            for (i in 1..3) {
                kotlinx.coroutines.delay(1000)
                emit(i)
            }
        }
        ticks.test {
            assertEquals(1, awaitItem())
            assertEquals(2, awaitItem())
            assertEquals(3, awaitItem())
            awaitComplete()
        }
        // runTest advances virtual time; the delays do not actually wait.
    }
}

// ===== 4) ViewModel test (StateFlow via Turbine) =====
class CounterViewModel : androidx.lifecycle.ViewModel() {
    private val _state = kotlinx.coroutines.flow.MutableStateFlow(0)
    val state: kotlinx.coroutines.flow.StateFlow<Int> = _state
    fun increment() { _state.value++ }
}

class CounterViewModelTest {
    @Test fun starts_at_zero_and_increments() = runTest {
        val vm = CounterViewModel()
        vm.state.test {
            assertEquals(0, awaitItem())
            vm.increment()
            assertEquals(1, awaitItem())
        }
    }
}

// ===== 5) Compose UI test =====
// import androidx.compose.ui.test.*
// import androidx.compose.ui.test.junit4.createComposeRule
//
// class CounterScreenTest {
//   @get:Rule val rule = createComposeRule()
//
//   @Test fun increments_on_press() {
//     val vm = CounterViewModel()
//     rule.setContent { CounterScreen(vm) }
//
//     rule.onNodeWithText('Count: 0').assertIsDisplayed()
//     rule.onNodeWithText('+').performClick()
//     rule.onNodeWithText('Count: 1').assertIsDisplayed()
//   }
// }

// ===== 6) Patterns to internalise =====
// - Many unit tests; few integration tests; tiny UI smoke set
// - Test ViewModels with runTest + Turbine, NOT Robolectric
// - Mock the boundaries (repos, services); not internal classes
// - confirmVerified(mock) catches forgotten verify() calls
// - Compose UI tests on a real device or emulator; not unit-test scope

// ===== 7) Pitfalls =====
// - kotlinx-coroutines-test + Dispatchers.IO from production code is a hang;
//   pass dispatchers as constructor args (di) so tests can inject Test ones
// - Heavy Robolectric tests for plain Kotlin code; use plain JUnit instead
// - Forgetting to reset MockK between tests (use @BeforeEach { clearAllMocks() })
// - One huge test class; split by behaviour, not by class under test

Why it matters

Inject dispatchers (or any IO boundary) via constructor instead of grabbing `Dispatchers.IO` from production code directly. With dispatchers as parameters, your tests pass `StandardTestDispatcher` and finish in milliseconds; without it, every async test risks hanging on real IO.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class CalcTest {
    @Test fun addsNumbers() {
        assertEquals(5, add(2, 3))
    }
}
Try it Yourself »

Discussion

Loading…