REDI-100: adopt MockK and rewrite unit tests to use it
Replace the hand-written CharacterRepository fakes in the ViewModel and UseCase unit tests with MockK mocks (coEvery / coVerify). This is a deliberate showcase of MockK and intentionally diverges from the repo's "prefer fakes over mocks" guidance. - Add io.mockk:mockk 1.14.3 to the version catalog and the unit-test bundle; add it explicitly to DomainModuleConventionPlugin (domain does not consume the bundle). - CharacterListViewModelTest: strict mockk, per-page coEvery stubs; the paging/in-flight guards are expressed via coVerify(exactly = ...) and coVerifyOrder instead of fake call counters. - CharacterDetailViewModelTest: relaxed mockk so "missing id" needs no stubbing; explicit coEvery elsewhere. - GetCharactersPageUseCaseTest: mockk + coVerify replaces the inline fake. - Move character()/characterDetails() fixtures to CharacterFixtures.kt and delete FakeCharacterRepository.kt. - NetworkCharacterRepositoryTest stays on Ktor MockEngine (MockK is for Kotlin collaborator interfaces, not the HTTP transport).
This commit is contained in:
@@ -20,6 +20,8 @@ class DomainModuleConventionPlugin : Plugin<Project> {
|
||||
add("implementation", libs.findLibrary("kotlinx-coroutines-core").get())
|
||||
add("testImplementation", libs.findLibrary("junit-jupiter-api").get())
|
||||
add("testImplementation", libs.findLibrary("assertk").get())
|
||||
// Domain doesn't consume the `unit-test` bundle, so MockK is added explicitly here.
|
||||
add("testImplementation", libs.findLibrary("mockk").get())
|
||||
add("testRuntimeOnly", libs.findLibrary("junit-jupiter-engine").get())
|
||||
// Gradle 9 dropped the bundled launcher; JUnit 5 won't start without it.
|
||||
add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
|
||||
|
||||
@@ -7,23 +7,29 @@ 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 io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
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.
|
||||
* JUnit 5 platform (see DomainModuleConventionPlugin); the [CharacterRepository] collaborator is a
|
||||
* MockK mock, stubbed with `coEvery` and verified with `coVerify`.
|
||||
*/
|
||||
class GetCharactersPageUseCaseTest {
|
||||
|
||||
private val repository = mockk<CharacterRepository>()
|
||||
private val useCase = GetCharactersPageUseCase(repository)
|
||||
|
||||
@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)))
|
||||
coEvery { repository.getCharacters(1) } returns Result.Success(page)
|
||||
|
||||
val result = useCase(page = 1)
|
||||
|
||||
@@ -32,9 +38,7 @@ class GetCharactersPageUseCaseTest {
|
||||
|
||||
@Test
|
||||
fun `propagates the repository error`() = runBlocking {
|
||||
val useCase = GetCharactersPageUseCase(
|
||||
FakeCharacterRepository(pageResult = Result.Error(DataError.Network.SERVER_ERROR)),
|
||||
)
|
||||
coEvery { repository.getCharacters(1) } returns Result.Error(DataError.Network.SERVER_ERROR)
|
||||
|
||||
val result = useCase(page = 1)
|
||||
|
||||
@@ -44,29 +48,12 @@ class GetCharactersPageUseCaseTest {
|
||||
|
||||
@Test
|
||||
fun `forwards the requested page number`() = runBlocking {
|
||||
val fake = FakeCharacterRepository(
|
||||
pageResult = Result.Success(CharactersPage(characters = emptyList(), nextPage = null)),
|
||||
)
|
||||
val useCase = GetCharactersPageUseCase(fake)
|
||||
coEvery { repository.getCharacters(any()) } returns
|
||||
Result.Success(CharactersPage(characters = emptyList(), nextPage = null))
|
||||
|
||||
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)
|
||||
coVerify(exactly = 1) { repository.getCharacters(7) }
|
||||
}
|
||||
|
||||
private fun domainCharacter(id: Int) = Character(
|
||||
|
||||
@@ -9,6 +9,10 @@ import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isSameInstanceAs
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
@@ -24,14 +28,15 @@ 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.
|
||||
* dependency. The [CharacterRepository] collaborator is a *relaxed* MockK mock, so the "missing id"
|
||||
* case needs no stubbing while the rest stub `getCharacterDetails` explicitly with `coEvery`;
|
||||
* assertions use AssertK; the back event is observed with Turbine.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CharacterDetailViewModelTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val repository = FakeCharacterRepository()
|
||||
private val repository = mockk<CharacterRepository>(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
@@ -48,7 +53,7 @@ class CharacterDetailViewModelTest {
|
||||
|
||||
@Test
|
||||
fun `loads details on init`() = runTest(dispatcher.scheduler) {
|
||||
repository.setDetails(characterDetails(1))
|
||||
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
|
||||
|
||||
val viewModel = viewModel(characterId = 1)
|
||||
advanceUntilIdle()
|
||||
@@ -62,7 +67,7 @@ class CharacterDetailViewModelTest {
|
||||
|
||||
@Test
|
||||
fun `load failure surfaces an error and no details`() = runTest(dispatcher.scheduler) {
|
||||
repository.failWith = DataError.Network.SERVER_ERROR
|
||||
coEvery { repository.getCharacterDetails(1) } returns Result.Error(DataError.Network.SERVER_ERROR)
|
||||
|
||||
val viewModel = viewModel(characterId = 1)
|
||||
advanceUntilIdle()
|
||||
@@ -75,14 +80,13 @@ class CharacterDetailViewModelTest {
|
||||
|
||||
@Test
|
||||
fun `retry after a failure clears the error and loads details`() = runTest(dispatcher.scheduler) {
|
||||
repository.failWith = DataError.Network.NO_INTERNET
|
||||
coEvery { repository.getCharacterDetails(1) } returns Result.Error(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))
|
||||
// Same call, new answer — the latest `coEvery` wins, so the retry attempt succeeds.
|
||||
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
|
||||
viewModel.onAction(CharacterDetailAction.OnRetry)
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -93,7 +97,7 @@ class CharacterDetailViewModelTest {
|
||||
|
||||
@Test
|
||||
fun `back click emits NavigateBack`() = runTest(dispatcher.scheduler) {
|
||||
repository.setDetails(characterDetails(1))
|
||||
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
|
||||
val viewModel = viewModel(characterId = 1)
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -108,6 +112,7 @@ class CharacterDetailViewModelTest {
|
||||
@Test
|
||||
fun `missing character id fails fast`() {
|
||||
// The route contract: type-safe nav must have written characterId into SavedStateHandle.
|
||||
// The constructor throws before the (relaxed) repository is ever touched.
|
||||
assertThrows<IllegalStateException> {
|
||||
CharacterDetailViewModel(SavedStateHandle(), repository)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
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
|
||||
|
||||
/** Minimal list-item domain fixture for presentation tests. */
|
||||
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 for presentation tests. */
|
||||
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,
|
||||
)
|
||||
@@ -13,7 +13,14 @@ import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import assertk.assertions.prop
|
||||
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.CharactersPage
|
||||
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
@@ -31,14 +38,15 @@ import org.junit.jupiter.api.Test
|
||||
*
|
||||
* 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.
|
||||
* coroutine runs. The collaborator is a MockK mock of [CharacterRepository] (the real
|
||||
* [GetCharactersPageUseCase] wraps it), stubbed per page with `coEvery` and verified with `coVerify`;
|
||||
* `state`/`events` are observed with Turbine; assertions use AssertK.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CharacterListViewModelTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val repository = FakeCharacterRepository()
|
||||
private val repository = mockk<CharacterRepository>()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
@@ -55,7 +63,8 @@ class CharacterListViewModelTest {
|
||||
|
||||
@Test
|
||||
fun `loads the first page on init`() = runTest(dispatcher.scheduler) {
|
||||
repository.setPage(page = 1, characters = listOf(character(1), character(2)), nextPage = 2)
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1), character(2)), nextPage = 2))
|
||||
|
||||
val viewModel = viewModel()
|
||||
|
||||
@@ -78,7 +87,7 @@ class CharacterListViewModelTest {
|
||||
@Test
|
||||
fun `initial load failure emits a snackbar event and a full-screen error`() =
|
||||
runTest(dispatcher.scheduler) {
|
||||
repository.failWith = DataError.Network.NO_INTERNET
|
||||
coEvery { repository.getCharacters(any()) } returns Result.Error(DataError.Network.NO_INTERNET)
|
||||
|
||||
val viewModel = viewModel()
|
||||
|
||||
@@ -95,8 +104,10 @@ class CharacterListViewModelTest {
|
||||
|
||||
@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
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
|
||||
coEvery { repository.getCharacters(2) } returns
|
||||
Result.Success(CharactersPage(listOf(character(2)), nextPage = null)) // last page
|
||||
|
||||
val viewModel = viewModel()
|
||||
advanceUntilIdle() // init → page 1
|
||||
@@ -107,21 +118,24 @@ class CharacterListViewModelTest {
|
||||
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)
|
||||
// Page 2 was fetched exactly once and no page 3 was ever requested (a page-3 fetch would also
|
||||
// blow up the strict mock as an unstubbed call).
|
||||
coVerify(exactly = 1) { repository.getCharacters(2) }
|
||||
coVerify(exactly = 0) { repository.getCharacters(3) }
|
||||
}
|
||||
|
||||
@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)
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
|
||||
coEvery { repository.getCharacters(2) } returns
|
||||
Result.Success(CharactersPage(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.
|
||||
@@ -129,14 +143,15 @@ class CharacterListViewModelTest {
|
||||
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore + 1)
|
||||
coVerify(exactly = 1) { repository.getCharacters(2) }
|
||||
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)
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
|
||||
|
||||
val viewModel = viewModel()
|
||||
// restore() set isLoading = true synchronously; its coroutine hasn't run yet, so this
|
||||
@@ -145,12 +160,13 @@ class CharacterListViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
// Only the single initial load ran — the guarded next-page request never fired.
|
||||
assertThat(repository.getCharactersCallCount).isEqualTo(1)
|
||||
coVerify(exactly = 1) { repository.getCharacters(1) }
|
||||
coVerify(exactly = 0) { repository.getCharacters(2) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retry after a failed initial load rebuilds the list`() = runTest(dispatcher.scheduler) {
|
||||
repository.failWith = DataError.Network.NO_INTERNET
|
||||
coEvery { repository.getCharacters(any()) } returns Result.Error(DataError.Network.NO_INTERNET)
|
||||
val viewModel = viewModel()
|
||||
|
||||
viewModel.events.test {
|
||||
@@ -160,9 +176,10 @@ class CharacterListViewModelTest {
|
||||
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)
|
||||
// Empty branch of retry(): the repository recovers (the later, more specific stub for
|
||||
// page 1 wins over the `any()` failure), OnRetry rebuilds from page 1.
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1), character(2)), nextPage = 2))
|
||||
viewModel.onAction(CharacterListAction.OnRetry)
|
||||
advanceUntilIdle()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
@@ -174,12 +191,14 @@ class CharacterListViewModelTest {
|
||||
|
||||
@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)
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(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.
|
||||
// Page 2 fails on the first attempt → list keeps page 1, shows an error.
|
||||
coEvery { repository.getCharacters(2) } returns Result.Error(DataError.Network.NOT_FOUND)
|
||||
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||
advanceUntilIdle()
|
||||
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
|
||||
@@ -187,7 +206,8 @@ class CharacterListViewModelTest {
|
||||
|
||||
// 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)
|
||||
coEvery { repository.getCharacters(2) } returns
|
||||
Result.Success(CharactersPage(listOf(character(2)), nextPage = null))
|
||||
viewModel.onAction(CharacterListAction.OnRetry)
|
||||
advanceUntilIdle()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
@@ -196,12 +216,16 @@ class CharacterListViewModelTest {
|
||||
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()
|
||||
// Page 2 was requested twice: the failed first load and the successful retry.
|
||||
coVerify(exactly = 2) { repository.getCharacters(2) }
|
||||
}
|
||||
|
||||
@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)
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
|
||||
coEvery { repository.getCharacters(2) } returns
|
||||
Result.Success(CharactersPage(listOf(character(2)), nextPage = 3))
|
||||
// Navigation/SavedStateHandle persisted the last loaded page across process death.
|
||||
val savedStateHandle = SavedStateHandle(mapOf("currentPage" to 2))
|
||||
|
||||
@@ -211,5 +235,9 @@ class CharacterListViewModelTest {
|
||||
// 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)
|
||||
coVerifyOrder {
|
||||
repository.getCharacters(1)
|
||||
repository.getCharacters(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -40,6 +40,7 @@ junitJupiter = "5.11.4"
|
||||
junitPlatform = "1.11.4"
|
||||
turbine = "1.2.0"
|
||||
assertk = "0.28.1"
|
||||
mockk = "1.14.3"
|
||||
androidxTestExt = "1.3.0"
|
||||
androidxTestRunner = "1.7.0"
|
||||
androidxEspresso = "3.7.0"
|
||||
@@ -119,6 +120,7 @@ junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", vers
|
||||
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" }
|
||||
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
|
||||
assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" }
|
||||
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" }
|
||||
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExt" }
|
||||
androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspresso" }
|
||||
@@ -142,7 +144,7 @@ ktor = [
|
||||
]
|
||||
lifecycle-compose = ["androidx-lifecycle-runtime-compose", "androidx-lifecycle-viewmodel-compose"]
|
||||
views = ["androidx-appcompat", "material", "androidx-recyclerview", "androidx-fragment-ktx"]
|
||||
unit-test = ["junit-jupiter-api", "kotlinx-coroutines-test", "turbine", "assertk"]
|
||||
unit-test = ["junit-jupiter-api", "kotlinx-coroutines-test", "turbine", "assertk", "mockk"]
|
||||
# Instrumented Compose UI test (androidTest): ComposeTestRule + AndroidJUnit4 runner.
|
||||
# espresso-core/runner are pinned to current versions: Compose's test rule drives Espresso's
|
||||
# onIdle, and the transitive espresso 3.5.0 calls InputManager.getInstance() (removed on API 34+),
|
||||
|
||||
Reference in New Issue
Block a user