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:
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user