diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index bb44937..c22b6fa 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/app/src/main/java/com/santimattius/template/di/AppModule.kt b/app/src/main/java/com/santimattius/template/di/AppModule.kt index e48fa76..9280156 100644 --- a/app/src/main/java/com/santimattius/template/di/AppModule.kt +++ b/app/src/main/java/com/santimattius/template/di/AppModule.kt @@ -5,28 +5,33 @@ import com.santimattius.core.data.client.database.TheMovieDataBase import com.santimattius.core.data.client.network.RetrofitServiceCreator import com.santimattius.core.data.client.network.TheMovieDBService import com.santimattius.template.BuildConfig -import org.koin.core.annotation.Factory import org.koin.core.annotation.Module +import org.koin.core.annotation.Named import org.koin.core.annotation.Singleton @Module class AppModule { - @Factory + @Singleton fun provideAppDatabase(context: Context): TheMovieDataBase = TheMovieDataBase.get(context) - @Factory + @Singleton fun provideMovieDBService(serviceCreator: RetrofitServiceCreator): TheMovieDBService = serviceCreator.createService(TheMovieDBService::class.java) @Singleton - fun provideRetrofit(): RetrofitServiceCreator { + fun provideRetrofit(@Named("base_url") baseUrl: String): RetrofitServiceCreator { return RetrofitServiceCreator( - baseUrl = "https://api.themoviedb.org", + baseUrl = baseUrl, apiKey = BuildConfig.apiKey ) } + + @Named("base_url") + @Singleton + @Suppress("FunctionOnlyReturningConstant") + fun provideBaseUrl() = "https://api.themoviedb.org" } \ No newline at end of file diff --git a/app/src/test/java/com/santimattius/template/ui/compose/HomeComposeViewModelDiTest.kt b/app/src/test/java/com/santimattius/template/ui/compose/HomeComposeViewModelDiTest.kt new file mode 100644 index 0000000..cfcaddd --- /dev/null +++ b/app/src/test/java/com/santimattius/template/ui/compose/HomeComposeViewModelDiTest.kt @@ -0,0 +1,130 @@ +package com.santimattius.template.ui.compose + +import android.app.Application +import android.os.Build +import androidx.lifecycle.viewmodel.testing.viewModelScenario +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.test +import com.santimattius.core.data.client.database.TheMovieDataBase +import com.santimattius.template.ui.compose.models.Messages +import com.santimattius.template.ui.models.MovieUiModel +import com.santimattius.template.ui.rules.RoomTestRule +import com.santimattius.template.ui.rules.loadModule +import com.santimattius.test.data.MovieMother +import com.santimattius.test.data.TheMovieDBServiceMother +import com.santimattius.test.rules.MainCoroutinesTestRule +import com.santimattius.test.rules.MockWebServerRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.koin.ksp.generated.com_santimattius_template_di_AppModule +import org.koin.ksp.generated.com_santimattius_template_di_DataModule +import org.koin.ksp.generated.defaultModule +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule +import org.koin.test.get +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config( + manifest = Config.NONE, + sdk = [Build.VERSION_CODES.R], + instrumentedPackages = ["androidx.loader.content"], + application = Application::class +) +class HomeComposeViewModelDiTest : KoinTest { + + private val appContext = ApplicationProvider.getApplicationContext() + + @get:Rule(order = 0) + val mockWebServerRule = MockWebServerRule { + TheMovieDBServiceMother.createPopularMovieResponse() + } + + @get:Rule(order = 1) + val roomTestRule = RoomTestRule(appContext, TheMovieDataBase::class.java) + + + @get:Rule(order = 2) + var koinTestRule = KoinTestRule.create { + modules( + com_santimattius_template_di_DataModule, + com_santimattius_template_di_AppModule, + defaultModule, + ) + } + + + @get:Rule(order = 3) + val mainCoroutinesTestRule = MainCoroutinesTestRule() + + @Before + fun setUp() { + koinTestRule.loadModule( + module { + //override base url and database + factory(named("base_url")) { mockWebServerRule.baseUrl } + factory { roomTestRule.db } + } + ) + } + + @Test + fun `onFavorite should update state with success message when adding to favorite succeeds`() { + viewModelScenario { HomeComposeViewModel(movieRepository = get()) }.use { + val dto = MovieMother.createMovie() + val movie = MovieUiModel( + id = dto.id, + title = dto.title, + imageUrl = dto.poster, + favorite = false + ) + val viewModel = it.viewModel + + runTest(mainCoroutinesTestRule.testDispatcher) { + viewModel.onFavorite(movie) + viewModel.state.test { + awaitItem() + assertEquals( + Messages.Success("Add ${movie.title} to favorite"), + awaitItem().successMessages + ) + cancelAndIgnoreRemainingEvents() + } + } + } + } + + @Test + fun `onFavorite should update state with success message when removing from favorite succeeds`() { + viewModelScenario { HomeComposeViewModel(movieRepository = get()) }.use { + val dto = MovieMother.createMovie() + val movie = MovieUiModel( + id = dto.id, + title = dto.title, + imageUrl = dto.poster, + favorite = true + ) + val viewModel = it.viewModel + runTest(mainCoroutinesTestRule.testDispatcher) { + viewModel.onFavorite(movie) + viewModel.state.test { + awaitItem() + assertEquals( + Messages.Success("Remove ${movie.title} from favorite"), + awaitItem().successMessages + ) + cancelAndIgnoreRemainingEvents() + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/santimattius/template/ui/compose/HomeComposeViewModelTest.kt b/app/src/test/java/com/santimattius/template/ui/compose/HomeComposeViewModelTest.kt new file mode 100644 index 0000000..617f610 --- /dev/null +++ b/app/src/test/java/com/santimattius/template/ui/compose/HomeComposeViewModelTest.kt @@ -0,0 +1,127 @@ +package com.santimattius.template.ui.compose + +import android.app.Application +import android.os.Build +import androidx.lifecycle.viewmodel.testing.viewModelScenario +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.test +import com.santimattius.core.data.client.database.TheMovieDataBase +import com.santimattius.core.data.client.network.RetrofitServiceCreator +import com.santimattius.core.data.client.network.TheMovieDBService +import com.santimattius.core.data.datasources.implementation.RetrofitMovieNetworkDataSource +import com.santimattius.core.data.datasources.implementation.RoomMovieLocalDataSource +import com.santimattius.core.data.repositories.TMDbRepository +import com.santimattius.template.ui.compose.models.Messages +import com.santimattius.template.ui.models.MovieUiModel +import com.santimattius.template.ui.rules.RoomTestRule +import com.santimattius.template.ui.rules.loadModule +import com.santimattius.test.data.MovieMother +import com.santimattius.test.data.TheMovieDBServiceMother +import com.santimattius.test.rules.MainCoroutinesTestRule +import com.santimattius.test.rules.MockWebServerRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.koin.ksp.generated.com_santimattius_template_di_AppModule +import org.koin.ksp.generated.com_santimattius_template_di_DataModule +import org.koin.ksp.generated.defaultModule +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule +import org.koin.test.get +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config( + manifest = Config.NONE, + sdk = [Build.VERSION_CODES.R], + instrumentedPackages = ["androidx.loader.content"], + application = Application::class +) +class HomeComposeViewModelTest { + + private lateinit var movieRepository: TMDbRepository + private val appContext = ApplicationProvider.getApplicationContext() + + @get:Rule(order = 0) + val mockWebServerRule = MockWebServerRule { + TheMovieDBServiceMother.createPopularMovieResponse() + } + + @get:Rule(order = 1) + val roomTestRule = RoomTestRule(appContext, TheMovieDataBase::class.java) + + + @get:Rule(order = 2) + val mainCoroutinesTestRule = MainCoroutinesTestRule() + + @Before + fun setUp() { + val movieLocalDataSource = RoomMovieLocalDataSource(roomTestRule.db) + val serviceCreator = RetrofitServiceCreator(mockWebServerRule.baseUrl, "test") + val movieNetworkDataSource = RetrofitMovieNetworkDataSource( + serviceCreator.createService( + TheMovieDBService::class.java + ) + ) + movieRepository = TMDbRepository(movieNetworkDataSource, movieLocalDataSource) + } + + @Test + fun `onFavorite should update state with success message when adding to favorite succeeds`() { + viewModelScenario { HomeComposeViewModel(movieRepository = movieRepository) }.use { + val dto = MovieMother.createMovie() + val movie = MovieUiModel( + id = dto.id, + title = dto.title, + imageUrl = dto.poster, + favorite = false + ) + val viewModel = it.viewModel + + runTest(mainCoroutinesTestRule.testDispatcher) { + viewModel.onFavorite(movie) + viewModel.state.test { + awaitItem() + assertEquals( + Messages.Success("Add ${movie.title} to favorite"), + awaitItem().successMessages + ) + cancelAndIgnoreRemainingEvents() + } + } + } + } + + @Test + fun `onFavorite should update state with success message when removing from favorite succeeds`() { + viewModelScenario { HomeComposeViewModel(movieRepository = movieRepository) }.use { + val dto = MovieMother.createMovie() + val movie = MovieUiModel( + id = dto.id, + title = dto.title, + imageUrl = dto.poster, + favorite = true + ) + val viewModel = it.viewModel + runTest(mainCoroutinesTestRule.testDispatcher) { + viewModel.onFavorite(movie) + viewModel.state.test { + awaitItem() + assertEquals( + Messages.Success("Remove ${movie.title} from favorite"), + awaitItem().successMessages + ) + cancelAndIgnoreRemainingEvents() + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/santimattius/template/ui/rules/KoinTestRule.kt b/app/src/test/java/com/santimattius/template/ui/rules/KoinTestRule.kt new file mode 100644 index 0000000..aa62f76 --- /dev/null +++ b/app/src/test/java/com/santimattius/template/ui/rules/KoinTestRule.kt @@ -0,0 +1,50 @@ +package com.santimattius.template.ui.rules + +import org.koin.core.module.Module +import org.koin.test.KoinTestRule + +/** + * Loads a single Koin [Module] into the Koin container within the scope of this [KoinTestRule]. + * + * This function simplifies the process of loading a module specifically for testing purposes. + * It delegates the actual loading process to Koin's `loadModules` function. + * + * @param module The Koin [Module] to be loaded. + * @param allowOverride If `true`, allows definitions in the loaded module to override + * existing definitions. Defaults to `true`. + * @param createEagerInstances If `true`, forces the creation of all eager instances + * defined in the loaded module. Defaults to `false`. + * + * @see org.koin.core.Koin.loadModules + * @see org.koin.core.module.Module + * @see org.koin.test.KoinTestRule + * + * Example Usage: + * ``` + * val myModule = module { + * single { MyDependency() } + * } + * + * @get:Rule + * val koinTestRule = KoinTestRule.create { + * // ... other configurations + * } + * + * @Test + * fun testMyComponent() { + * koinTestRule.loadModule(myModule) + * // ... now the module is loaded and ready for usage + * } + * ``` + */ +fun KoinTestRule.loadModule( + module: Module, + allowOverride: Boolean = true, + createEagerInstances: Boolean = false +) { + koin.loadModules( + modules = listOf(module), + allowOverride = allowOverride, + createEagerInstances = createEagerInstances + ) +} \ No newline at end of file diff --git a/app/src/test/java/com/santimattius/template/ui/rules/RoomTestRule.kt b/app/src/test/java/com/santimattius/template/ui/rules/RoomTestRule.kt new file mode 100644 index 0000000..7b8ca10 --- /dev/null +++ b/app/src/test/java/com/santimattius/template/ui/rules/RoomTestRule.kt @@ -0,0 +1,34 @@ +package com.santimattius.template.ui.rules + +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * A JUnit TestRule for testing Room database interactions. + * + * This rule provides an in-memory instance of a Room database for testing purposes. + * It handles the setup and teardown of the database, ensuring a clean state for each test. + * + * @param appContext The application context used to build the in-memory database. + * @param klass The class of the Room database to be tested (e.g., `MyDatabase::class.java`). + * @param T The type of the Room database, which must extend [RoomDatabase]. + */ +class RoomTestRule( + private val appContext: Context, + private val klass: Class +) : TestWatcher() { + + lateinit var db: T + + override fun starting(description: Description?) { + db = Room.inMemoryDatabaseBuilder(appContext, klass) + .allowMainThreadQueries().build() + } + + override fun finished(description: Description?) { + db.close() + } +} \ No newline at end of file diff --git a/core/src/main/java/com/santimattius/core/data/client/database/MovieDao.kt b/core/src/main/java/com/santimattius/core/data/client/database/MovieDao.kt index cf0b41b..7c1b71f 100644 --- a/core/src/main/java/com/santimattius/core/data/client/database/MovieDao.kt +++ b/core/src/main/java/com/santimattius/core/data/client/database/MovieDao.kt @@ -7,7 +7,6 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Update -import androidx.room.Upsert import com.santimattius.core.data.models.MovieEntity import kotlinx.coroutines.flow.Flow @@ -36,10 +35,10 @@ interface MovieDao { suspend fun update(movies: MovieEntity) @Query("UPDATE movie SET favorite = 1 WHERE id = :movieId") - suspend fun addToFavorite(movieId: Long) + suspend fun addToFavorite(movieId: Long): Int @Query("UPDATE movie SET favorite = 0 WHERE id = :movieId") - suspend fun removeFromFavorite(movieId: Long) + suspend fun removeFromFavorite(movieId: Long): Int @Query( """ diff --git a/shared-test/src/main/java/com/santimattius/test/rules/MockWebServerRule.kt b/shared-test/src/main/java/com/santimattius/test/rules/MockWebServerRule.kt index 51e72dc..d9eef1e 100644 --- a/shared-test/src/main/java/com/santimattius/test/rules/MockWebServerRule.kt +++ b/shared-test/src/main/java/com/santimattius/test/rules/MockWebServerRule.kt @@ -7,7 +7,8 @@ import org.junit.runner.Description class MockWebServerRule( private val server: MockWebServer = MockWebServer(), - private val port: Int = 0 + private val port: Int = 0, + private val initialResponse: (() -> MockResponse)? = null ) : TestWatcher() { val baseUrl: String @@ -17,6 +18,7 @@ class MockWebServerRule( override fun starting(description: Description) { super.starting(description) server.start(port) + initialResponse?.let { server.enqueue(it.invoke()) } } override fun finished(description: Description) {