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:
2026-06-10 15:53:31 +02:00
parent 06de5f37d5
commit 1cbf00c02c
7 changed files with 113 additions and 135 deletions

View File

@@ -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())

View File

@@ -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(

View File

@@ -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)
}

View File

@@ -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,
)

View File

@@ -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)
}
}
}

View File

@@ -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,
)

View File

@@ -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+),