REDI-95: ViewModel unit tests (JUnit5 + Turbine + AssertK + fakes)

Test CharacterListViewModel and CharacterDetailViewModel entirely through their
MVI surface with a FakeCharacterRepository (a fake, not a mock) and a directly
constructed SavedStateHandle, on StandardTestDispatcher. Coverage: happy path,
error -> UiText + snackbar Event, pagination end-reached, the in-flight and
duplicate next-page guards, process-death restore, and both branches of OnRetry.
Also a domain test for GetCharactersPageUseCase (delegation + error
propagation).
This commit is contained in:
2026-06-10 15:00:45 +02:00
parent 7a7ab45a66
commit 3f9cf96216
4 changed files with 483 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
package com.example.architecture.feature.characters.domain.usecase
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.domain.model.Character
import com.example.architecture.feature.characters.domain.model.CharacterDetails
import com.example.architecture.feature.characters.domain.model.CharacterStatus
import com.example.architecture.feature.characters.domain.model.CharactersPage
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
/**
* Tests for the (thin pass-through) [GetCharactersPageUseCase]: it must forward the requested page to
* the repository and return its result verbatim — success and error alike. Pure JVM test on the
* JUnit 5 platform (see DomainModuleConventionPlugin); collaborator is a hand-written fake.
*/
class GetCharactersPageUseCaseTest {
@Test
fun `returns the repository page on success`() = runBlocking {
val page = CharactersPage(characters = listOf(domainCharacter(1)), nextPage = 2)
val useCase = GetCharactersPageUseCase(FakeCharacterRepository(pageResult = Result.Success(page)))
val result = useCase(page = 1)
assertThat(result).isEqualTo(Result.Success(page))
}
@Test
fun `propagates the repository error`() = runBlocking {
val useCase = GetCharactersPageUseCase(
FakeCharacterRepository(pageResult = Result.Error(DataError.Network.SERVER_ERROR)),
)
val result = useCase(page = 1)
assertThat(result).isInstanceOf(Result.Error::class)
assertThat((result as Result.Error).error).isEqualTo(DataError.Network.SERVER_ERROR)
}
@Test
fun `forwards the requested page number`() = runBlocking {
val fake = FakeCharacterRepository(
pageResult = Result.Success(CharactersPage(characters = emptyList(), nextPage = null)),
)
val useCase = GetCharactersPageUseCase(fake)
useCase(page = 7)
assertThat(fake.lastRequestedPage).isEqualTo(7)
}
private class FakeCharacterRepository(
private val pageResult: Result<CharactersPage, DataError>,
) : CharacterRepository {
var lastRequestedPage: Int? = null
private set
override suspend fun getCharacters(page: Int): Result<CharactersPage, DataError> {
lastRequestedPage = page
return pageResult
}
override suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError> =
Result.Error(DataError.Network.NOT_FOUND)
}
private fun domainCharacter(id: Int) = Character(
id = id,
name = "Character $id",
status = CharacterStatus.ALIVE,
species = "Human",
imageUrl = "https://example.com/$id.png",
)
}

View File

@@ -0,0 +1,115 @@
package com.example.architecture.feature.characters.presentation
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isSameInstanceAs
import com.example.architecture.core.domain.DataError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
/**
* Unit tests for [CharacterDetailViewModel]. The character id arrives via [SavedStateHandle] (written
* by type-safe navigation), which is constructed directly here — proving the VM needs no navigation
* dependency. Collaborator is a [FakeCharacterRepository]; assertions use AssertK; the back event is
* observed with Turbine.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CharacterDetailViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val repository = FakeCharacterRepository()
@BeforeEach
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
private fun viewModel(characterId: Int = 1) =
CharacterDetailViewModel(SavedStateHandle(mapOf("characterId" to characterId)), repository)
@Test
fun `loads details on init`() = runTest(dispatcher.scheduler) {
repository.setDetails(characterDetails(1))
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
val state = viewModel.state.value
assertThat(state.isLoading).isFalse()
assertThat(state.error).isNull()
assertThat(state.details).isNotNull()
assertThat(state.details?.name).isEqualTo("Character 1")
}
@Test
fun `load failure surfaces an error and no details`() = runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.SERVER_ERROR
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
val state = viewModel.state.value
assertThat(state.error).isNotNull()
assertThat(state.details).isNull()
assertThat(state.isLoading).isFalse()
}
@Test
fun `retry after a failure clears the error and loads details`() = runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.NO_INTERNET
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
assertThat(viewModel.state.value.error).isNotNull()
// The next attempt will succeed.
repository.failWith = null
repository.setDetails(characterDetails(1))
viewModel.onAction(CharacterDetailAction.OnRetry)
advanceUntilIdle()
val state = viewModel.state.value
assertThat(state.error).isNull()
assertThat(state.details).isNotNull()
}
@Test
fun `back click emits NavigateBack`() = runTest(dispatcher.scheduler) {
repository.setDetails(characterDetails(1))
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
viewModel.events.test {
viewModel.onAction(CharacterDetailAction.OnBackClick)
advanceUntilIdle()
assertThat(awaitItem()).isSameInstanceAs(CharacterDetailEvent.NavigateBack)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `missing character id fails fast`() {
// The route contract: type-safe nav must have written characterId into SavedStateHandle.
assertThrows<IllegalStateException> {
CharacterDetailViewModel(SavedStateHandle(), repository)
}
}
}

View File

@@ -0,0 +1,215 @@
package com.example.architecture.feature.characters.presentation
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.prop
import com.example.architecture.core.domain.DataError
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Unit tests for [CharacterListViewModel] — driven entirely through its public MVI surface
* (State/Action/Event), so they prove the VM correct regardless of which renderer hosts it.
*
* Uses [StandardTestDispatcher] (not Unconfined) so launched work is queued until `advanceUntilIdle`,
* which lets the duplicate-paging test observe the *synchronous* loading-flag guard before any
* coroutine runs. Collaborator is a [FakeCharacterRepository] (a fake, not a mock); `state`/`events`
* are observed with Turbine; assertions use AssertK.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CharacterListViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val repository = FakeCharacterRepository()
@BeforeEach
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
private fun viewModel(savedStateHandle: SavedStateHandle = SavedStateHandle()) =
CharacterListViewModel(GetCharactersPageUseCase(repository), savedStateHandle)
@Test
fun `loads the first page on init`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1), character(2)), nextPage = 2)
val viewModel = viewModel()
viewModel.state.test {
// restore() flips isLoading synchronously during construction, before the coroutine runs.
assertThat(awaitItem()).isEqualTo(CharacterListState(isLoading = true))
advanceUntilIdle()
val loaded = awaitItem()
assertThat(loaded).prop(CharacterListState::characters).hasSize(2)
assertThat(loaded).prop(CharacterListState::isLoading).isFalse()
assertThat(loaded).prop(CharacterListState::currentPage).isEqualTo(1)
assertThat(loaded).prop(CharacterListState::endReached).isFalse()
assertThat(loaded).prop(CharacterListState::error).isNull()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `initial load failure emits a snackbar event and a full-screen error`() =
runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.NO_INTERNET
val viewModel = viewModel()
viewModel.events.test {
advanceUntilIdle()
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
cancelAndIgnoreRemainingEvents()
}
advanceUntilIdle()
assertThat(viewModel.state.value).prop(CharacterListState::error).isNotNull()
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(0)
}
@Test
fun `does not load past the last page`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = null) // last page
val viewModel = viewModel()
advanceUntilIdle() // init → page 1
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle() // → page 2, end reached
assertThat(viewModel.state.value).prop(CharacterListState::endReached).isTrue()
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
val callsBefore = repository.getCharactersCallCount
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle() // guarded by endReached → no request
assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore)
}
@Test
fun `rapid duplicate next-page actions load the page only once`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3)
val viewModel = viewModel()
advanceUntilIdle() // init → page 1
val callsBefore = repository.getCharactersCallCount
// Both fire before any launched coroutine runs; the second sees the synchronously-set
// isLoadingNextPage flag and is guarded out.
viewModel.onAction(CharacterListAction.OnLoadNextPage)
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore + 1)
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
}
@Test
fun `ignores a next-page request while the initial load is in flight`() =
runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
val viewModel = viewModel()
// restore() set isLoading = true synchronously; its coroutine hasn't run yet, so this
// OnLoadNextPage hits the `isLoading` guard in loadNextPage() and is dropped.
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
// Only the single initial load ran — the guarded next-page request never fired.
assertThat(repository.getCharactersCallCount).isEqualTo(1)
}
@Test
fun `retry after a failed initial load rebuilds the list`() = runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.NO_INTERNET
val viewModel = viewModel()
viewModel.events.test {
advanceUntilIdle()
// The initial-load failure surfaces as a snackbar; consuming it is also how the
// rendezvous-Channel send in restore() completes so state can settle.
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
assertThat(viewModel.state.value).prop(CharacterListState::characters).isEmpty()
// Empty branch of retry(): the repository recovers, OnRetry rebuilds from page 1.
repository.failWith = null
repository.setPage(page = 1, characters = listOf(character(1), character(2)), nextPage = 2)
viewModel.onAction(CharacterListAction.OnRetry)
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
}
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::error).isNull()
}
@Test
fun `retry after a failed next page re-requests that page`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
val viewModel = viewModel()
advanceUntilIdle() // page 1 loaded (no event)
viewModel.events.test {
// Page 2 isn't configured yet → next-page load fails; list keeps page 1, shows an error.
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(1)
// Non-empty branch of retry(): with page 2 now available, OnRetry re-requests page 2 and
// appends it (currentPage stayed 1 because loadPage only advances on success).
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = null)
viewModel.onAction(CharacterListAction.OnRetry)
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
}
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
assertThat(viewModel.state.value).prop(CharacterListState::error).isNull()
}
@Test
fun `restores up to the saved page after process death`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3)
// Navigation/SavedStateHandle persisted the last loaded page across process death.
val savedStateHandle = SavedStateHandle(mapOf("currentPage" to 2))
val viewModel = viewModel(savedStateHandle)
advanceUntilIdle()
// Both pages are rebuilt (1 then 2), and currentPage is restored.
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
}
}

View File

@@ -0,0 +1,74 @@
package com.example.architecture.feature.characters.presentation
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.domain.model.Character
import com.example.architecture.feature.characters.domain.model.CharacterDetails
import com.example.architecture.feature.characters.domain.model.CharacterStatus
import com.example.architecture.feature.characters.domain.model.CharactersPage
/**
* In-memory [CharacterRepository] for ViewModel tests — a **fake**, not a mock: it has real behaviour
* (returns configured pages/details, counts calls, can be flipped to fail) so tests assert against a
* working collaborator instead of recording interactions. Configure pages via [setPage]/[setDetails];
* set [failWith] to make every call fail with a specific [DataError].
*/
class FakeCharacterRepository : CharacterRepository {
/** When non-null, every call fails with this error (overrides any configured data). */
var failWith: DataError? = null
var getCharactersCallCount = 0
private set
var getCharacterDetailsCallCount = 0
private set
private val pages = mutableMapOf<Int, CharactersPage>()
private val details = mutableMapOf<Int, CharacterDetails>()
fun setPage(page: Int, characters: List<Character>, nextPage: Int?) {
pages[page] = CharactersPage(characters = characters, nextPage = nextPage)
}
fun setDetails(value: CharacterDetails) {
details[value.id] = value
}
override suspend fun getCharacters(page: Int): Result<CharactersPage, DataError> {
getCharactersCallCount++
failWith?.let { return Result.Error(it) }
val pageData = pages[page] ?: return Result.Error(DataError.Network.NOT_FOUND)
return Result.Success(pageData)
}
override suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError> {
getCharacterDetailsCallCount++
failWith?.let { return Result.Error(it) }
val value = details[id] ?: return Result.Error(DataError.Network.NOT_FOUND)
return Result.Success(value)
}
}
/** Minimal list-item domain fixture. */
fun character(id: Int): Character = Character(
id = id,
name = "Character $id",
status = CharacterStatus.ALIVE,
species = "Human",
imageUrl = "https://example.com/$id.png",
)
/** Minimal detail domain fixture. */
fun characterDetails(id: Int): CharacterDetails = CharacterDetails(
id = id,
name = "Character $id",
status = CharacterStatus.ALIVE,
species = "Human",
type = "Genetic experiment",
gender = "Male",
origin = "Earth (C-137)",
location = "Citadel of Ricks",
imageUrl = "https://example.com/$id.png",
episodeCount = 10,
)