Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 0 additions & 32 deletions .circleci/config.yml

This file was deleted.

62 changes: 62 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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: Run unit tests
run: ./gradlew testDebugUnitTest

- name: Build debug with Gradle
run: ./gradlew :app:assembleDebug

- 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()
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/
5 changes: 5 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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) }
}
}
Original file line number Diff line number Diff line change
@@ -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<List<Movie>>()
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<List<Movie>>()
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<List<Movie>>()
val job = launch { vm.movieResult.collect { collected.add(it) } }

vm.setSearchQuery("Batman")
advanceTimeBy(301)
vm.setSearchQuery("")
advanceTimeBy(301)
advanceUntilIdle()
job.cancel()

assertEquals(emptyList<Movie>(), 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<List<Movie>>()
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())
}
}
Original file line number Diff line number Diff line change
@@ -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<Resource<List<Movie>>>()
val job = launch { vm.movies.collect { collected.add(it) } }
advanceUntilIdle()
job.cancel()

val success = collected.filterIsInstance<Resource.Success<List<Movie>>>().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<Resource<List<Movie>>>()
val job = launch { vm.movies.collect { collected.add(it) } }
advanceUntilIdle()
job.cancel()

val error = collected.filterIsInstance<Resource.Error<List<Movie>>>().firstOrNull()
assertNotNull(error)
assertEquals("Failed to load", error!!.message)
}
}
Loading
Loading