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.
This commit is contained in:
@@ -75,10 +75,18 @@ suspend inline fun <reified T> safeCall(
|
|||||||
Result.Error(DataError.Network.SERIALIZATION)
|
Result.Error(DataError.Network.SERIALIZATION)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
|
// 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")
|
logNetworkError(e, "Unknown network failure")
|
||||||
Result.Error(DataError.Network.UNKNOWN)
|
Result.Error(DataError.Network.UNKNOWN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs a caught network error. `@PublishedApi internal` so the public inline [safeCall] can call it
|
* Logs a caught network error. `@PublishedApi internal` so the public inline [safeCall] can call it
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CharacterListAction>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user