| name | android-unit-test-editor |
| description | Writes unit tests for Android Kotlin code covering ViewModels, Use Cases, and Repositories using MockK and Kotlin Coroutines Test. Use when user asks to "write a unit test", "add tests for this class", "how do I test a ViewModel", or "apply GIVEN/WHEN/THEN". |
Android Unit Test Editor
Overview
Unit tests validate isolated business logic: Use Cases, ViewModels, Repositories, mappers, and extensions. This guide uses MockK for mocking and kotlinx.coroutines.test for coroutine testing.
Setup
[versions]
mockk = "1.13.12"
coroutines-test = "1.8.1"
turbine = "1.1.0"
[libraries]
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-test" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
junit = { module = "junit:junit", version = "4.13.2" }
dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
}
Naming Convention
SHOULD [expected behavior] WHEN [action/event] GIVEN [precondition]
Examples:
`SHOULD update state to loading WHEN load event received GIVEN initial state`
`SHOULD return error WHEN network call fails GIVEN no cached data`
`SHOULD emit navigation event WHEN submit clicked GIVEN valid form`
Test Structure
@Test
fun `SHOULD ... WHEN ... GIVEN ...`() = runTest {
val input = "test"
coEvery { mockDependency.fetch(input) } returns Result.success(expectedData)
sut.doAction(input)
advanceUntilIdle()
assertEquals(expectedData, sut.state.value.data)
}
Use Case Tests
class GetOrdersUseCaseTest {
private val repository: OrderRepository = mockk()
private lateinit var sut: GetOrdersUseCase
@Before
fun setup() {
sut = GetOrdersUseCase(
repository = repository,
ioDispatcher = UnconfinedTestDispatcher(),
)
}
@Test
fun `SHOULD return success WHEN repository returns data`() = runTest {
val expected = listOf(Order("1", "Pizza"))
coEvery { repository.getOrders() } returns Result.success(expected)
val result = sut()
assertEquals(expected, result.getOrNull())
}
@Test
fun `SHOULD return failure WHEN repository throws`() = runTest {
coEvery { repository.getOrders() } returns Result.failure(IOException("Net error"))
val result = sut()
assertTrue(result.isFailure)
}
}
ViewModel Tests
@OptIn(ExperimentalCoroutinesApi::class)
class OrderViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val getOrdersUseCase: GetOrdersUseCase = mockk()
private lateinit var sut: OrderViewModel
@Before
fun setup() {
sut = OrderViewModel(getOrdersUseCase)
}
@Test
fun `SHOULD show orders WHEN load succeeds`() = runTest {
val orders = listOf(Order("1", "Pizza"))
coEvery { getOrdersUseCase() } returns Result.success(orders)
sut.onViewEvent(OrderViewEvent.OnScreenOpened)
advanceUntilIdle()
with(sut.state.value) {
assertEquals(orders, this.orders)
assertFalse(isLoading)
assertNull(errorMessage)
}
}
@Test
fun `SHOULD set error message WHEN load fails`() = runTest {
coEvery { getOrdersUseCase() } returns Result.failure(RuntimeException("Fail"))
sut.onViewEvent(OrderViewEvent.OnScreenOpened)
advanceUntilIdle()
assertNotNull(sut.state.value.errorMessage)
assertFalse(sut.state.value.isLoading)
}
}
MainDispatcherRule
Required for viewModelScope.launch to work in unit tests:
class MainDispatcherRule(
private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
dispatcher.cleanupTestCoroutines()
}
}
Or with StandardTestDispatcher (preferred in coroutines-test 1.6+):
class MainDispatcherRule(
val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}
Flow Testing with Turbine
Turbine provides a clean API for testing Flows:
@Test
fun `SHOULD emit navigation effect WHEN order clicked`() = runTest {
val orderId = "order-123"
coEvery { getOrdersUseCase() } returns Result.success(listOf(Order(orderId, "Pizza")))
sut.sideEffects.test {
sut.onViewEvent(OrderViewEvent.OnOrderClicked(orderId))
val effect = awaitItem()
assertTrue(effect is OrderSideEffect.Navigation.GoToOrderDetail)
assertEquals(orderId, (effect as OrderSideEffect.Navigation.GoToOrderDetail).orderId)
cancelAndIgnoreRemainingEvents()
}
}
Repository Tests
class OrderRepositoryImplTest {
private val localDataSource: OrderLocalDataSource = mockk()
private val remoteDataSource: OrderRemoteDataSource = mockk()
private lateinit var sut: OrderRepository
@Before
fun setup() {
sut = OrderRepositoryImpl(
localDataSource = localDataSource,
remoteDataSource = remoteDataSource,
ioDispatcher = UnconfinedTestDispatcher(),
)
}
@Test
fun `SHOULD return local data WHEN cache is valid`() = runTest {
val cached = listOf(Order("1", "Sushi"))
coEvery { localDataSource.getOrders() } returns cached
val result = sut.getOrders()
assertEquals(cached, result.getOrNull())
coVerify(exactly = 0) { remoteDataSource.getOrders() }
}
@Test
fun `SHOULD fetch remote and save WHEN cache is empty`() = runTest {
coEvery { localDataSource.getOrders() } returns emptyList()
val remote = listOf(Order("2", "Ramen"))
coEvery { remoteDataSource.getOrders() } returns Result.success(remote)
coEvery { localDataSource.saveOrders(any()) } just Runs
val result = sut.getOrders()
assertEquals(remote, result.getOrNull())
coVerify { localDataSource.saveOrders(remote) }
}
}
MockK Quick Reference
val mockRepo: OrderRepository = mockk()
val spyService: MyService = spyk(MyService())
val relaxedMock: LogService = mockk(relaxed = true)
every { mockRepo.isInitialized } returns true
coEvery { mockRepo.getOrders() } returns Result.success(orders)
every { mockRepo.getOrders() } throws RuntimeException("fail")
coEvery { mockRepo.save(any()) } just Runs
val slot = slot<Order>()
coEvery { mockRepo.save(capture(slot)) } just Runs
verify { mockRepo.isInitialized }
coVerify { mockRepo.getOrders() }
coVerify(exactly = 0) { mockRepo.getOrders() }
coVerify(atLeast = 2) { mockRepo.getOrders() }
verifyOrder { mockRepo.getOrders(); mockRepo.save(any()) }
coEvery { mockRepo.find(any()) } returns order
coEvery { mockRepo.find(match { it.startsWith("order") }) } returns order
Parameterized Tests
@RunWith(Parameterized::class)
class EmailValidatorTest(private val email: String, private val isValid: Boolean) {
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0} → {1}")
fun data() = listOf(
arrayOf("user@example.com", true),
arrayOf("notanemail", false),
arrayOf("@missing.com", false),
arrayOf("", false),
)
}
@Test
fun `validate email`() {
assertEquals(isValid, EmailValidator.isValid(email))
}
}
Checklist: New Test Class
References