Skip to content

Commit

Permalink
Add unit test for ApiService
Browse files Browse the repository at this point in the history
  • Loading branch information
AntonShapovalov committed Dec 27, 2024
1 parent 932deb6 commit 1e898c4
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 24 deletions.
7 changes: 6 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,17 @@ dependencies {
runtimeOnly("com.h2database:h2")
runtimeOnly("io.r2dbc:r2dbc-h2")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-starter-test"){
exclude(module = "mockito-core")
}
testImplementation("io.projectreactor:reactor-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
testImplementation("org.wiremock.integrations:wiremock-spring-boot:3.2.0")
testImplementation("org.junit.jupiter:junit-jupiter-api")
testImplementation("com.ninja-squad:springmockk:4.0.2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")

detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.7")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
Expand Down
19 changes: 19 additions & 0 deletions src/main/kotlin/concept/stc/coroutines/DispatchersProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package concept.stc.coroutines

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import org.springframework.stereotype.Component

/**
* Provides coroutines dispatchers.
* This class allows to replace background dispatchers with test dispatchers
* for unit and integration testing.
*/
@Component
class DispatchersProvider {

/**
* Get the IO dispatcher.
*/
val io: CoroutineDispatcher get() = Dispatchers.IO
}
28 changes: 16 additions & 12 deletions src/main/kotlin/concept/stc/data/ApiService.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
package concept.stc.data

import concept.stc.data.local.MovieRepository
import concept.stc.data.mapper.toDomain
import concept.stc.coroutines.DispatchersProvider
import concept.stc.data.local.MovieCrudRepository
import concept.stc.data.mapper.toEntity
import concept.stc.data.remote.ApiClient
import concept.stc.domain.model.Movie
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.springframework.stereotype.Service

/**
Expand All @@ -16,23 +14,29 @@ import org.springframework.stereotype.Service
*
* @param apiClient the API client.
* @param repository the CRUD movie repository.
* @param dispatchers the dispatcher provider.
*/
@Service
class ApiService(
private val apiClient: ApiClient,
private val repository: MovieRepository
private val repository: MovieCrudRepository,
private val dispatchers: DispatchersProvider
) {

/**
* Load movies from the external API and save them to the database.
*
* @param title the movie title.
*
* @return the movie flow that emits the saved movies.
*/
suspend fun loadMovies(title: String): Flow<Movie> {
val movies = apiClient.search(title).movies
val entities = movies.map { movie -> movie.toEntity() }
return repository.saveAll(entities).map { entity -> entity.toDomain() }
suspend fun loadMovies(title: String) {
withContext(dispatchers.io) {
val movies = apiClient.search(title).movies
for (movie in movies) {
val entity = repository.getMovieByImdbId(movie.imdbID)
if (entity == null) {
repository.save(movie.toEntity())
}
}
}
}
}
21 changes: 21 additions & 0 deletions src/main/kotlin/concept/stc/data/local/MovieCrudRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package concept.stc.data.local

import concept.stc.data.local.entity.MovieEntity
import org.springframework.data.r2dbc.repository.Query
import org.springframework.data.repository.kotlin.CoroutineCrudRepository

/**
* Repository to manage database operations for [MovieEntity].
*/
interface MovieCrudRepository : CoroutineCrudRepository<MovieEntity, Int> {

/**
* Find movie by IMDB ID.
*
* @param imdbId the IMDB ID.
*
* @return the movie entity or null if not found.
*/
@Query("SELECT * FROM movies WHERE imdb_id = :imdbId")
suspend fun getMovieByImdbId(imdbId: String): MovieEntity?
}
9 changes: 0 additions & 9 deletions src/main/kotlin/concept/stc/data/local/MovieRepository.kt

This file was deleted.

86 changes: 86 additions & 0 deletions src/test/kotlin/concept/stc/data/ApiServiceTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package concept.stc.data

import concept.stc.coroutines.DispatchersProvider
import concept.stc.data.local.MovieCrudRepository
import concept.stc.data.mapper.toEntity
import concept.stc.data.remote.ApiClient
import concept.stc.data.remote.model.SearchResponse
import io.mockk.Called
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test

@OptIn(ExperimentalCoroutinesApi::class)
class ApiServiceTest {

private val apiClient = mockk<ApiClient>()
private val repository = mockk<MovieCrudRepository>()
private val dispatchers = mockk<DispatchersProvider> {
coEvery { io } returns UnconfinedTestDispatcher()
}

private val service = ApiService(apiClient, repository, dispatchers)

@AfterEach
fun tearDown() {
clearMocks(apiClient, repository)
}

@Test
fun `when load movies, given API response, then save them to database`() = runTest {
// Given
val movie = _movie.copy(imdbID = "testId")
val searchResponse = _searchResponse.copy(movies = listOf(movie))

coEvery { apiClient.search(any()) } returns searchResponse
coEvery { repository.getMovieByImdbId(any()) } returns null
coEvery { repository.save(any()) } returns movie.toEntity()

// When
service.loadMovies("test")

// Then
coVerify { apiClient.search("test") }
coVerify { repository.getMovieByImdbId("testId") }
coVerify { repository.save(movie.toEntity()) }
}

@Test
fun `when load movies, given movie is saved already, then should not save it`() = runTest {
// Given
val movie = _movie.copy(imdbID = "testId")
val searchResponse = _searchResponse.copy(movies = listOf(movie))

coEvery { apiClient.search(any()) } returns searchResponse
coEvery { repository.getMovieByImdbId(any()) } returns movie.toEntity()
coEvery { repository.save(any()) } returns movie.toEntity()

// When
service.loadMovies("test")

// Then
coVerify { apiClient.search("test") }
coVerify { repository.getMovieByImdbId("testId") }
coVerify { repository.save(movie.toEntity()) wasNot Called }
}

private val _movie = SearchResponse.Movie(
title = "",
year = "",
imdbID = "",
type = "",
poster = ""
)

private val _searchResponse = SearchResponse(
movies = emptyList(),
totalResults = 0,
response = ""
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ import kotlin.test.assertNotNull

@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@AutoConfigureTestDatabase(replace = Replace.NONE)
class MovieRepositoryIntegrationTest {
class MovieCrudRepositoryIntegrationTest {

@Autowired
private lateinit var repository: MovieRepository
private lateinit var repository: MovieCrudRepository

@AfterTest
fun cleanUp() {
Expand All @@ -38,6 +38,20 @@ class MovieRepositoryIntegrationTest {
assertEquals("test123", saved.imdbID)
}

@Test
fun `when getting movie by IMDb ID, given entity, then result is not null`() = runTest {
// Given
val entity = _entity.copy(imdbID = "test123")
repository.save(entity)

// When
val result = repository.getMovieByImdbId("test123")

// Then
assertNotNull(result)
assertEquals("test123", result.imdbID)
}

private val _entity = MovieEntity(
title = "",
year = "",
Expand Down

0 comments on commit 1e898c4

Please sign in to comment.