From 655991ca3193b88b1577615fe9235ee6efb6eb30 Mon Sep 17 00:00:00 2001 From: Luka Date: Fri, 26 Jul 2019 09:41:49 +0200 Subject: [PATCH 1/3] Add WeatherViewModel test (failing because of the issue with live data) --- app/build.gradle | 2 + .../test/java/com/cobeisfresh/TestUtils.kt | 6 +++ .../com/cobeisfresh/WeatherViewModelTest.kt | 50 ++++++++++++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/cobeisfresh/TestUtils.kt diff --git a/app/build.gradle b/app/build.gradle index dac3e38..965bf54 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -44,6 +44,8 @@ dependencies { androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockitoVersion" + testImplementation "android.arch.core:core-testing:$architectureComponents" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" //di implementation "org.koin:koin-android:$koinVersion" diff --git a/app/src/test/java/com/cobeisfresh/TestUtils.kt b/app/src/test/java/com/cobeisfresh/TestUtils.kt new file mode 100644 index 0000000..c14bc23 --- /dev/null +++ b/app/src/test/java/com/cobeisfresh/TestUtils.kt @@ -0,0 +1,6 @@ +package com.cobeisfresh + +const val TEMP = 20.0 +const val HUMIDITY = 20 +const val PRESSURE = 1013.30 +const val OSIJEK_CITY_NAME = "Osijek" \ No newline at end of file diff --git a/app/src/test/java/com/cobeisfresh/WeatherViewModelTest.kt b/app/src/test/java/com/cobeisfresh/WeatherViewModelTest.kt index b708bae..ecc9dc4 100644 --- a/app/src/test/java/com/cobeisfresh/WeatherViewModelTest.kt +++ b/app/src/test/java/com/cobeisfresh/WeatherViewModelTest.kt @@ -1,9 +1,57 @@ package com.cobeisfresh +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer +import com.cobeisfresh.template.ui.weather.base.ViewState +import com.cobeisfresh.template.ui.weather.presentation.WeatherViewModel +import com.example.domain.interaction.weather.GetWeatherUseCase +import com.example.domain.model.Success +import com.example.domain.model.WeatherInfo +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule import org.junit.runner.RunWith import org.mockito.junit.MockitoJUnitRunner +import java.util.concurrent.Executors @RunWith(MockitoJUnitRunner::class) class WeatherViewModelTest { - + + private val getWeather: GetWeatherUseCase = mock() + private val weatherViewModel by lazy { WeatherViewModel(getWeather) } + private val fakeMainThread = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + private val fakeObserver: Observer> = mock() + + @get:Rule + val rule: TestRule = InstantTaskExecutorRule() + + @Before + fun setUp() { + Dispatchers.setMain(fakeMainThread) + } + + @Test + fun `test getWeather sets liveData value when success`() = runBlocking { + weatherViewModel.weatherLiveData.observeForever(fakeObserver) + whenever(getWeather(OSIJEK_CITY_NAME)).thenReturn(Success(WeatherInfo(TEMP, HUMIDITY, PRESSURE))) + weatherViewModel.getWeatherForLocation(OSIJEK_CITY_NAME) + assertEquals(ViewState.Status.LOADING, weatherViewModel.weatherLiveData.value?.status) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + fakeMainThread.close() + weatherViewModel.weatherLiveData.removeObserver(fakeObserver) + } } \ No newline at end of file From 7e5196407bece3fe8ab6e9e241f55dd8bddfcedb Mon Sep 17 00:00:00 2001 From: Luka Date: Sat, 27 Jul 2019 23:59:43 +0200 Subject: [PATCH 2/3] Create CoroutineContextProvider, fix failing viewModel test --- app/build.gradle | 3 +- .../main/java/com/cobeisfresh/template/App.kt | 3 +- .../coroutine/CoroutineContextProvider.kt | 16 +++++++++ .../com/cobeisfresh/template/di/AppModule.kt | 8 +++++ .../template/di/PresentationModule.kt | 4 +-- .../weather/presentation/WeatherViewModel.kt | 22 ++++++------ .../ui/weather/view/WeatherActivity.kt | 4 +-- .../com/cobeisfresh/WeatherViewModelTest.kt | 36 +++++-------------- data/build.gradle | 3 +- .../com/example/data/di/RepositoryModule.kt | 1 + domain/build.gradle | 3 +- .../example/domain/di/InteractionModule.kt | 5 ++- 12 files changed, 59 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/cobeisfresh/template/common/coroutine/CoroutineContextProvider.kt create mode 100644 app/src/main/java/com/cobeisfresh/template/di/AppModule.kt diff --git a/app/build.gradle b/app/build.gradle index 965bf54..4fa7575 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -49,7 +49,8 @@ dependencies { //di implementation "org.koin:koin-android:$koinVersion" - implementation "org.koin:koin-android-viewmodel:$koinVersion" + implementation "org.koin:koin-androidx-viewmodel:$koinVersion" + implementation "org.koin:koin-androidx-scope:$koinVersion" //arhitecture components implementation "android.arch.lifecycle:viewmodel:$architectureComponents" diff --git a/app/src/main/java/com/cobeisfresh/template/App.kt b/app/src/main/java/com/cobeisfresh/template/App.kt index 9aeaa22..a14808c 100644 --- a/app/src/main/java/com/cobeisfresh/template/App.kt +++ b/app/src/main/java/com/cobeisfresh/template/App.kt @@ -1,6 +1,7 @@ package com.cobeisfresh.template import android.app.Application +import com.cobeisfresh.template.di.appModule import com.cobeisfresh.template.di.presentationModule import com.example.data.di.databaseModule import com.example.data.di.networkingModule @@ -25,6 +26,6 @@ class App : Application() { } } -val appModules = listOf(presentationModule) +val appModules = listOf(presentationModule, appModule) val domainModules = listOf(interactionModule) val dataModules = listOf(networkingModule, repositoryModule, databaseModule) diff --git a/app/src/main/java/com/cobeisfresh/template/common/coroutine/CoroutineContextProvider.kt b/app/src/main/java/com/cobeisfresh/template/common/coroutine/CoroutineContextProvider.kt new file mode 100644 index 0000000..0138789 --- /dev/null +++ b/app/src/main/java/com/cobeisfresh/template/common/coroutine/CoroutineContextProvider.kt @@ -0,0 +1,16 @@ +package com.cobeisfresh.template.common.coroutine + +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +open class CoroutineContextProvider { + open val main: CoroutineContext by lazy { Dispatchers.Main } + open val io: CoroutineContext by lazy { Dispatchers.IO } + open val default: CoroutineContext by lazy { Dispatchers.Default } +} + +class TestCoroutineContextProvider : CoroutineContextProvider() { + override val main: CoroutineContext = Dispatchers.Unconfined + override val io: CoroutineContext = Dispatchers.Unconfined + override val default: CoroutineContext = Dispatchers.Unconfined +} \ No newline at end of file diff --git a/app/src/main/java/com/cobeisfresh/template/di/AppModule.kt b/app/src/main/java/com/cobeisfresh/template/di/AppModule.kt new file mode 100644 index 0000000..2e70068 --- /dev/null +++ b/app/src/main/java/com/cobeisfresh/template/di/AppModule.kt @@ -0,0 +1,8 @@ +package com.cobeisfresh.template.di + +import com.cobeisfresh.template.common.coroutine.CoroutineContextProvider +import org.koin.dsl.module + +val appModule = module { + single { CoroutineContextProvider() } +} \ No newline at end of file diff --git a/app/src/main/java/com/cobeisfresh/template/di/PresentationModule.kt b/app/src/main/java/com/cobeisfresh/template/di/PresentationModule.kt index d68acf2..b25ffe9 100644 --- a/app/src/main/java/com/cobeisfresh/template/di/PresentationModule.kt +++ b/app/src/main/java/com/cobeisfresh/template/di/PresentationModule.kt @@ -1,9 +1,9 @@ package com.cobeisfresh.template.di import com.cobeisfresh.template.ui.weather.presentation.WeatherViewModel -import org.koin.android.viewmodel.dsl.viewModel +import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module val presentationModule = module { - viewModel { WeatherViewModel(get()) } + viewModel { WeatherViewModel(get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/com/cobeisfresh/template/ui/weather/presentation/WeatherViewModel.kt b/app/src/main/java/com/cobeisfresh/template/ui/weather/presentation/WeatherViewModel.kt index ee0a975..774cbd2 100644 --- a/app/src/main/java/com/cobeisfresh/template/ui/weather/presentation/WeatherViewModel.kt +++ b/app/src/main/java/com/cobeisfresh/template/ui/weather/presentation/WeatherViewModel.kt @@ -5,28 +5,28 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cobeisfresh.template.common.DEFAULT_CITY_NAME +import com.cobeisfresh.template.common.coroutine.CoroutineContextProvider import com.cobeisfresh.template.ui.weather.base.ViewState import com.example.domain.interaction.weather.GetWeatherUseCase import com.example.domain.model.WeatherInfo import com.example.domain.model.onFailure import com.example.domain.model.onSuccess -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import org.koin.core.KoinComponent -class WeatherViewModel(private val getWeather: GetWeatherUseCase) : ViewModel() { +class WeatherViewModel(private val getWeather: GetWeatherUseCase, + private val coroutineContextProvider: CoroutineContextProvider) : ViewModel(), KoinComponent { // we make this private and provide only immutable live data to observers so they can't change anything private val _weatherLiveData = MutableLiveData>() val weatherLiveData: LiveData> get() = _weatherLiveData - fun getWeatherForLocation(location: String = DEFAULT_CITY_NAME) = viewModelScope.launch { - _weatherLiveData.value = ViewState.loading() - withContext(Dispatchers.IO) { - getWeather(location) - .onSuccess { _weatherLiveData.postValue(ViewState.success(it)) } - .onFailure { _weatherLiveData.postValue(ViewState.error(it.throwable)) } - } - } + fun getWeatherForLocation(location: String = DEFAULT_CITY_NAME) = + viewModelScope.launch(coroutineContextProvider.main) { + _weatherLiveData.value = ViewState.loading() + getWeather(location) + .onSuccess { _weatherLiveData.value = ViewState.success(it) } + .onFailure { _weatherLiveData.value = ViewState.error(it.throwable) } + } } diff --git a/app/src/main/java/com/cobeisfresh/template/ui/weather/view/WeatherActivity.kt b/app/src/main/java/com/cobeisfresh/template/ui/weather/view/WeatherActivity.kt index 5a100de..7b53202 100644 --- a/app/src/main/java/com/cobeisfresh/template/ui/weather/view/WeatherActivity.kt +++ b/app/src/main/java/com/cobeisfresh/template/ui/weather/view/WeatherActivity.kt @@ -3,7 +3,6 @@ package com.cobeisfresh.template.ui.weather.view import android.content.Context import android.os.Bundle import android.view.inputmethod.InputMethodManager -import androidx.lifecycle.Observer import com.cobeisfresh.template.R import com.cobeisfresh.template.common.convertKelvinToCelsius import com.cobeisfresh.template.common.extensions.onClick @@ -14,10 +13,9 @@ import com.cobeisfresh.template.ui.weather.base.ViewState.Status.* import com.cobeisfresh.template.ui.weather.presentation.WeatherViewModel import com.example.domain.model.WeatherInfo import kotlinx.android.synthetic.main.activity_weather.* -import org.koin.android.viewmodel.ext.android.viewModel +import org.koin.androidx.viewmodel.ext.android.viewModel class WeatherActivity : BaseActivity() { - private val viewModel: WeatherViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/test/java/com/cobeisfresh/WeatherViewModelTest.kt b/app/src/test/java/com/cobeisfresh/WeatherViewModelTest.kt index ecc9dc4..51de1f5 100644 --- a/app/src/test/java/com/cobeisfresh/WeatherViewModelTest.kt +++ b/app/src/test/java/com/cobeisfresh/WeatherViewModelTest.kt @@ -1,7 +1,7 @@ package com.cobeisfresh import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.Observer +import com.cobeisfresh.template.common.coroutine.TestCoroutineContextProvider import com.cobeisfresh.template.ui.weather.base.ViewState import com.cobeisfresh.template.ui.weather.presentation.WeatherViewModel import com.example.domain.interaction.weather.GetWeatherUseCase @@ -9,49 +9,29 @@ import com.example.domain.model.Success import com.example.domain.model.WeatherInfo import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule -import org.junit.runner.RunWith -import org.mockito.junit.MockitoJUnitRunner -import java.util.concurrent.Executors +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule -@RunWith(MockitoJUnitRunner::class) class WeatherViewModelTest { private val getWeather: GetWeatherUseCase = mock() - private val weatherViewModel by lazy { WeatherViewModel(getWeather) } - private val fakeMainThread = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - private val fakeObserver: Observer> = mock() + private val coroutineContext = TestCoroutineContextProvider() + private val weatherViewModel by lazy { WeatherViewModel(getWeather, coroutineContext) } @get:Rule val rule: TestRule = InstantTaskExecutorRule() - - @Before - fun setUp() { - Dispatchers.setMain(fakeMainThread) - } + @get:Rule + val mockitoRule: MockitoRule = MockitoJUnit.rule() @Test fun `test getWeather sets liveData value when success`() = runBlocking { - weatherViewModel.weatherLiveData.observeForever(fakeObserver) whenever(getWeather(OSIJEK_CITY_NAME)).thenReturn(Success(WeatherInfo(TEMP, HUMIDITY, PRESSURE))) weatherViewModel.getWeatherForLocation(OSIJEK_CITY_NAME) - assertEquals(ViewState.Status.LOADING, weatherViewModel.weatherLiveData.value?.status) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - fakeMainThread.close() - weatherViewModel.weatherLiveData.removeObserver(fakeObserver) + assertEquals(ViewState.Status.SUCCESS, weatherViewModel.weatherLiveData.value?.status) } } \ No newline at end of file diff --git a/data/build.gradle b/data/build.gradle index 248adb4..8c76763 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -52,5 +52,6 @@ dependencies { //di implementation "org.koin:koin-android:$koinVersion" - implementation "org.koin:koin-android-viewmodel:$koinVersion" + implementation "org.koin:koin-androidx-viewmodel:$koinVersion" + implementation "org.koin:koin-androidx-scope:$koinVersion" } diff --git a/data/src/main/java/com/example/data/di/RepositoryModule.kt b/data/src/main/java/com/example/data/di/RepositoryModule.kt index 7a9e7ae..8d1bdce 100644 --- a/data/src/main/java/com/example/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/example/data/di/RepositoryModule.kt @@ -5,5 +5,6 @@ import com.example.domain.repository.WeatherRepository import org.koin.dsl.module val repositoryModule = module { + //TODO scope this to weather viewmodel single { WeatherRepositoryImpl(get(), get()) } } \ No newline at end of file diff --git a/domain/build.gradle b/domain/build.gradle index 94d6823..7c96c6f 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -47,5 +47,6 @@ dependencies { //di implementation "org.koin:koin-android:$koinVersion" - implementation "org.koin:koin-android-viewmodel:$koinVersion" + implementation "org.koin:koin-androidx-viewmodel:$koinVersion" + implementation "org.koin:koin-androidx-scope:$koinVersion" } diff --git a/domain/src/main/java/com/example/domain/di/InteractionModule.kt b/domain/src/main/java/com/example/domain/di/InteractionModule.kt index 51718e9..2a6768f 100644 --- a/domain/src/main/java/com/example/domain/di/InteractionModule.kt +++ b/domain/src/main/java/com/example/domain/di/InteractionModule.kt @@ -2,8 +2,11 @@ package com.example.domain.di import com.example.domain.interaction.weather.GetWeatherUseCase import com.example.domain.interaction.weather.GetWeatherUseCaseImpl +import org.koin.core.qualifier.named import org.koin.dsl.module val interactionModule = module { factory { GetWeatherUseCaseImpl(get()) } -} \ No newline at end of file +} + +const val WEATHER_SCOPE = "weather_scope" \ No newline at end of file From 514d2b8ee59d719bf158cdd810069d640d676b0c Mon Sep 17 00:00:00 2001 From: Luka Date: Mon, 29 Jul 2019 13:12:48 +0200 Subject: [PATCH 3/3] Remove unused scope strings --- app/src/main/java/com/cobeisfresh/template/App.kt | 3 +++ .../cobeisfresh/template/ui/weather/view/WeatherActivity.kt | 1 + data/src/main/java/com/example/data/di/RepositoryModule.kt | 3 +-- .../src/main/java/com/example/domain/di/InteractionModule.kt | 3 --- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/cobeisfresh/template/App.kt b/app/src/main/java/com/cobeisfresh/template/App.kt index a14808c..4403b8b 100644 --- a/app/src/main/java/com/cobeisfresh/template/App.kt +++ b/app/src/main/java/com/cobeisfresh/template/App.kt @@ -8,7 +8,9 @@ import com.example.data.di.networkingModule import com.example.data.di.repositoryModule import com.example.domain.di.interactionModule import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin +import org.koin.core.logger.Level class App : Application() { @@ -21,6 +23,7 @@ class App : Application() { startKoin { androidContext(this@App) + if (BuildConfig.DEBUG) androidLogger(Level.DEBUG) modules(appModules + domainModules + dataModules) } } diff --git a/app/src/main/java/com/cobeisfresh/template/ui/weather/view/WeatherActivity.kt b/app/src/main/java/com/cobeisfresh/template/ui/weather/view/WeatherActivity.kt index 7b53202..29f3689 100644 --- a/app/src/main/java/com/cobeisfresh/template/ui/weather/view/WeatherActivity.kt +++ b/app/src/main/java/com/cobeisfresh/template/ui/weather/view/WeatherActivity.kt @@ -24,6 +24,7 @@ class WeatherActivity : BaseActivity() { viewModel.getWeatherForLocation() subscribeToData() + getWeather.onClick { val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(weatherActivityContainer.windowToken, InputMethodManager.HIDE_NOT_ALWAYS) diff --git a/data/src/main/java/com/example/data/di/RepositoryModule.kt b/data/src/main/java/com/example/data/di/RepositoryModule.kt index 8d1bdce..666713f 100644 --- a/data/src/main/java/com/example/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/example/data/di/RepositoryModule.kt @@ -5,6 +5,5 @@ import com.example.domain.repository.WeatherRepository import org.koin.dsl.module val repositoryModule = module { - //TODO scope this to weather viewmodel - single { WeatherRepositoryImpl(get(), get()) } + factory { WeatherRepositoryImpl(get(), get()) } } \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/di/InteractionModule.kt b/domain/src/main/java/com/example/domain/di/InteractionModule.kt index 2a6768f..862ec76 100644 --- a/domain/src/main/java/com/example/domain/di/InteractionModule.kt +++ b/domain/src/main/java/com/example/domain/di/InteractionModule.kt @@ -2,11 +2,8 @@ package com.example.domain.di import com.example.domain.interaction.weather.GetWeatherUseCase import com.example.domain.interaction.weather.GetWeatherUseCaseImpl -import org.koin.core.qualifier.named import org.koin.dsl.module val interactionModule = module { factory { GetWeatherUseCaseImpl(get()) } } - -const val WEATHER_SCOPE = "weather_scope" \ No newline at end of file