Skip to content

Commit

Permalink
Test: Add TimeTableViewModel unit tests and fake trip response builder (
Browse files Browse the repository at this point in the history
#514)

### TL;DR
Added test coverage for TimeTableViewModel and implemented a fake trip response builder

### What changed?
- Created `FakeTripResponseBuilder` to generate test data for trip responses
- Added success/failure toggle to `FakeTripPlanningService`
- Implemented unit tests for TimeTableViewModel covering:
  - Initial state verification
  - Success response handling
  - Error response handling
- Added `@VisibleForTesting` annotation to `fetchTrip()` method
- Added debug logging statements in TimeTableViewModel

### How to test?
1. Run the TimeTableViewModel test suite
2. Verify tests pass for:
   - Initial state observation
   - Loading time table with successful API response
   - Loading time table with error API response

### Why make this change?
To improve test coverage and ensure proper handling of both successful and failed trip planning scenarios in the TimeTableViewModel. The addition of the FakeTripResponseBuilder provides consistent test data and makes tests more maintainable.
  • Loading branch information
ksharma-xyz authored Jan 8, 2025
1 parent f382e4e commit e1beff2
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -1,23 +1,47 @@
package xyz.ksharma.core.test.fakes

import xyz.ksharma.core.test.fakes.FakeTripResponseBuilder.buildDestinationStopSequence
import xyz.ksharma.core.test.fakes.FakeTripResponseBuilder.buildOriginStopSequence
import xyz.ksharma.core.test.fakes.FakeTripResponseBuilder.buildTransportation
import xyz.ksharma.krail.trip.planner.network.api.model.StopFinderResponse
import xyz.ksharma.krail.trip.planner.network.api.model.StopType
import xyz.ksharma.krail.trip.planner.network.api.model.TripResponse
import xyz.ksharma.krail.trip.planner.network.api.model.TripResponse.StopSequence
import xyz.ksharma.krail.trip.planner.network.api.service.DepArr
import xyz.ksharma.krail.trip.planner.network.api.service.TripPlanningService

class FakeTripPlanningService : TripPlanningService {

var isSuccess: Boolean = true

override suspend fun trip(
originStopId: String,
destinationStopId: String,
depArr: DepArr,
date: String?,
time: String?,
): TripResponse {
return if (isSuccess)

// Return a fake TripResponse
return TripResponse(
)
TripResponse(
journeys = listOf(
TripResponse.Journey(
legs = listOf(
TripResponse.Leg(
origin = buildOriginStopSequence(),
destination = buildDestinationStopSequence(),
stopSequence = listOf(
buildOriginStopSequence(),
),
transportation = buildTransportation(),
duration = 100,
),
),
),
)
)
else throw IllegalStateException("Failed to fetch trip")
}

override suspend fun stopFinder(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package xyz.ksharma.core.test.fakes

import xyz.ksharma.krail.trip.planner.network.api.model.StopType
import xyz.ksharma.krail.trip.planner.network.api.model.TripResponse
import xyz.ksharma.krail.trip.planner.network.api.model.TripResponse.StopSequence

object FakeTripResponseBuilder {

fun buildOriginStopSequence() = StopSequence(
arrivalTimePlanned = "2024-09-24T19:00:00Z",
arrivalTimeEstimated = "2024-09-24T19:10:00Z",
departureTimePlanned = "2024-09-24T19:10:00Z",
departureTimeEstimated = "2024-09-24T19:10:00Z",
name = "Origin Stop",
disassembledName = "Origin Name",
id = "Origin_stop_id",
type = StopType.STOP.type,
)

fun buildDestinationStopSequence() = StopSequence(
arrivalTimePlanned = "2024-09-24T20:00:00Z",
arrivalTimeEstimated = "2024-09-24T20:10:00Z",
departureTimePlanned = "2024-09-24T20:10:00Z",
departureTimeEstimated = "2024-09-24T20:10:00Z",
name = "Destination Stop",
disassembledName = "Destination Name",
id = "Destination_stop_id",
type = StopType.STOP.type,
)

fun buildTransportation() = TripResponse.Transportation(
disassembledName = "Transportation Name",
product = TripResponse.Product(
productClass = 1,
name = "Train",
),
destination = TripResponse.OperatorClass(
name = "Destination Operator",
id = "Destination Operator Id",
),
name = "Transportation Name",
id = "Transportation Id",
description = "Transportation Description",
)
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
package xyz.ksharma.core.test.viewmodels

import app.cash.turbine.test
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 xyz.ksharma.core.test.fakes.FakeAnalytics
import xyz.ksharma.core.test.fakes.FakeRateLimiter
import xyz.ksharma.core.test.fakes.FakeSandook
import xyz.ksharma.core.test.fakes.FakeTripPlanningService
import xyz.ksharma.krail.core.analytics.Analytics
import xyz.ksharma.krail.sandook.Sandook
import xyz.ksharma.krail.trip.planner.ui.state.timetable.TimeTableUiEvent
import xyz.ksharma.krail.trip.planner.ui.state.timetable.Trip
import xyz.ksharma.krail.trip.planner.ui.timetable.TimeTableViewModel
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue

@OptIn(ExperimentalCoroutinesApi::class)
class TimeTableViewModelTest {
Expand Down Expand Up @@ -42,4 +53,130 @@ class TimeTableViewModelTest {
fun tearDown() {
Dispatchers.resetMain()
}

@Test
fun `GIVEN initial state WHEN observer is active THEN fetchTrip and trackScreenViewEvent should be called`() =
runTest {
// Ensure analytics events have not been tracked before observation
assertFalse((analytics as FakeAnalytics).isEventTracked("view_screen"))

viewModel.isLoading.test {
val isLoadingState = awaitItem()
assertEquals(isLoadingState, true)

advanceUntilIdle()
assertTrue(analytics.isEventTracked("view_screen"))

cancelAndConsumeRemainingEvents()
}
}

@Test
fun `GIVEN a trip WHEN LoadTimeTable event is triggered and Trip API is success response THEN UI State must update with journeyList`() =
runTest {
// GIVEN a trip
val trip = Trip(
fromStopId = "FROM_STOP_ID_1",
fromStopName = "STOP_NAME_1",
toStopId = "TO_STOP_ID_1",
toStopName = "STOP_NAME_2"
)
tripPlanningService.isSuccess = true

// THEN verify that the UI state is updated correctly
viewModel.uiState.test {
val initialState = awaitItem()
initialState.run {
assertTrue(isLoading)
assertNull(initialState.trip)
assertFalse(isError)
assertFalse(isTripSaved)
}

// WHEN the LoadTimeTable event is triggered
viewModel.onEvent(TimeTableUiEvent.LoadTimeTable(trip))
viewModel.fetchTrip() // Manually call fetchTrip() to simulate the actual behavior
awaitItem().run {
assertTrue(isLoading)
assertFalse(silentLoading)
assertFalse(isError)
assertTrue(journeyList.isEmpty())
}

// need to skip two items, because silentLoading will be toggled, as we manually call fetchTrip()
skipItems(2)
/*
awaitItem().run {
assertTrue(silentLoading)
}
awaitItem().run {
assertFalse(silentLoading)
}
*/

awaitItem().run {
assertFalse(isLoading)
assertFalse(silentLoading)
assertTrue(journeyList.isNotEmpty())
assertEquals(expected = 1, journeyList.size)
}

cancelAndConsumeRemainingEvents()
}
}

@Test
fun `GIVEN a trip WHEN LoadTimeTable event is triggered and Trip API is error response THEN UIState should have isError as true`() =
runTest {
// GIVEN a trip
val trip = Trip(
fromStopId = "FROM_STOP_ID_1",
fromStopName = "STOP_NAME_1",
toStopId = "TO_STOP_ID_1",
toStopName = "STOP_NAME_2"
)
tripPlanningService.isSuccess = false

// THEN verify that the UI state is updated correctly
viewModel.uiState.test {
val initialState = awaitItem()
initialState.run {
assertTrue(isLoading)
assertNull(initialState.trip)
assertFalse(isError)
assertFalse(isTripSaved)
}

// WHEN the LoadTimeTable event is triggered
viewModel.onEvent(TimeTableUiEvent.LoadTimeTable(trip))
viewModel.fetchTrip() // Manually call fetchTrip() to simulate the actual behavior
awaitItem().run {
assertTrue(isLoading)
assertFalse(silentLoading)
assertFalse(isError)
assertTrue(journeyList.isEmpty())
}

// need to skip two items, because silentLoading will be toggled, as we manually call fetchTrip()
skipItems(2)
/*
awaitItem().run {
assertTrue(silentLoading)
}
awaitItem().run {
assertFalse(silentLoading)
}
*/

awaitItem().run {
assertFalse(isLoading)
assertFalse(silentLoading)
assertTrue(journeyList.isEmpty())
assertTrue(isError)
}

cancelAndConsumeRemainingEvents()
}
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package xyz.ksharma.krail.trip.planner.ui.timetable

import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.toImmutableList
Expand Down Expand Up @@ -169,7 +170,8 @@ class TimeTableViewModel(
}
}

private fun fetchTrip() {
@VisibleForTesting
fun fetchTrip() {
log("fetchTrip API Call")
fetchTripJob?.cancel()
updateUiState { copy(silentLoading = true) }
Expand All @@ -181,6 +183,7 @@ class TimeTableViewModel(
}.catch { e ->
log("Error while fetching trip: $e")
}.collectLatest { result ->
println("result Success: $result")
updateUiState { copy(silentLoading = false) }
result.onSuccess { response ->
updateTripsCache(response)
Expand Down Expand Up @@ -239,12 +242,17 @@ class TimeTableViewModel(
}

private fun updateUiStateWithFilteredTrips() {
println("updateUiStateWithFilteredTrips")

val journeyList = updateJourneyCardInfoTimeText(journeys.values.toList())
.sortedBy { it.originUtcDateTime.utcToLocalDateTimeAEST() }
.toImmutableList()

println("updateUiStateWithFilteredTrips: ${journeyList.size}")
updateUiState {
copy(
isLoading = false,
journeyList = updateJourneyCardInfoTimeText(journeys.values.toList())
.sortedBy { it.originUtcDateTime.utcToLocalDateTimeAEST() }
.toImmutableList(),
journeyList = journeyList,
isError = false,
)
}
Expand All @@ -268,6 +276,8 @@ class TimeTableViewModel(
else -> DepArr.DEP
}
)
println("tripResponse: $tripResponse")

Result.success(tripResponse)
}.getOrElse { error ->
Result.failure(error)
Expand Down

0 comments on commit e1beff2

Please sign in to comment.