From d1972689779181c27e7492069e2aa558f47759e3 Mon Sep 17 00:00:00 2001 From: Kenneth Mathari Date: Sun, 12 Jan 2025 22:04:26 +0300 Subject: [PATCH] update unit tests with turbine --- README.md | 3 +- composeApp/build.gradle.kts | 2 + .../data/repository/WeatherRepositoryImpl.kt | 6 +- .../weather/multiplatform/di/SharedModule.kt | 26 ++--- .../ui/viewmodel/WeatherViewModel.kt | 30 +++--- .../multiplatform/utils/DispatcherProvider.kt | 26 +++++ .../multiplatform/utils/GeolocatorProvider.kt | 13 +++ .../data/repository/WeatherRepositoryTest.kt | 61 +++++------- .../multiplatform/ui/WeatherViewModelTest.kt | 99 +++++++++++++++++++ .../weather/multiplatform/utils/TestData.kt | 30 ++++++ .../utils/TestDispatcherProvider.kt | 20 ++++ gradle/libs.versions.toml | 3 + 12 files changed, 250 insertions(+), 69 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/DispatcherProvider.kt create mode 100644 composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/GeolocatorProvider.kt create mode 100644 composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/ui/WeatherViewModelTest.kt create mode 100644 composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestData.kt create mode 100644 composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestDispatcherProvider.kt diff --git a/README.md b/README.md index 0b137b9..faefb38 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ WeatherApp is a Kotlin Multiplatform (KMP) project that provides a **5-day weath - Kotlinx Serialization : Facilitates data serialization and deserialization in a format-agnostic way. - KtLint: creates convenient tasks in your Gradle project that run ktlint checks or do code auto format. - Mokkery : For mocking dependencies in tests. +- Turbine : Specialized library for testing kotlinx.coroutines Flow. - Build Konfig : BuildConfig for Kotlin Multiplatform Project. - Compass : Kotlin Multiplatform library location toolkit for geocoding and geolocation - Assertk : assertions for kotlin tests @@ -41,6 +42,6 @@ Other dependencies are listed in the build.gradle files. ## iOS Weather App Screenshots > ![iOS](https://github.com/user-attachments/assets/e2019086-147d-4849-a627-313b6d0144bb) -## APK File +## Artifacts The app artifacts(Android & iOS) can be found from the latest successful action on the [GitHub Actions](https://github.com/KennethMathari/WeatherApp_KMP/actions) tab diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 4a02044..f30be1f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -112,6 +112,8 @@ kotlin { implementation(libs.assertk) // Kotlin Coroutine Test implementation(libs.kotlinx.coroutines.test) + // Turbine + implementation(libs.turbine) } iosMain.dependencies { diff --git a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryImpl.kt index eb4e7a2..ba3882b 100644 --- a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryImpl.kt @@ -3,6 +3,7 @@ package co.ke.weather.multiplatform.data.repository import co.ke.weather.multiplatform.data.model.weather.WeatherForecastDTO import co.ke.weather.multiplatform.domain.repository.WeatherRepository import co.ke.weather.multiplatform.utils.Constants.WEATHER_FORECAST_BASE_URL +import co.ke.weather.multiplatform.utils.DispatcherProvider import co.ke.weather.multiplatform.utils.NetworkResult import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -12,14 +13,13 @@ import io.ktor.client.plugins.ServerResponseException import io.ktor.client.request.get import io.ktor.http.isSuccess import io.ktor.utils.io.errors.IOException -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn class WeatherRepositoryImpl( private val httpClient: HttpClient, - private val ioDispatcher: CoroutineDispatcher + private val dispatcherProvider: DispatcherProvider ) : WeatherRepository { override fun getWeatherForecast( @@ -57,6 +57,6 @@ class WeatherRepositoryImpl( } catch (e: Exception) { emit(NetworkResult.NetworkError(e)) } - }.flowOn(ioDispatcher) + }.flowOn(dispatcherProvider.io) } } diff --git a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/di/SharedModule.kt b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/di/SharedModule.kt index 0301c3d..9a475c2 100644 --- a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/di/SharedModule.kt +++ b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/di/SharedModule.kt @@ -3,6 +3,10 @@ package co.ke.weather.multiplatform.di import co.ke.weather.multiplatform.data.repository.WeatherRepositoryImpl import co.ke.weather.multiplatform.domain.repository.WeatherRepository import co.ke.weather.multiplatform.ui.viewmodel.WeatherViewModel +import co.ke.weather.multiplatform.utils.DefaultDispatcherProvider +import co.ke.weather.multiplatform.utils.DispatcherProvider +import dev.jordond.compass.geolocation.Geolocator +import dev.jordond.compass.geolocation.mobile import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.HttpTimeout @@ -13,35 +17,35 @@ import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.http.HttpHeaders import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO import kotlinx.serialization.json.Json import org.koin.compose.viewmodel.dsl.viewModelOf import org.koin.dsl.module val sharedModule = module { - single { - Dispatchers.IO + single { + DefaultDispatcherProvider() } single { WeatherRepositoryImpl( - httpClient = get(), - ioDispatcher = get() + httpClient = get(), dispatcherProvider = get() ) } + single { + Geolocator.mobile() + } + single { HttpClient { install(ContentNegotiation) { json( Json { - ignoreUnknownKeys = true - prettyPrint = true - isLenient = true - } + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + } ) } diff --git a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/ui/viewmodel/WeatherViewModel.kt b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/ui/viewmodel/WeatherViewModel.kt index 60697b7..d9acad0 100644 --- a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/ui/viewmodel/WeatherViewModel.kt +++ b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/ui/viewmodel/WeatherViewModel.kt @@ -6,18 +6,20 @@ import co.ke.weather.multiplatform.BuildKonfig import co.ke.weather.multiplatform.data.model.weather.WeatherForecastDTO import co.ke.weather.multiplatform.domain.repository.WeatherRepository import co.ke.weather.multiplatform.ui.state.WeatherState +import co.ke.weather.multiplatform.utils.DispatcherProvider import co.ke.weather.multiplatform.utils.NetworkResult import dev.jordond.compass.Priority import dev.jordond.compass.geolocation.Geolocator import dev.jordond.compass.geolocation.GeolocatorResult -import dev.jordond.compass.geolocation.mobile import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class WeatherViewModel( - private val weatherRepository: WeatherRepository + private val weatherRepository: WeatherRepository, + private val dispatcherProvider: DispatcherProvider, + private val geolocator: Geolocator ) : ViewModel() { private val _weatherState = MutableStateFlow(WeatherState()) @@ -31,21 +33,19 @@ class WeatherViewModel( fun checkApiKey(openWeatherApiKey: String) { if (openWeatherApiKey.isEmpty() || openWeatherApiKey == "DEFAULT_API_KEY") { - _weatherState.value = WeatherState( - isLoading = false, weatherForecast = null, errorMessage = "API Key is not set!" - ) + updateErrorMessage(errorMessage = "API Key is not set!") } else { fetchUserLocation() } } - private fun fetchUserLocation() { - viewModelScope.launch { + fun fetchUserLocation() { + viewModelScope.launch(dispatcherProvider.main) { _weatherState.value = WeatherState( isLoading = true, weatherForecast = null, errorMessage = null ) - when (val locationResult = Geolocator.mobile().current(Priority.HighAccuracy)) { + when (val locationResult = geolocator.current(Priority.HighAccuracy)) { is GeolocatorResult.Success -> { val latitude = locationResult.data.coordinates.latitude val longitude = locationResult.data.coordinates.longitude @@ -64,16 +64,14 @@ class WeatherViewModel( else -> "An unknown error occurred while fetching location." } - _weatherState.value = WeatherState( - isLoading = false, weatherForecast = null, errorMessage = errorMessage - ) + updateErrorMessage(errorMessage) } } } } - private fun getWeatherForecast(latitude: Double, longitude: Double) { - viewModelScope.launch { + fun getWeatherForecast(latitude: Double, longitude: Double) { + viewModelScope.launch(dispatcherProvider.main) { weatherRepository.getWeatherForecast( latitude = latitude.toString(), longitude = longitude.toString(), @@ -105,15 +103,15 @@ class WeatherViewModel( } } - private fun updateErrorMessage(errorMessage: String) { - viewModelScope.launch { + fun updateErrorMessage(errorMessage: String) { + viewModelScope.launch(dispatcherProvider.main) { _weatherState.value = WeatherState( isLoading = false, weatherForecast = null, errorMessage = errorMessage ) } } - private fun filterDailyWeather(forecast: WeatherForecastDTO): WeatherForecastDTO { + fun filterDailyWeather(forecast: WeatherForecastDTO): WeatherForecastDTO { val filteredList = forecast.list.filter { weatherItem -> weatherItem.dtTxt.contains("12:00:00") } diff --git a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/DispatcherProvider.kt b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/DispatcherProvider.kt new file mode 100644 index 0000000..fa1bba7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/DispatcherProvider.kt @@ -0,0 +1,26 @@ +package co.ke.weather.multiplatform.utils + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO + +interface DispatcherProvider { + + val main: CoroutineDispatcher + + val io: CoroutineDispatcher + + val default: CoroutineDispatcher +} + +class DefaultDispatcherProvider : DispatcherProvider { + + override val main: CoroutineDispatcher + get() = Dispatchers.Main + + override val io: CoroutineDispatcher + get() = Dispatchers.IO + + override val default: CoroutineDispatcher + get() = Dispatchers.Default +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/GeolocatorProvider.kt b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/GeolocatorProvider.kt new file mode 100644 index 0000000..ce4afbd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/co/ke/weather/multiplatform/utils/GeolocatorProvider.kt @@ -0,0 +1,13 @@ +package co.ke.weather.multiplatform.utils + +import dev.jordond.compass.geolocation.Geolocator +import dev.jordond.compass.geolocation.mobile + +interface GeolocatorProvider { + val mobile: Geolocator +} + +class DefaultGeolocatorProvider : GeolocatorProvider { + + override val mobile: Geolocator = Geolocator.mobile() +} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryTest.kt b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryTest.kt index c224c72..6c3042a 100644 --- a/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/data/repository/WeatherRepositoryTest.kt @@ -1,44 +1,25 @@ +import app.cash.turbine.test import assertk.assertThat import assertk.assertions.isEqualTo -import co.ke.weather.multiplatform.data.model.weather.City -import co.ke.weather.multiplatform.data.model.weather.Coord -import co.ke.weather.multiplatform.data.model.weather.WeatherForecastDTO import co.ke.weather.multiplatform.domain.repository.WeatherRepository import co.ke.weather.multiplatform.utils.NetworkResult +import co.ke.weather.multiplatform.utils.TestData.apiKey +import co.ke.weather.multiplatform.utils.TestData.latitude +import co.ke.weather.multiplatform.utils.TestData.longitude +import co.ke.weather.multiplatform.utils.TestData.weatherForecastDTO +import co.ke.weather.multiplatform.utils.TestData.error import dev.mokkery.answering.returns import dev.mokkery.everySuspend import dev.mokkery.mock -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.test.assertEquals class WeatherRepositoryTest { private val weatherRepository = mock() - private val weatherForecastDTO = WeatherForecastDTO( - city = City( - coord = Coord( - lat = -1.365646, lon = 36.45675 - ), - country = "Kenya", - id = 1, - name = "Kiambu", - population = 3455566, - sunrise = 54633636, - sunset = 456456, - timezone = 554365354 - ), - cnt = 3555, cod = "null", list = emptyList(), message = 200 - ) - - private val error = Exception("Error") - - private val latitude = "-1.365646" - private val longitude = "36.45675" - private val apiKey = "ksnckwnckwnc" - @Test fun getWeatherForecastReturnsSuccess() = runTest { everySuspend { @@ -47,9 +28,10 @@ class WeatherRepositoryTest { ) } returns flowOf(NetworkResult.Success(weatherForecastDTO)) - val result = weatherRepository.getWeatherForecast(latitude, longitude, apiKey).first() - - assertThat(result).isEqualTo(NetworkResult.Success(weatherForecastDTO)) + weatherRepository.getWeatherForecast(latitude, longitude, apiKey).test { + assertEquals(NetworkResult.Success(weatherForecastDTO), awaitItem()) + cancelAndIgnoreRemainingEvents() + } } @Test @@ -60,9 +42,10 @@ class WeatherRepositoryTest { ) } returns flowOf(NetworkResult.ClientError(error)) - val result = weatherRepository.getWeatherForecast(latitude, longitude, apiKey).first() - - assertThat(result).isEqualTo(NetworkResult.ClientError(error)) + weatherRepository.getWeatherForecast(latitude, longitude, apiKey).test { + assertThat(awaitItem()).isEqualTo(NetworkResult.ClientError(error)) + cancelAndIgnoreRemainingEvents() + } } @Test @@ -73,9 +56,10 @@ class WeatherRepositoryTest { ) } returns flowOf(NetworkResult.NetworkError(error)) - val result = weatherRepository.getWeatherForecast(latitude, longitude, apiKey).first() - - assertThat(result).isEqualTo(NetworkResult.NetworkError(error)) + weatherRepository.getWeatherForecast(latitude, longitude, apiKey).test { + assertThat(awaitItem()).isEqualTo(NetworkResult.NetworkError(error)) + cancelAndIgnoreRemainingEvents() + } } @Test @@ -86,8 +70,9 @@ class WeatherRepositoryTest { ) } returns flowOf(NetworkResult.ServerError(error)) - val result = weatherRepository.getWeatherForecast(latitude, longitude, apiKey).first() - - assertThat(result).isEqualTo(NetworkResult.ServerError(error)) + weatherRepository.getWeatherForecast(latitude, longitude, apiKey).test { + assertThat(awaitItem()).isEqualTo(NetworkResult.ServerError(error)) + cancelAndIgnoreRemainingEvents() + } } } \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/ui/WeatherViewModelTest.kt b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/ui/WeatherViewModelTest.kt new file mode 100644 index 0000000..72f06be --- /dev/null +++ b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/ui/WeatherViewModelTest.kt @@ -0,0 +1,99 @@ +package co.ke.weather.multiplatform.ui + +import app.cash.turbine.test +import co.ke.weather.multiplatform.domain.repository.WeatherRepository +import co.ke.weather.multiplatform.ui.viewmodel.WeatherViewModel +import co.ke.weather.multiplatform.utils.DispatcherProvider +import co.ke.weather.multiplatform.utils.TestData.weatherForecastDTO +import co.ke.weather.multiplatform.utils.TestDispatcherProvider +import dev.jordond.compass.Priority +import dev.jordond.compass.geolocation.Geolocator +import dev.jordond.compass.geolocation.GeolocatorResult +import dev.jordond.compass.geolocation.LocationRequest +import dev.jordond.compass.geolocation.TrackingStatus +import dev.mokkery.mock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +class WeatherViewModelTest { + private val weatherRepository = mock() + + private val testDispatcherProvider: DispatcherProvider = TestDispatcherProvider() + private lateinit var weatherViewModel: WeatherViewModel + + private val fakeGeolocator = object : Geolocator { + + override val trackingStatus: Flow + get() = emptyFlow() + + override suspend fun current(priority: Priority): GeolocatorResult { + return GeolocatorResult.NotFound // Simulate not found error + } + + override suspend fun isAvailable(): Boolean { + return true + } + + override fun stopTracking() { + } + + override fun track(request: LocationRequest): Flow { + return emptyFlow() + } + } + + @BeforeTest + fun setUp() { + weatherViewModel = + WeatherViewModel(weatherRepository, testDispatcherProvider, fakeGeolocator) + } + + @Test + fun `checkApiKey should set error message when API key is not set`() = runTest { + val invalidApiKey = "DEFAULT_API_KEY" + + weatherViewModel.checkApiKey(invalidApiKey) + + weatherViewModel.weatherState.test { + val state = awaitItem() + assertEquals("API Key is not set!", state.errorMessage) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `fetchUserLocation should set error message on geolocation error`() = runTest { + weatherViewModel.fetchUserLocation() + + weatherViewModel.weatherState.test { + val state = awaitItem() + assertTrue(state.errorMessage!!.contains("Location not found.")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `updateErrorMessage should update state after invocation`() = runTest { + weatherViewModel.updateErrorMessage("error") + + weatherViewModel.weatherState.test { + val state = awaitItem() + assertTrue(state.errorMessage!!.contains("error")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `filterDailyWeather returns WeatherForecastDTO`() = run { + val result = weatherViewModel.filterDailyWeather(weatherForecastDTO) + + assertEquals(weatherForecastDTO, result) + } +} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestData.kt b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestData.kt new file mode 100644 index 0000000..a8dad54 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestData.kt @@ -0,0 +1,30 @@ +package co.ke.weather.multiplatform.utils + +import co.ke.weather.multiplatform.data.model.weather.City +import co.ke.weather.multiplatform.data.model.weather.Coord +import co.ke.weather.multiplatform.data.model.weather.WeatherForecastDTO + +object TestData { + + val weatherForecastDTO = WeatherForecastDTO( + city = City( + coord = Coord( + lat = -1.365646, lon = 36.45675 + ), + country = "Kenya", + id = 1, + name = "Kiambu", + population = 3455566, + sunrise = 54633636, + sunset = 456456, + timezone = 554365354 + ), + cnt = 3555, cod = "null", list = emptyList(), message = 200 + ) + + val error = Exception("Error") + + val latitude = "-1.365646" + val longitude = "36.45675" + val apiKey = "ksnckwnckwnc" +} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestDispatcherProvider.kt b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestDispatcherProvider.kt new file mode 100644 index 0000000..c36d4c7 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/co/ke/weather/multiplatform/utils/TestDispatcherProvider.kt @@ -0,0 +1,20 @@ +package co.ke.weather.multiplatform.utils + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher + +@ExperimentalCoroutinesApi +class TestDispatcherProvider : DispatcherProvider { + + private val testDispatcher = UnconfinedTestDispatcher() + + override val main: CoroutineDispatcher + get() = testDispatcher + + override val io: CoroutineDispatcher + get() = testDispatcher + + override val default: CoroutineDispatcher + get() = testDispatcher +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 670c209..67d34ea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ compass = "1.2.2" ktor = "2.3.12" assertk = "0.28.1" kotlinx-coroutines-test = "1.8.1" +turbine = "1.2.0" @@ -67,8 +68,10 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor"} +ktor-client-mock = {module="io.ktor:ktor-client-mock", version.ref = "ktor"} assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }