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…