Testing Strategy
Convention Plugins
| Plugin | Use for | Provides |
|---|---|---|
android.test.unit |
ViewModel, UseCase, Repository | JUnit + MockK + Coroutines Test + Truth |
android.test.instrumentation |
Compose UI tests | AndroidJUnit4 + Compose Test |
android.test.robolectric |
Android unit tests | Robolectric |
Add to build.gradle.kts:
plugins {
alias(libs.plugins.android.feature.compose)
alias(libs.plugins.android.test.unit) // Add for tests
}
Test File Locations
feature/<name>/src/test/ -> ViewModel unit tests
domain/usecase/src/test/ -> UseCase unit tests
data/remote-data/src/test/ -> Repository unit tests
feature/<name>/src/androidTest/ -> Compose UI tests
1. ViewModel Unit Test
@OptIn(ExperimentalCoroutinesApi::class)
class SampleViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: SampleViewModel
private val getSampleUseCase: GetSampleUseCase = mockk()
@Before
fun setup() {
every { getSampleUseCase() } returns flowOf(PagingData.empty())
viewModel = SampleViewModel(getSampleUseCase)
}
@Test
fun `initial state should have paging flow set`() {
assertThat(viewModel.uiState.value.items).isNotNull()
}
@Test
fun `OnItemClick should emit NavigateToDetail effect`() = runTest {
val item = SampleModel(id = "123", name = "Test Item")
viewModel.onTriggerEvent(SampleViewEvent.OnItemClick(item))
assertThat(viewModel.effectState.value)
.isInstanceOf(SampleViewEffect.NavigateToDetail::class.java)
assertThat((viewModel.effectState.value as SampleViewEffect.NavigateToDetail).id)
.isEqualTo("123")
}
}
MainDispatcherRule
Located in the test source of the first feature module that uses it. Copy to each test module that needs it:
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
2. UseCase Unit Test
class GetDetailUseCaseTest {
private val repository: ItemRepository = mockk()
private lateinit var useCase: GetDetailUseCaseImpl
@Before
fun setup() {
useCase = GetDetailUseCaseImpl(repository)
}
@Test
fun `invoke should return item from repository`() = runTest {
val expectedItem = Item(id = "1", name = "Test")
coEvery { repository.getItemDetail("1") } returns flowOf(
DataState.Success(expectedItem)
)
useCase(GetDetailParam(id = "1")).collect { state ->
when (state) {
is DataState.Success -> assertThat(state.data).isEqualTo(expectedItem)
else -> fail("Expected Success")
}
}
}
@Test
fun `invoke should propagate repository errors`() = runTest {
coEvery { repository.getItemDetail("1") } returns flowOf(
DataState.Error(AppError.ServerError(500))
)
useCase(GetDetailParam(id = "1")).collect { state ->
assertThat(state).isInstanceOf(DataState.Error::class.java)
}
}
}
3. Repository Unit Test
class ItemRepositoryImplTest {
private val remoteDataSource: ItemRemoteDataSource = mockk()
private val itemMapper: ItemMapper = mockk()
private lateinit var repository: ItemRepositoryImpl
@Before
fun setup() {
repository = ItemRepositoryImpl(remoteDataSource, itemMapper)
}
@Test
fun `getItemDetail should map DTO to domain model`() = runTest {
val dto = ItemDto(id = "1", name = "Test")
val domain = Item(id = "1", name = "Test")
coEvery { remoteDataSource.getItemById("1") } returns flowOf(DataState.Success(dto))
every { itemMapper.toDomain(dto) } returns domain
repository.getItemDetail("1").collect { state ->
when (state) {
is DataState.Success -> assertThat(state.data).isEqualTo(domain)
else -> {}
}
}
}
}
4. Compose UI Test
@HiltAndroidTest
class SampleScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `SampleScreenInternal displays content`() {
val fakeViewModel = FakeBaseViewModel<SampleViewState, SampleViewEvent, SampleViewEffect>(
initialState = SampleViewState()
)
composeTestRule.setContent {
TemplateThemePreview { SampleScreenInternal(viewModel = fakeViewModel) }
}
composeTestRule.onNodeWithText("Sample").assertIsDisplayed()
}
@Test
fun `loading state shows loading indicator`() {
val fakeViewModel = FakeBaseViewModel<SampleViewState, SampleViewEvent, SampleViewEffect>(
initialState = SampleViewState(),
initialLoading = true
)
composeTestRule.setContent {
TemplateThemePreview { SampleScreenInternal(viewModel = fakeViewModel) }
}
composeTestRule.onNode(hasTestTag("loading_indicator")).assertIsDisplayed()
}
}
Test Naming Convention
Pattern: `[method/action] [condition] should [expected result]`
fun `onTriggerEvent OnItemClick should set NavigateToDetail effect`()
fun `collectDataState with server error should call onError`()
fun `initial state should have empty list and loading false`()
Test Priority
High (test first)
- ViewModels -
onTriggerEvent(), state transitions, effect emissions - UseCases with business logic
- ExceptionMapper - exception -> AppError mappings
- ErrorHandler - AppError -> string resource mappings
Medium
- Repository implementations - DTO -> Domain, flow logic
- BaseDataSource strategies - success/error flows
Lower
- Compose UI tests - screen rendering, user interactions
- Navigation tests