From d232757eb47614ae9d1d63f59419f2bc4aa1e66a Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 15:00:54 +0200 Subject: [PATCH] REDI-96: repository MockEngine test + Compose robot UI test + serialization fix NetworkCharacterRepositoryTest swaps a Ktor MockEngine into HttpClientFactory and covers success mapping (incl. request URL/page-param construction), 404 -> NOT_FOUND, 500 -> SERVER_ERROR, and malformed body -> SERIALIZATION. That last case exposed a real bug: Ktor wraps the kotlinx SerializationException in its own ContentConvertException, so safeCall mapped it to UNKNOWN; safeCall now scans the cause chain and maps it to SERIALIZATION. Adds an instrumented Compose UI test (CharacterListScreen) using the chaining CharacterListRobot: rendered items, empty/error states, and tap -> Action. --- .../core/data/network/HttpClientExt.kt | 12 +- .../data/NetworkCharacterRepositoryTest.kt | 162 ++++++++++++++++++ .../compose/CharacterListRobot.kt | 81 +++++++++ .../compose/CharacterListScreenTest.kt | 77 +++++++++ 4 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 feature/characters/data/src/test/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepositoryTest.kt create mode 100644 feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListRobot.kt create mode 100644 feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreenTest.kt diff --git a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt index b14caa1..af4a3c8 100644 --- a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt +++ b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt @@ -75,8 +75,16 @@ suspend inline fun safeCall( Result.Error(DataError.Network.SERIALIZATION) } catch (e: Exception) { if (e is CancellationException) throw e - logNetworkError(e, "Unknown network failure") - Result.Error(DataError.Network.UNKNOWN) + // Ktor's ContentNegotiation wraps a kotlinx SerializationException (malformed/garbage body) + // in its own ContentConvertException, so the catch above misses it. Scan the cause chain so a + // bad payload still maps to SERIALIZATION instead of the generic UNKNOWN. + if (generateSequence(e as Throwable) { it.cause }.any { it is SerializationException }) { + logNetworkError(e, "Serialization failure (wrapped)") + Result.Error(DataError.Network.SERIALIZATION) + } else { + logNetworkError(e, "Unknown network failure") + Result.Error(DataError.Network.UNKNOWN) + } } } diff --git a/feature/characters/data/src/test/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepositoryTest.kt b/feature/characters/data/src/test/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepositoryTest.kt new file mode 100644 index 0000000..46de1f7 --- /dev/null +++ b/feature/characters/data/src/test/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepositoryTest.kt @@ -0,0 +1,162 @@ +package com.example.architecture.feature.characters.data + +import assertk.assertThat +import assertk.assertions.endsWith +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import com.example.architecture.core.data.network.HttpClientFactory +import com.example.architecture.core.domain.DataError +import com.example.architecture.core.domain.Result +import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.HttpResponseData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +/** + * Data-layer test for [NetworkCharacterRepository]. A Ktor [MockEngine] is swapped into the real + * [HttpClientFactory] (`create(engine)` takes the engine precisely so tests can do this) — so the + * full path under test is genuine: Ktor request → status/JSON handling in `safeCall` → DTO mapping → + * domain model. Covers success mapping, a 404 and a 5xx mapped to typed [DataError.Network], and a + * malformed-body → SERIALIZATION case. + */ +class NetworkCharacterRepositoryTest { + + private fun repository( + handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData, + ): NetworkCharacterRepository { + val engine = MockEngine { request -> handler(request) } + val httpClient = HttpClientFactory.create(engine) + return NetworkCharacterRepository(KtorCharacterDataSource(httpClient)) + } + + private fun jsonHeaders() = headersOf(HttpHeaders.ContentType, "application/json") + + @Test + fun `getCharacters maps a successful response to a domain page`() = runTest { + var requestedPath: String? = null + var requestedPage: String? = null + val repository = repository { request -> + requestedPath = request.url.encodedPath + requestedPage = request.url.parameters["page"] + respond(content = CHARACTERS_PAGE_JSON, status = HttpStatusCode.OK, headers = jsonHeaders()) + } + + val result = repository.getCharacters(page = 3) + + // Request construction: correct endpoint and the page forwarded as a query param. + assertThat(requestedPath).isNotNull().endsWith("/character") + assertThat(requestedPage).isEqualTo("3") + + assertThat(result).isInstanceOf(Result.Success::class) + val page = (result as Result.Success).data + assertThat(page.characters.size).isEqualTo(2) + assertThat(page.characters.first().name).isEqualTo("Rick Sanchez") + // `next` URL ".../character?page=2" is parsed to a page number. + assertThat(page.nextPage).isEqualTo(2) + } + + @Test + fun `getCharacters maps 404 to NOT_FOUND`() = runTest { + val repository = repository { + respond(content = "", status = HttpStatusCode.NotFound) + } + + val result = repository.getCharacters(page = 1) + + assertThat(result).isEqualTo(Result.Error(DataError.Network.NOT_FOUND)) + } + + @Test + fun `getCharacters maps 500 to SERVER_ERROR`() = runTest { + val repository = repository { + respond(content = "", status = HttpStatusCode.InternalServerError) + } + + val result = repository.getCharacters(page = 1) + + assertThat(result).isEqualTo(Result.Error(DataError.Network.SERVER_ERROR)) + } + + @Test + fun `getCharacters maps a malformed body to SERIALIZATION`() = runTest { + val repository = repository { + respond(content = "{ this is not valid json", status = HttpStatusCode.OK, headers = jsonHeaders()) + } + + val result = repository.getCharacters(page = 1) + + assertThat(result).isEqualTo(Result.Error(DataError.Network.SERIALIZATION)) + } + + @Test + fun `getCharacterDetails maps a successful response to domain details`() = runTest { + var requestedPath: String? = null + val repository = repository { request -> + requestedPath = request.url.encodedPath + respond(content = CHARACTER_JSON, status = HttpStatusCode.OK, headers = jsonHeaders()) + } + + val result = repository.getCharacterDetails(id = 1) + + // Request construction: the id is placed in the path. + assertThat(requestedPath).isNotNull().endsWith("/character/1") + + assertThat(result).isInstanceOf(Result.Success::class) + val details = (result as Result.Success).data + assertThat(details.name).isEqualTo("Rick Sanchez") + assertThat(details.origin).isEqualTo("Earth (C-137)") + assertThat(details.episodeCount).isEqualTo(3) + } + + private companion object { + val CHARACTER_JSON = """ + { + "id": 1, + "name": "Rick Sanchez", + "status": "Alive", + "species": "Human", + "type": "", + "gender": "Male", + "origin": { "name": "Earth (C-137)", "url": "" }, + "location": { "name": "Citadel of Ricks", "url": "" }, + "image": "https://example.com/1.png", + "episode": ["e1", "e2", "e3"] + } + """.trimIndent() + + val CHARACTERS_PAGE_JSON = """ + { + "info": { + "count": 2, + "pages": 1, + "next": "https://rickandmortyapi.com/api/character?page=2", + "prev": null + }, + "results": [ + { + "id": 1, "name": "Rick Sanchez", "status": "Alive", "species": "Human", + "type": "", "gender": "Male", + "origin": { "name": "Earth (C-137)", "url": "" }, + "location": { "name": "Citadel of Ricks", "url": "" }, + "image": "https://example.com/1.png", "episode": ["e1", "e2"] + }, + { + "id": 2, "name": "Morty Smith", "status": "Alive", "species": "Human", + "type": "", "gender": "Male", + "origin": { "name": "Earth (C-137)", "url": "" }, + "location": { "name": "Citadel of Ricks", "url": "" }, + "image": "https://example.com/2.png", "episode": ["e1"] + } + ] + } + """.trimIndent() + } +} diff --git a/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListRobot.kt b/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListRobot.kt new file mode 100644 index 0000000..a6e907c --- /dev/null +++ b/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListRobot.kt @@ -0,0 +1,81 @@ +package com.example.architecture.feature.characters.presentation.compose + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.example.architecture.core.design.system.theme.AppTheme +import com.example.architecture.feature.characters.presentation.CharacterListAction +import com.example.architecture.feature.characters.presentation.CharacterListState +import org.junit.Assert.assertTrue + +/** + * Robot for [CharacterListScreen] UI tests. Each method returns `this` so calls read as a fluent + * scenario (`robot.setContent(state).assertCharacterShown(...).clickCharacter(...)`). The robot owns + * the interaction vocabulary; the test owns the assertions' intent — keeping tests readable and + * resilient to UI structure changes. See android-testing. + */ +class CharacterListRobot( + private val composeRule: ComposeContentTestRule, + private val context: Context, +) { + private val recordedActions = mutableListOf() + + fun setContent(state: CharacterListState): CharacterListRobot { + composeRule.setContent { + AppTheme { + CharacterListScreen( + state = state, + onAction = { recordedActions += it }, + onOpenAbout = {}, + onOpenViewsList = {}, + onOpenErrorDemo = {}, + ) + } + } + return this + } + + fun assertCharacterShown(name: String): CharacterListRobot { + composeRule.onNodeWithText(name).assertIsDisplayed() + return this + } + + fun assertEmptyStateShown(): CharacterListRobot { + composeRule.onNodeWithText(context.getString(R.string.characters_empty)).assertIsDisplayed() + return this + } + + fun assertErrorShown(message: String): CharacterListRobot { + composeRule.onNodeWithText(message).assertIsDisplayed() + return this + } + + fun assertRetryShown(): CharacterListRobot { + composeRule.onNodeWithText(retryLabel).assertIsDisplayed() + return this + } + + fun clickCharacter(name: String): CharacterListRobot { + composeRule.onNodeWithText(name).performClick() + return this + } + + fun clickRetry(): CharacterListRobot { + composeRule.onNodeWithText(retryLabel).performClick() + return this + } + + fun assertActionRecorded(action: CharacterListAction): CharacterListRobot { + assertTrue( + "Expected $action to be recorded, but got $recordedActions", + recordedActions.contains(action), + ) + return this + } + + // The retry label lives in the design-system module; reference its R directly (non-transitive R). + private val retryLabel: String + get() = context.getString(com.example.architecture.core.design.system.R.string.designsystem_retry) +} diff --git a/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreenTest.kt b/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreenTest.kt new file mode 100644 index 0000000..426d28f --- /dev/null +++ b/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreenTest.kt @@ -0,0 +1,77 @@ +package com.example.architecture.feature.characters.presentation.compose + +import android.content.Context +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.architecture.core.presentation.UiText +import com.example.architecture.feature.characters.domain.model.CharacterStatus +import com.example.architecture.feature.characters.presentation.CharacterListAction +import com.example.architecture.feature.characters.presentation.CharacterListState +import com.example.architecture.feature.characters.presentation.model.CharacterUi +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented Compose UI test for [CharacterListScreen] using [CharacterListRobot]. Runs on a + * device/emulator (`connectedDebugAndroidTest`); CI assembles it. Asserts rendered items, the + * empty + error states, and that user gestures fire the right MVI [CharacterListAction]s. + */ +@RunWith(AndroidJUnit4::class) +class CharacterListScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + + private fun robot() = CharacterListRobot(composeRule, context) + + private val loadedState = CharacterListState( + characters = persistentListOf( + CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE), + CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE), + ), + ) + + @Test + fun rendersCharacterItems() { + robot() + .setContent(loadedState) + .assertCharacterShown("Rick Sanchez") + .assertCharacterShown("Morty Smith") + } + + @Test + fun showsEmptyState() { + robot() + .setContent(CharacterListState()) + .assertEmptyStateShown() + } + + @Test + fun showsErrorStateWithRetry() { + robot() + .setContent(CharacterListState(error = UiText.DynamicString("Boom"))) + .assertErrorShown("Boom") + .assertRetryShown() + } + + @Test + fun tappingAnItemFiresOnCharacterClick() { + robot() + .setContent(loadedState) + .clickCharacter("Rick Sanchez") + .assertActionRecorded(CharacterListAction.OnCharacterClick(1)) + } + + @Test + fun tappingRetryFiresOnRetry() { + robot() + .setContent(CharacterListState(error = UiText.DynamicString("Boom"))) + .clickRetry() + .assertActionRecorded(CharacterListAction.OnRetry) + } +}