From 53c27c4ea2a7a4149c01d10cbe327260649fb3f1 Mon Sep 17 00:00:00 2001 From: Arga Hutama Date: Thu, 11 Jun 2026 17:57:14 +0700 Subject: [PATCH 1/3] migrate CI from CircleCI to GitHub Actions --- .circleci/config.yml | 32 ------------------------ .github/workflows/build.yml | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 32 deletions(-) delete mode 100644 .circleci/config.yml create mode 100644 .github/workflows/build.yml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index b2f59d4..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,32 +0,0 @@ -version: 2.1 - -orbs: - android: circleci/android@0.2.1 - -jobs: - build: - executor: android/android - steps: - - checkout - - restore_cache: - key: android-orb-v1- - - run: - name: Chmod permissions - command: sudo chmod +x ./gradlew - - run: - name: Download Dependencies - command: ./gradlew androidDependencies - - save_cache: - key: 'android-orb-v1-{{ epoch }}' - paths: - - ~/.android/build-cache - - ~/.android/cache - - run: - name: Run Build - command: ./gradlew build - - store_artifacts: - path: app/build/reports - destination: reports - - store_artifacts: - path: app/build/outputs/apk/debug/ - destination: artifact-file \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..0d4d7f0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,49 @@ +name: Android CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew build + + - name: Upload build reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: build-reports + path: app/build/reports + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: app/build/outputs/apk/debug/ From 682166ae6f2a1fae22fb1273575db4f4684749ac Mon Sep 17 00:00:00 2001 From: Arga Hutama Date: Thu, 11 Jun 2026 18:48:04 +0700 Subject: [PATCH 2/3] add unit tests and update build configuration - Add unit tests for `MovieRepository`, `LocalDataSource`, `RemoteDataSource`, `DataMapper`, and `MovieInteractor`. - Add unit tests for `MainViewModel`, `DetailViewModel`, `MovieViewModel`, `TvShowViewModel`, and `FavoriteViewModel`. - Update `libs.versions.toml` with `mockk`, `turbine`, and `kotlinx-coroutines-test` dependencies. - Update `build.gradle` in `app`, `core`, and `favorite` modules to include test dependencies. - Configure GitHub Actions to run unit tests and upload test reports. - Remove unused legacy support, espresso, and cardview version references. --- .github/workflows/build.yml | 13 ++ app/build.gradle | 5 + .../made/detail/DetailViewModelTest.kt | 55 +++++ .../submission/made/main/MainViewModelTest.kt | 128 +++++++++++ .../made/movie/MovieViewModelTest.kt | 75 +++++++ .../made/tvshow/TvShowViewModelTest.kt | 76 +++++++ core/build.gradle | 5 + .../core/data/MovieRepositoryTest.kt | 199 ++++++++++++++++++ .../data/source/local/LocalDataSourceTest.kt | 130 ++++++++++++ .../source/remote/RemoteDataSourceTest.kt | 110 ++++++++++ .../domain/usecase/MovieInteractorTest.kt | 106 ++++++++++ .../submission/core/util/DataMapperTest.kt | 162 ++++++++++++++ favorite/build.gradle | 5 + .../favorite/FavoriteViewModelTest.kt | 112 ++++++++++ gradle/libs.versions.toml | 11 +- 15 files changed, 1186 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/com/argahutama/submission/made/detail/DetailViewModelTest.kt create mode 100644 app/src/test/java/com/argahutama/submission/made/main/MainViewModelTest.kt create mode 100644 app/src/test/java/com/argahutama/submission/made/movie/MovieViewModelTest.kt create mode 100644 app/src/test/java/com/argahutama/submission/made/tvshow/TvShowViewModelTest.kt create mode 100644 core/src/test/java/com/argahutama/submission/core/data/MovieRepositoryTest.kt create mode 100644 core/src/test/java/com/argahutama/submission/core/data/source/local/LocalDataSourceTest.kt create mode 100644 core/src/test/java/com/argahutama/submission/core/data/source/remote/RemoteDataSourceTest.kt create mode 100644 core/src/test/java/com/argahutama/submission/core/domain/usecase/MovieInteractorTest.kt create mode 100644 core/src/test/java/com/argahutama/submission/core/util/DataMapperTest.kt create mode 100644 favorite/src/test/java/com/argahutama/submission/favorite/FavoriteViewModelTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d4d7f0..5e62f36 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,9 +32,22 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Run unit tests + run: ./gradlew testDebugUnitTest + - name: Build with Gradle run: ./gradlew build + - name: Upload test reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-reports + path: | + app/build/reports/tests/ + core/build/reports/tests/ + favorite/build/reports/tests/ + - name: Upload build reports uses: actions/upload-artifact@v4 if: always() diff --git a/app/build.gradle b/app/build.gradle index 9fa75b8..ea7528d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -67,4 +67,9 @@ dependencies { debugImplementation libs.pluto.network releaseImplementation libs.pluto.network.noop debugImplementation libs.leakcanary + + testImplementation libs.junit + testImplementation libs.mockk + testImplementation libs.turbine + testImplementation libs.kotlinx.coroutines.test } diff --git a/app/src/test/java/com/argahutama/submission/made/detail/DetailViewModelTest.kt b/app/src/test/java/com/argahutama/submission/made/detail/DetailViewModelTest.kt new file mode 100644 index 0000000..ff5b712 --- /dev/null +++ b/app/src/test/java/com/argahutama/submission/made/detail/DetailViewModelTest.kt @@ -0,0 +1,55 @@ +package com.argahutama.submission.made.detail + +import com.argahutama.submission.core.domain.model.Movie +import com.argahutama.submission.core.domain.usecase.MovieUseCase +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class DetailViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var useCase: MovieUseCase + private lateinit var viewModel: DetailViewModel + + private val movie = Movie(1, "Overview", "en", "2024-01-01", 9.5, 7.8, "Movie", 100, "/poster.jpg") + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + useCase = mockk() + viewModel = DetailViewModel(useCase) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `setFavoriteMovie marks movie as favorite`() { + every { useCase.setMovieFavorite(movie, true) } returns Unit + + viewModel.setFavoriteMovie(movie, true) + + verify(exactly = 1) { useCase.setMovieFavorite(movie, true) } + } + + @Test + fun `setFavoriteMovie removes movie from favorites`() { + every { useCase.setMovieFavorite(movie, false) } returns Unit + + viewModel.setFavoriteMovie(movie, false) + + verify(exactly = 1) { useCase.setMovieFavorite(movie, false) } + } +} diff --git a/app/src/test/java/com/argahutama/submission/made/main/MainViewModelTest.kt b/app/src/test/java/com/argahutama/submission/made/main/MainViewModelTest.kt new file mode 100644 index 0000000..b64279d --- /dev/null +++ b/app/src/test/java/com/argahutama/submission/made/main/MainViewModelTest.kt @@ -0,0 +1,128 @@ +package com.argahutama.submission.made.main + +import com.argahutama.submission.core.domain.model.Movie +import com.argahutama.submission.core.domain.usecase.MovieUseCase +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +class MainViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var useCase: MovieUseCase + + private val movie = Movie(1, "Overview", "en", "2024-01-01", 9.5, 7.8, "Batman", 100, "/poster.jpg") + private val tvShow = Movie(2, "TV Overview", "ko", "2023-05-10", 8.0, 8.5, "Breaking Bad", 200, "/tv.jpg", isTvShows = true) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + useCase = mockk() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `searchQuery initial value is empty string`() { + every { useCase.searchMovies(any()) } returns flowOf(emptyList()) + every { useCase.searchTvShows(any()) } returns flowOf(emptyList()) + val vm = MainViewModel(useCase) + assertEquals("", vm.searchQuery.value) + } + + @Test + fun `setSearchQuery updates searchQuery state`() { + every { useCase.searchMovies(any()) } returns flowOf(emptyList()) + every { useCase.searchTvShows(any()) } returns flowOf(emptyList()) + val vm = MainViewModel(useCase) + + vm.setSearchQuery("Batman") + + assertEquals("Batman", vm.searchQuery.value) + } + + @Test + fun `movieResult emits empty list when query is blank`() = runTest(testDispatcher) { + every { useCase.searchMovies(any()) } returns flowOf(emptyList()) + every { useCase.searchTvShows(any()) } returns flowOf(emptyList()) + val vm = MainViewModel(useCase) + + val collected = mutableListOf>() + val job = launch { vm.movieResult.collect { collected.add(it) } } + advanceTimeBy(301) + advanceUntilIdle() + job.cancel() + + assertTrue(collected.all { it.isEmpty() }) + } + + @Test + fun `movieResult emits search results after debounce`() = runTest(testDispatcher) { + val results = listOf(movie) + every { useCase.searchMovies("Batman") } returns flowOf(results) + every { useCase.searchTvShows(any()) } returns flowOf(emptyList()) + val vm = MainViewModel(useCase) + + val collected = mutableListOf>() + val job = launch { vm.movieResult.collect { collected.add(it) } } + + vm.setSearchQuery("Batman") + advanceTimeBy(301) + advanceUntilIdle() + job.cancel() + + assertTrue(collected.any { it.isNotEmpty() }) + assertEquals(results, collected.last()) + } + + @Test + fun `movieResult emits empty list when query changes back to blank`() = runTest(testDispatcher) { + every { useCase.searchMovies("Batman") } returns flowOf(listOf(movie)) + every { useCase.searchTvShows(any()) } returns flowOf(emptyList()) + val vm = MainViewModel(useCase) + + val collected = mutableListOf>() + val job = launch { vm.movieResult.collect { collected.add(it) } } + + vm.setSearchQuery("Batman") + advanceTimeBy(301) + vm.setSearchQuery("") + advanceTimeBy(301) + advanceUntilIdle() + job.cancel() + + assertEquals(emptyList(), collected.last()) + } + + @Test + fun `tvShowResult emits search results after debounce`() = runTest(testDispatcher) { + every { useCase.searchMovies(any()) } returns flowOf(emptyList()) + val results = listOf(tvShow) + every { useCase.searchTvShows("Breaking") } returns flowOf(results) + val vm = MainViewModel(useCase) + + val collected = mutableListOf>() + val job = launch { vm.tvShowResult.collect { collected.add(it) } } + + vm.setSearchQuery("Breaking") + advanceTimeBy(301) + advanceUntilIdle() + job.cancel() + + assertTrue(collected.any { it.isNotEmpty() }) + assertEquals(results, collected.last()) + } +} diff --git a/app/src/test/java/com/argahutama/submission/made/movie/MovieViewModelTest.kt b/app/src/test/java/com/argahutama/submission/made/movie/MovieViewModelTest.kt new file mode 100644 index 0000000..b096271 --- /dev/null +++ b/app/src/test/java/com/argahutama/submission/made/movie/MovieViewModelTest.kt @@ -0,0 +1,75 @@ +package com.argahutama.submission.made.movie + +import com.argahutama.submission.core.data.Resource +import com.argahutama.submission.core.domain.model.Movie +import com.argahutama.submission.core.domain.usecase.MovieUseCase +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class MovieViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var useCase: MovieUseCase + + private val movie = Movie(1, "Overview", "en", "2024-01-01", 9.5, 7.8, "Movie", 100, "/poster.jpg") + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + useCase = mockk() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `movies has Loading as initial value before subscription`() { + every { useCase.getAllMovies() } returns emptyFlow() + val vm = MovieViewModel(useCase) + assertTrue(vm.movies.value is Resource.Loading) + } + + @Test + fun `movies emits Success when use case provides data`() = runTest(testDispatcher) { + val movieList = listOf(movie) + every { useCase.getAllMovies() } returns flowOf(Resource.Success(movieList)) + val vm = MovieViewModel(useCase) + + val collected = mutableListOf>>() + val job = launch { vm.movies.collect { collected.add(it) } } + advanceUntilIdle() + job.cancel() + + val success = collected.filterIsInstance>>().firstOrNull() + assertNotNull(success) + assertEquals(movieList, success!!.data) + } + + @Test + fun `movies emits Error when use case returns error`() = runTest(testDispatcher) { + every { useCase.getAllMovies() } returns flowOf(Resource.Error("Failed to load")) + val vm = MovieViewModel(useCase) + + val collected = mutableListOf>>() + val job = launch { vm.movies.collect { collected.add(it) } } + advanceUntilIdle() + job.cancel() + + val error = collected.filterIsInstance>>().firstOrNull() + assertNotNull(error) + assertEquals("Failed to load", error!!.message) + } +} diff --git a/app/src/test/java/com/argahutama/submission/made/tvshow/TvShowViewModelTest.kt b/app/src/test/java/com/argahutama/submission/made/tvshow/TvShowViewModelTest.kt new file mode 100644 index 0000000..092a0f3 --- /dev/null +++ b/app/src/test/java/com/argahutama/submission/made/tvshow/TvShowViewModelTest.kt @@ -0,0 +1,76 @@ +package com.argahutama.submission.made.tvshow + +import com.argahutama.submission.core.data.Resource +import com.argahutama.submission.core.domain.model.Movie +import com.argahutama.submission.core.domain.usecase.MovieUseCase +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class TvShowViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var useCase: MovieUseCase + + private val tvShow = Movie(2, "TV Overview", "ko", "2023-05-10", 8.0, 8.5, "TV Show", 200, "/tv.jpg", isTvShows = true) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + useCase = mockk() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `tvShows has Loading as initial value before subscription`() { + every { useCase.getAllTvShows() } returns emptyFlow() + val vm = TvShowViewModel(useCase) + assertTrue(vm.tvShows.value is Resource.Loading) + } + + @Test + fun `tvShows emits Success when use case provides data`() = runTest(testDispatcher) { + val tvShowList = listOf(tvShow) + every { useCase.getAllTvShows() } returns flowOf(Resource.Success(tvShowList)) + val vm = TvShowViewModel(useCase) + + val collected = mutableListOf>>() + val job = launch { vm.tvShows.collect { collected.add(it) } } + advanceUntilIdle() + job.cancel() + + val success = collected.filterIsInstance>>().firstOrNull() + assertNotNull(success) + assertEquals(tvShowList, success!!.data) + assertTrue(success.data!!.first().isTvShows) + } + + @Test + fun `tvShows emits Error when use case returns error`() = runTest(testDispatcher) { + every { useCase.getAllTvShows() } returns flowOf(Resource.Error("Service unavailable")) + val vm = TvShowViewModel(useCase) + + val collected = mutableListOf>>() + val job = launch { vm.tvShows.collect { collected.add(it) } } + advanceUntilIdle() + job.cancel() + + val error = collected.filterIsInstance>>().firstOrNull() + assertNotNull(error) + assertEquals("Service unavailable", error!!.message) + } +} diff --git a/core/build.gradle b/core/build.gradle index 3c93be5..811474f 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -58,6 +58,11 @@ dependencies { implementation libs.androidx.room.runtime ksp libs.androidx.room.compiler androidTestImplementation libs.androidx.room.testing + + testImplementation libs.junit + testImplementation libs.mockk + testImplementation libs.turbine + testImplementation libs.kotlinx.coroutines.test implementation libs.sqlcipher implementation libs.androidx.sqlite.ktx diff --git a/core/src/test/java/com/argahutama/submission/core/data/MovieRepositoryTest.kt b/core/src/test/java/com/argahutama/submission/core/data/MovieRepositoryTest.kt new file mode 100644 index 0000000..39bb946 --- /dev/null +++ b/core/src/test/java/com/argahutama/submission/core/data/MovieRepositoryTest.kt @@ -0,0 +1,199 @@ +package com.argahutama.submission.core.data + +import app.cash.turbine.test +import com.argahutama.submission.core.data.source.local.LocalDataSource +import com.argahutama.submission.core.data.source.local.entity.MovieEntity +import com.argahutama.submission.core.data.source.remote.RemoteDataSource +import com.argahutama.submission.core.data.source.remote.network.ApiResponse +import com.argahutama.submission.core.data.source.remote.response.MovieResponse +import com.argahutama.submission.core.data.source.remote.response.TvShowResponse +import com.argahutama.submission.core.domain.model.Movie +import com.argahutama.submission.core.util.AppExecutors +import io.mockk.* +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.util.concurrent.Executor + +class MovieRepositoryTest { + + private lateinit var remoteDataSource: RemoteDataSource + private lateinit var localDataSource: LocalDataSource + private lateinit var appExecutors: AppExecutors + private lateinit var repository: MovieRepository + + private val movieEntity = MovieEntity( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg", + favorite = false, isTvShows = false + ) + + private val tvShowEntity = MovieEntity( + id = 2, overview = "TV Overview", originalLanguage = "ko", + releaseDate = "2023-05-10", popularity = 8.0, voteAverage = 8.5, + title = "TV Show", voteCount = 200, posterPath = "/tv.jpg", + favorite = false, isTvShows = true + ) + + private val movieResponse = MovieResponse( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg" + ) + + private val tvShowResponse = TvShowResponse( + id = 2, overview = "TV Overview", originalLanguage = "ko", + firstAirDate = "2023-05-10", popularity = 8.0, voteAverage = 8.5, + name = "TV Show", voteCount = 200, posterPath = "/tv.jpg" + ) + + @Before + fun setUp() { + remoteDataSource = mockk() + localDataSource = mockk() + appExecutors = mockk() + every { appExecutors.diskIO() } returns Executor { it.run() } + repository = MovieRepository(remoteDataSource, localDataSource, appExecutors) + } + + @Test + fun `getAllMovies returns cached data without network call when db is not empty`() = runTest { + every { localDataSource.getAllMovies() } returns flowOf(listOf(movieEntity)) + + repository.getAllMovies().test { + assertTrue(awaitItem() is Resource.Loading) + val success = awaitItem() as Resource.Success + assertEquals(1, success.data?.size) + assertEquals("Movie", success.data?.first()?.title) + awaitComplete() + } + + coVerify(exactly = 0) { remoteDataSource.getMovies() } + } + + @Test + fun `getAllMovies fetches from network and saves when db is empty`() = runTest { + every { localDataSource.getAllMovies() } returnsMany listOf( + flowOf(emptyList()), + flowOf(listOf(movieEntity)) + ) + coEvery { remoteDataSource.getMovies() } returns flowOf(ApiResponse.Success(listOf(movieResponse))) + coEvery { localDataSource.insertMovies(any()) } just Runs + + repository.getAllMovies().test { + assertTrue(awaitItem() is Resource.Loading) + assertTrue(awaitItem() is Resource.Loading) + val success = awaitItem() as Resource.Success + assertEquals(1, success.data?.size) + awaitComplete() + } + + coVerify { localDataSource.insertMovies(any()) } + } + + @Test + fun `getAllMovies emits Error when network call fails`() = runTest { + every { localDataSource.getAllMovies() } returns flowOf(emptyList()) + coEvery { remoteDataSource.getMovies() } returns flowOf(ApiResponse.Error("Network error")) + + repository.getAllMovies().test { + assertTrue(awaitItem() is Resource.Loading) + assertTrue(awaitItem() is Resource.Loading) + val error = awaitItem() as Resource.Error + assertEquals("Network error", error.message) + awaitComplete() + } + } + + @Test + fun `getAllMovies emits Success with empty list on Empty api response`() = runTest { + every { localDataSource.getAllMovies() } returnsMany listOf( + flowOf(emptyList()), + flowOf(emptyList()) + ) + coEvery { remoteDataSource.getMovies() } returns flowOf(ApiResponse.Empty) + + repository.getAllMovies().test { + assertTrue(awaitItem() is Resource.Loading) + assertTrue(awaitItem() is Resource.Loading) + val success = awaitItem() as Resource.Success + assertTrue(success.data.isNullOrEmpty()) + awaitComplete() + } + } + + @Test + fun `getAllTvShows fetches from network and saves when db is empty`() = runTest { + every { localDataSource.getAllTvShows() } returnsMany listOf( + flowOf(emptyList()), + flowOf(listOf(tvShowEntity)) + ) + coEvery { remoteDataSource.getTvShows() } returns flowOf(ApiResponse.Success(listOf(tvShowResponse))) + coEvery { localDataSource.insertMovies(any()) } just Runs + + repository.getAllTvShows().test { + assertTrue(awaitItem() is Resource.Loading) + assertTrue(awaitItem() is Resource.Loading) + val success = awaitItem() as Resource.Success + assertEquals(1, success.data?.size) + assertTrue(success.data?.first()?.isTvShows == true) + awaitComplete() + } + } + + @Test + fun `getFavoriteMovies returns mapped domain list`() = runTest { + val favorite = movieEntity.copy(favorite = true) + every { localDataSource.getAllFavoriteMovies() } returns flowOf(listOf(favorite)) + + repository.getFavoriteMovies().test { + val movies = awaitItem() + assertEquals(1, movies.size) + assertTrue(movies.first().favorite) + awaitComplete() + } + } + + @Test + fun `getFavoriteTvShows returns mapped domain list`() = runTest { + val favoriteTvShow = tvShowEntity.copy(favorite = true) + every { localDataSource.getAllFavoriteTvShows() } returns flowOf(listOf(favoriteTvShow)) + + repository.getFavoriteTvShows().test { + val movies = awaitItem() + assertEquals(1, movies.size) + assertTrue(movies.first().isTvShows) + awaitComplete() + } + } + + @Test + fun `searchMovies returns matching domain objects`() = runTest { + every { localDataSource.searchMovie("Movie") } returns flowOf(listOf(movieEntity)) + + repository.searchMovies("Movie").test { + val result = awaitItem() + assertEquals(1, result.size) + assertEquals("Movie", result.first().title) + awaitComplete() + } + } + + @Test + fun `setMovieFavorite executes on disk IO and updates local data source`() { + val movie = Movie( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg" + ) + every { localDataSource.setMovieFavorite(any(), true) } just Runs + + repository.setMovieFavorite(movie, true) + + verify { appExecutors.diskIO() } + verify { localDataSource.setMovieFavorite(any(), true) } + } +} diff --git a/core/src/test/java/com/argahutama/submission/core/data/source/local/LocalDataSourceTest.kt b/core/src/test/java/com/argahutama/submission/core/data/source/local/LocalDataSourceTest.kt new file mode 100644 index 0000000..3a640f0 --- /dev/null +++ b/core/src/test/java/com/argahutama/submission/core/data/source/local/LocalDataSourceTest.kt @@ -0,0 +1,130 @@ +package com.argahutama.submission.core.data.source.local + +import com.argahutama.submission.core.data.source.local.entity.MovieEntity +import com.argahutama.submission.core.data.source.local.room.MovieDao +import com.argahutama.submission.core.util.SortUtil +import io.mockk.* +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class LocalDataSourceTest { + + private lateinit var movieDao: MovieDao + private lateinit var localDataSource: LocalDataSource + + private val movieEntity = MovieEntity( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg" + ) + + @Before + fun setUp() { + movieDao = mockk() + localDataSource = LocalDataSource(movieDao) + mockkObject(SortUtil) + every { SortUtil.getSortedQueryMovies() } returns mockk() + every { SortUtil.getSortedQueryTvShows() } returns mockk() + every { SortUtil.getSortedQueryFavoriteMovies() } returns mockk() + every { SortUtil.getSortedQueryFavoriteTvShows() } returns mockk() + } + + @After + fun tearDown() { + unmockkObject(SortUtil) + } + + @Test + fun `getAllMovies returns flow from dao using sorted query`() = runTest { + val expected = listOf(movieEntity) + every { movieDao.getMovies(any()) } returns flowOf(expected) + + val result = localDataSource.getAllMovies().first() + + assertEquals(expected, result) + verify { movieDao.getMovies(any()) } + } + + @Test + fun `getAllTvShows returns flow from dao using sorted query`() = runTest { + val tvShow = movieEntity.copy(isTvShows = true) + every { movieDao.getTvShows(any()) } returns flowOf(listOf(tvShow)) + + val result = localDataSource.getAllTvShows().first() + + assertEquals(listOf(tvShow), result) + verify { movieDao.getTvShows(any()) } + } + + @Test + fun `getAllFavoriteMovies returns flow from dao using sorted query`() = runTest { + val favorite = movieEntity.copy(favorite = true) + every { movieDao.getFavoriteMovies(any()) } returns flowOf(listOf(favorite)) + + val result = localDataSource.getAllFavoriteMovies().first() + + assertEquals(listOf(favorite), result) + verify { movieDao.getFavoriteMovies(any()) } + } + + @Test + fun `getAllFavoriteTvShows returns flow from dao using sorted query`() = runTest { + val favoriteTvShow = movieEntity.copy(favorite = true, isTvShows = true) + every { movieDao.getFavoriteTvShows(any()) } returns flowOf(listOf(favoriteTvShow)) + + val result = localDataSource.getAllFavoriteTvShows().first() + + assertEquals(listOf(favoriteTvShow), result) + verify { movieDao.getFavoriteTvShows(any()) } + } + + @Test + fun `searchMovie returns matching results from dao`() = runTest { + val query = "Movie" + val expected = listOf(movieEntity) + every { movieDao.searchMovies(query) } returns flowOf(expected) + + val result = localDataSource.searchMovie(query).first() + + assertEquals(expected, result) + verify { movieDao.searchMovies(query) } + } + + @Test + fun `searchTvShow returns matching results from dao`() = runTest { + val query = "Show" + val tvShow = movieEntity.copy(isTvShows = true) + every { movieDao.searchTvShows(query) } returns flowOf(listOf(tvShow)) + + val result = localDataSource.searchTvShow(query).first() + + assertEquals(listOf(tvShow), result) + verify { movieDao.searchTvShows(query) } + } + + @Test + fun `insertMovies delegates to dao`() = runTest { + val movies = listOf(movieEntity) + coEvery { movieDao.insertMovie(movies) } just Runs + + localDataSource.insertMovies(movies) + + coVerify { movieDao.insertMovie(movies) } + } + + @Test + fun `setMovieFavorite updates favorite flag and delegates to dao`() { + val movie = movieEntity.copy(favorite = false) + every { movieDao.updateFavoriteMovie(any()) } just Runs + + localDataSource.setMovieFavorite(movie, true) + + assertEquals(true, movie.favorite) + verify { movieDao.updateFavoriteMovie(movie) } + } +} diff --git a/core/src/test/java/com/argahutama/submission/core/data/source/remote/RemoteDataSourceTest.kt b/core/src/test/java/com/argahutama/submission/core/data/source/remote/RemoteDataSourceTest.kt new file mode 100644 index 0000000..f8bc2a1 --- /dev/null +++ b/core/src/test/java/com/argahutama/submission/core/data/source/remote/RemoteDataSourceTest.kt @@ -0,0 +1,110 @@ +package com.argahutama.submission.core.data.source.remote + +import app.cash.turbine.test +import com.argahutama.submission.core.data.source.remote.network.ApiResponse +import com.argahutama.submission.core.data.source.remote.network.ApiService +import com.argahutama.submission.core.data.source.remote.response.MovieResponse +import com.argahutama.submission.core.data.source.remote.response.MoviesResponse +import com.argahutama.submission.core.data.source.remote.response.TvShowResponse +import com.argahutama.submission.core.data.source.remote.response.TvShowsResponse +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class RemoteDataSourceTest { + + private lateinit var apiService: ApiService + private lateinit var remoteDataSource: RemoteDataSource + + private val movieResponse = MovieResponse( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg" + ) + + private val tvShowResponse = TvShowResponse( + id = 2, overview = "TV Overview", originalLanguage = "ko", + firstAirDate = "2023-05-10", popularity = 8.0, voteAverage = 8.5, + name = "TV Show", voteCount = 200, posterPath = "/tv.jpg" + ) + + @Before + fun setUp() { + apiService = mockk() + remoteDataSource = RemoteDataSource(apiService) + } + + @Test + fun `getMovies emits Success when results are not empty`() = runTest { + coEvery { apiService.getMovies(any()) } returns MoviesResponse(listOf(movieResponse)) + + remoteDataSource.getMovies().test { + val result = awaitItem() + assertTrue(result is ApiResponse.Success) + assertEquals(listOf(movieResponse), (result as ApiResponse.Success).data) + awaitComplete() + } + } + + @Test + fun `getMovies emits Empty when results are empty`() = runTest { + coEvery { apiService.getMovies(any()) } returns MoviesResponse(emptyList()) + + remoteDataSource.getMovies().test { + assertTrue(awaitItem() is ApiResponse.Empty) + awaitComplete() + } + } + + @Test + fun `getMovies emits Error on exception`() = runTest { + val errorMessage = "Network error" + coEvery { apiService.getMovies(any()) } throws RuntimeException(errorMessage) + + remoteDataSource.getMovies().test { + val result = awaitItem() + assertTrue(result is ApiResponse.Error) + assertTrue((result as ApiResponse.Error).errorMessage.contains(errorMessage)) + awaitComplete() + } + } + + @Test + fun `getTvShows emits Success when results are not empty`() = runTest { + coEvery { apiService.getTvShows(any()) } returns TvShowsResponse(listOf(tvShowResponse)) + + remoteDataSource.getTvShows().test { + val result = awaitItem() + assertTrue(result is ApiResponse.Success) + assertEquals(listOf(tvShowResponse), (result as ApiResponse.Success).data) + awaitComplete() + } + } + + @Test + fun `getTvShows emits Empty when results are empty`() = runTest { + coEvery { apiService.getTvShows(any()) } returns TvShowsResponse(emptyList()) + + remoteDataSource.getTvShows().test { + assertTrue(awaitItem() is ApiResponse.Empty) + awaitComplete() + } + } + + @Test + fun `getTvShows emits Error on exception`() = runTest { + val errorMessage = "Timeout" + coEvery { apiService.getTvShows(any()) } throws RuntimeException(errorMessage) + + remoteDataSource.getTvShows().test { + val result = awaitItem() + assertTrue(result is ApiResponse.Error) + assertTrue((result as ApiResponse.Error).errorMessage.contains(errorMessage)) + awaitComplete() + } + } +} diff --git a/core/src/test/java/com/argahutama/submission/core/domain/usecase/MovieInteractorTest.kt b/core/src/test/java/com/argahutama/submission/core/domain/usecase/MovieInteractorTest.kt new file mode 100644 index 0000000..f5702b2 --- /dev/null +++ b/core/src/test/java/com/argahutama/submission/core/domain/usecase/MovieInteractorTest.kt @@ -0,0 +1,106 @@ +package com.argahutama.submission.core.domain.usecase + +import com.argahutama.submission.core.data.Resource +import com.argahutama.submission.core.domain.model.Movie +import com.argahutama.submission.core.domain.repository.IMovieRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import org.junit.Before +import org.junit.Test + +class MovieInteractorTest { + + private lateinit var repository: IMovieRepository + private lateinit var interactor: MovieInteractor + + private val movie = Movie( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg" + ) + + @Before + fun setUp() { + repository = mockk() + interactor = MovieInteractor(repository) + } + + @Test + fun `getAllMovies delegates to repository`() { + val flow = flowOf(Resource.Success(listOf(movie))) + every { repository.getAllMovies() } returns flow + + val result = interactor.getAllMovies() + + assert(result === flow) + verify(exactly = 1) { repository.getAllMovies() } + } + + @Test + fun `getAllTvShows delegates to repository`() { + val flow = flowOf(Resource.Success(listOf(movie))) + every { repository.getAllTvShows() } returns flow + + val result = interactor.getAllTvShows() + + assert(result === flow) + verify(exactly = 1) { repository.getAllTvShows() } + } + + @Test + fun `getFavoriteMovies delegates to repository`() { + val flow = flowOf(listOf(movie)) + every { repository.getFavoriteMovies() } returns flow + + val result = interactor.getFavoriteMovies() + + assert(result === flow) + verify(exactly = 1) { repository.getFavoriteMovies() } + } + + @Test + fun `getFavoriteTvShows delegates to repository`() { + val flow = flowOf(listOf(movie)) + every { repository.getFavoriteTvShows() } returns flow + + val result = interactor.getFavoriteTvShows() + + assert(result === flow) + verify(exactly = 1) { repository.getFavoriteTvShows() } + } + + @Test + fun `searchMovies delegates to repository with query`() { + val query = "batman" + val flow = flowOf(listOf(movie)) + every { repository.searchMovies(query) } returns flow + + val result = interactor.searchMovies(query) + + assert(result === flow) + verify(exactly = 1) { repository.searchMovies(query) } + } + + @Test + fun `searchTvShows delegates to repository with query`() { + val query = "breaking bad" + val flow = flowOf(listOf(movie)) + every { repository.searchTvShows(query) } returns flow + + val result = interactor.searchTvShows(query) + + assert(result === flow) + verify(exactly = 1) { repository.searchTvShows(query) } + } + + @Test + fun `setMovieFavorite delegates to repository`() { + every { repository.setMovieFavorite(movie, true) } returns Unit + + interactor.setMovieFavorite(movie, true) + + verify(exactly = 1) { repository.setMovieFavorite(movie, true) } + } +} diff --git a/core/src/test/java/com/argahutama/submission/core/util/DataMapperTest.kt b/core/src/test/java/com/argahutama/submission/core/util/DataMapperTest.kt new file mode 100644 index 0000000..99c3229 --- /dev/null +++ b/core/src/test/java/com/argahutama/submission/core/util/DataMapperTest.kt @@ -0,0 +1,162 @@ +package com.argahutama.submission.core.util + +import com.argahutama.submission.core.data.source.local.entity.MovieEntity +import com.argahutama.submission.core.data.source.remote.response.MovieResponse +import com.argahutama.submission.core.data.source.remote.response.TvShowResponse +import com.argahutama.submission.core.domain.model.Movie +import org.junit.Assert.assertEquals +import org.junit.Test + +class DataMapperTest { + + @Test + fun `mapMovieResponsesToEntities maps all fields correctly`() { + val response = MovieResponse( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg" + ) + + val result = DataMapper.mapMovieResponsesToEntities(listOf(response)) + + assertEquals(1, result.size) + result[0].also { + assertEquals(1, it.id) + assertEquals("Overview", it.overview) + assertEquals("en", it.originalLanguage) + assertEquals("2024-01-01", it.releaseDate) + assertEquals(9.5, it.popularity, 0.0) + assertEquals(7.8, it.voteAverage, 0.0) + assertEquals("Movie", it.title) + assertEquals(100, it.voteCount) + assertEquals("/poster.jpg", it.posterPath) + assertEquals(false, it.favorite) + assertEquals(false, it.isTvShows) + } + } + + @Test + fun `mapMovieResponsesToEntities rounds voteAverage to one decimal`() { + val response = MovieResponse( + id = 1, overview = null, originalLanguage = null, releaseDate = null, + popularity = null, voteAverage = 7.86, title = null, voteCount = null, posterPath = null + ) + + val result = DataMapper.mapMovieResponsesToEntities(listOf(response)) + + assertEquals(7.9, result[0].voteAverage, 0.0) + } + + @Test + fun `mapMovieResponsesToEntities handles null fields with defaults`() { + val response = MovieResponse( + id = null, overview = null, originalLanguage = null, releaseDate = null, + popularity = null, voteAverage = null, title = null, voteCount = null, posterPath = null + ) + + val result = DataMapper.mapMovieResponsesToEntities(listOf(response)) + + result[0].also { + assertEquals(0, it.id) + assertEquals("", it.overview) + assertEquals("", it.title) + assertEquals(0.0, it.voteAverage, 0.0) + assertEquals(0, it.voteCount) + } + } + + @Test + fun `mapTvShowResponsesToEntities maps all fields correctly`() { + val response = TvShowResponse( + id = 2, overview = "TV Overview", originalLanguage = "ko", + firstAirDate = "2023-05-10", popularity = 8.0, voteAverage = 8.5, + name = "TV Show", voteCount = 200, posterPath = "/tv.jpg" + ) + + val result = DataMapper.mapTvShowResponsesToEntities(listOf(response)) + + assertEquals(1, result.size) + result[0].also { + assertEquals(2, it.id) + assertEquals("TV Overview", it.overview) + assertEquals("ko", it.originalLanguage) + assertEquals("2023-05-10", it.releaseDate) + assertEquals(8.0, it.popularity, 0.0) + assertEquals(8.5, it.voteAverage, 0.0) + assertEquals("TV Show", it.title) + assertEquals(200, it.voteCount) + assertEquals("/tv.jpg", it.posterPath) + assertEquals(false, it.favorite) + assertEquals(true, it.isTvShows) + } + } + + @Test + fun `mapTvShowResponsesToEntities rounds voteAverage to one decimal`() { + val response = TvShowResponse( + id = 1, overview = null, originalLanguage = null, firstAirDate = null, + popularity = null, voteAverage = 6.56, name = null, voteCount = null, posterPath = null + ) + + val result = DataMapper.mapTvShowResponsesToEntities(listOf(response)) + + assertEquals(6.6, result[0].voteAverage, 0.0) + } + + @Test + fun `mapEntitiesToDomain maps all fields correctly`() { + val entity = MovieEntity( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg", + favorite = true, isTvShows = false + ) + + val result = DataMapper.mapEntitiesToDomain(listOf(entity)) + + assertEquals(1, result.size) + result[0].also { + assertEquals(1, it.id) + assertEquals("Overview", it.overview) + assertEquals("en", it.originalLanguage) + assertEquals("2024-01-01", it.releaseDate) + assertEquals(9.5, it.popularity, 0.0) + assertEquals(7.8, it.voteAverage, 0.0) + assertEquals("Movie", it.title) + assertEquals(100, it.voteCount) + assertEquals("/poster.jpg", it.posterPath) + assertEquals(true, it.favorite) + assertEquals(false, it.isTvShows) + } + } + + @Test + fun `mapDomainToEntity maps all fields correctly`() { + val movie = Movie( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg", + favorite = false, isTvShows = true + ) + + val entity = DataMapper.mapDomainToEntity(movie) + + assertEquals(1, entity.id) + assertEquals("Overview", entity.overview) + assertEquals("en", entity.originalLanguage) + assertEquals("2024-01-01", entity.releaseDate) + assertEquals(9.5, entity.popularity, 0.0) + assertEquals(7.8, entity.voteAverage, 0.0) + assertEquals("Movie", entity.title) + assertEquals(100, entity.voteCount) + assertEquals("/poster.jpg", entity.posterPath) + assertEquals(false, entity.favorite) + assertEquals(true, entity.isTvShows) + } + + @Test + fun `mapEntitiesToDomain returns empty list for empty input`() { + val result = DataMapper.mapEntitiesToDomain(emptyList()) + assertEquals(0, result.size) + } +} diff --git a/favorite/build.gradle b/favorite/build.gradle index fe5687a..94cc384 100644 --- a/favorite/build.gradle +++ b/favorite/build.gradle @@ -44,4 +44,9 @@ dependencies { implementation libs.androidx.lifecycle.runtime implementation libs.koin.core implementation libs.koin.android + + testImplementation libs.junit + testImplementation libs.mockk + testImplementation libs.turbine + testImplementation libs.kotlinx.coroutines.test } diff --git a/favorite/src/test/java/com/argahutama/submission/favorite/FavoriteViewModelTest.kt b/favorite/src/test/java/com/argahutama/submission/favorite/FavoriteViewModelTest.kt new file mode 100644 index 0000000..1ca6d5e --- /dev/null +++ b/favorite/src/test/java/com/argahutama/submission/favorite/FavoriteViewModelTest.kt @@ -0,0 +1,112 @@ +package com.argahutama.submission.favorite + +import com.argahutama.submission.core.domain.model.Movie +import com.argahutama.submission.core.domain.usecase.MovieUseCase +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class FavoriteViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var useCase: MovieUseCase + + private val movie = Movie(1, "Overview", "en", "2024-01-01", 9.5, 7.8, "Movie", 100, "/poster.jpg", favorite = true) + private val tvShow = Movie(2, "TV Overview", "ko", "2023-05-10", 8.0, 8.5, "TV Show", 200, "/tv.jpg", favorite = true, isTvShows = true) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + useCase = mockk() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `favoriteMovies has empty list as initial value`() { + every { useCase.getFavoriteMovies() } returns emptyFlow() + every { useCase.getFavoriteTvShows() } returns emptyFlow() + val vm = FavoriteViewModel(useCase) + assertEquals(emptyList(), vm.favoriteMovies.value) + } + + @Test + fun `favoriteTvShows has empty list as initial value`() { + every { useCase.getFavoriteMovies() } returns emptyFlow() + every { useCase.getFavoriteTvShows() } returns emptyFlow() + val vm = FavoriteViewModel(useCase) + assertEquals(emptyList(), vm.favoriteTvShows.value) + } + + @Test + fun `favoriteMovies emits data from use case`() = runTest(testDispatcher) { + val favoriteList = listOf(movie) + every { useCase.getFavoriteMovies() } returns flowOf(favoriteList) + every { useCase.getFavoriteTvShows() } returns emptyFlow() + val vm = FavoriteViewModel(useCase) + + val collected = mutableListOf>() + val job = launch { vm.favoriteMovies.collect { collected.add(it) } } + advanceUntilIdle() + job.cancel() + + assertTrue(collected.any { it.isNotEmpty() }) + assertEquals(favoriteList, collected.last()) + assertTrue(collected.last().all { it.favorite }) + } + + @Test + fun `favoriteTvShows emits data from use case`() = runTest(testDispatcher) { + every { useCase.getFavoriteMovies() } returns emptyFlow() + val tvShowList = listOf(tvShow) + every { useCase.getFavoriteTvShows() } returns flowOf(tvShowList) + val vm = FavoriteViewModel(useCase) + + val collected = mutableListOf>() + val job = launch { vm.favoriteTvShows.collect { collected.add(it) } } + advanceUntilIdle() + job.cancel() + + assertTrue(collected.any { it.isNotEmpty() }) + assertEquals(tvShowList, collected.last()) + assertTrue(collected.last().all { it.isTvShows }) + } + + @Test + fun `setFavorite marks item as favorite via use case`() { + every { useCase.getFavoriteMovies() } returns emptyFlow() + every { useCase.getFavoriteTvShows() } returns emptyFlow() + every { useCase.setMovieFavorite(movie, true) } returns Unit + val vm = FavoriteViewModel(useCase) + + vm.setFavorite(movie, true) + + verify(exactly = 1) { useCase.setMovieFavorite(movie, true) } + } + + @Test + fun `setFavorite removes item from favorites via use case`() { + every { useCase.getFavoriteMovies() } returns emptyFlow() + every { useCase.getFavoriteTvShows() } returns emptyFlow() + every { useCase.setMovieFavorite(movie, false) } returns Unit + val vm = FavoriteViewModel(useCase) + + vm.setFavorite(movie, false) + + verify(exactly = 1) { useCase.setMovieFavorite(movie, false) } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7362f8..c4a3944 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,14 +6,12 @@ appcompat = "1.7.0" coreKtx = "1.16.0" activity = "1.10.1" constraintlayout = "2.2.1" -legacySupport = "1.0.0" junit = "4.13.2" -androidxJunit = "1.2.1" -espresso = "3.6.1" +mockk = "1.13.12" +turbine = "1.2.0" multidex = "2.0.1" leakcanary = "2.14" pluto = "3.0.1" -cardview = "1.0.0" recyclerview = "1.4.0" material = "1.12.0" glide = "4.16.0" @@ -43,8 +41,9 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-sqlite-ktx = { group = "androidx.sqlite", name = "sqlite-ktx", version.ref = "sqlite" } junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } -androidx-espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } leakcanary = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "leakcanary" } pluto = { group = "com.androidpluto", name = "pluto", version.ref = "pluto" } pluto-noop = { group = "com.androidpluto", name = "pluto-no-op", version.ref = "pluto" } From 6e4a2fa52a532557a4219c588491060b9fb32c98 Mon Sep 17 00:00:00 2001 From: Arga Hutama Date: Thu, 11 Jun 2026 18:57:50 +0700 Subject: [PATCH 3/3] update github workflow to build debug artifact --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5e62f36..9477a48 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,8 +35,8 @@ jobs: - name: Run unit tests run: ./gradlew testDebugUnitTest - - name: Build with Gradle - run: ./gradlew build + - name: Build debug with Gradle + run: ./gradlew :app:assembleDebug - name: Upload test reports uses: actions/upload-artifact@v4