Skip to content

Commit 5dc2352

Browse files
authored
feat: ✅ create suggestion & search viewmodel test suite (#28)
1 parent c213837 commit 5dc2352

File tree

11 files changed

+249
-23
lines changed

11 files changed

+249
-23
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
.DS_Store
66
/build
77
/captures
8-
*.iml
8+
*.iml
9+
*/.kotlintest

app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ dependencies {
7979
testImplementation libs.arch_comp_room_test
8080
testImplementation libs.arch_comp_test
8181
testImplementation libs.junit_api
82+
testImplementation libs.kotlintest
8283
testImplementation libs.koin_test
8384
testImplementation libs.mockito_kotlin
8485

app/src/main/java/es/ffgiraldez/comicsearch/comics/data/ComicRepository.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import es.ffgiraldez.comicsearch.platform.left
1212
import es.ffgiraldez.comicsearch.platform.right
1313
import io.reactivex.Flowable
1414

15-
abstract class ComicRepository<T>(
15+
abstract class ComicRepository<T> (
1616
private val local: ComicLocalDataSource<T>,
1717
private val remote: ComicRemoteDataSource<T>
1818
) {
@@ -38,7 +38,7 @@ abstract class ComicRepository<T>(
3838
results: Either<ComicError, List<T>>,
3939
term: String
4040
): Flowable<Either<ComicError, List<T>>> =
41-
results.fold({ _ ->
41+
results.fold({
4242
Flowable.just(results)
4343
}, {
4444
local.insert(term, it).toFlowable<Either<ComicError, List<T>>>()

app/src/main/java/es/ffgiraldez/comicsearch/query/base/presentation/QueryViewState.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package es.ffgiraldez.comicsearch.query.base.presentation
22

3+
import arrow.core.Either
34
import es.ffgiraldez.comicsearch.comics.domain.ComicError
45

56
sealed class QueryViewState<out T> {
67

78
companion object {
8-
fun <T> result(volumeList: List<T>): QueryViewState<T> = Result(volumeList)
9+
fun <T> result(results: List<T>): QueryViewState<T> = Result(results)
910
fun <T> idle(): QueryViewState<T> = Idle
1011
fun <T> loading(): QueryViewState<T> = Loading
1112
fun <T> error(error: ComicError): QueryViewState<T> = Error(error)
@@ -18,3 +19,7 @@ sealed class QueryViewState<out T> {
1819

1920
}
2021

22+
fun <T> Either<ComicError, List<T>>.toViewState(): QueryViewState<T> = fold(
23+
{ QueryViewState.error(it) },
24+
{ QueryViewState.result(it) }
25+
)

app/src/main/java/es/ffgiraldez/comicsearch/query/search/presentation/SearchViewModel.kt

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package es.ffgiraldez.comicsearch.query.search.presentation
33
import es.ffgiraldez.comicsearch.comics.domain.Volume
44
import es.ffgiraldez.comicsearch.query.base.presentation.QueryStateViewModel
55
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
6+
import es.ffgiraldez.comicsearch.query.base.presentation.toViewState
67
import es.ffgiraldez.comicsearch.query.search.data.SearchRepository
78
import io.reactivex.Flowable
89
import org.reactivestreams.Publisher
@@ -11,20 +12,14 @@ class SearchViewModel private constructor(
1112
queryToResult: (Flowable<String>) -> Publisher<QueryViewState<Volume>>
1213
) : QueryStateViewModel<Volume>(queryToResult) {
1314
companion object {
14-
operator fun invoke(repo: SearchRepository): SearchViewModel = SearchViewModel { it ->
15-
it.switchMap { handleQuery(repo, it) }
15+
operator fun invoke(repo: SearchRepository): SearchViewModel = SearchViewModel {
16+
it.switchMap { query -> handleQuery(repo, query) }
1617
.startWith(QueryViewState.idle())
1718
}
1819

19-
private fun handleQuery(repo: SearchRepository, it: String): Flowable<QueryViewState<Volume>> =
20-
repo.findByTerm(it)
21-
.map {
22-
it.fold({
23-
QueryViewState.error<Volume>(it)
24-
}, {
25-
QueryViewState.result(it)
26-
})
27-
}
20+
private fun handleQuery(repo: SearchRepository, query: String): Flowable<QueryViewState<Volume>> =
21+
repo.findByTerm(query)
22+
.map { it.toViewState() }
2823
.startWith(QueryViewState.loading())
2924
}
3025
}

app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/presentation/SuggestionViewModel.kt

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package es.ffgiraldez.comicsearch.query.sugestion.presentation
22

33
import es.ffgiraldez.comicsearch.query.base.presentation.QueryStateViewModel
44
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
5+
import es.ffgiraldez.comicsearch.query.base.presentation.toViewState
56
import es.ffgiraldez.comicsearch.query.sugestion.data.SuggestionRepository
67
import io.reactivex.Flowable
78
import org.reactivestreams.Publisher
@@ -15,6 +16,7 @@ class SuggestionViewModel private constructor(
1516
it.debounce(400, TimeUnit.MILLISECONDS)
1617
.switchMap { query -> handleQuery(query, repo) }
1718
.startWith(QueryViewState.idle())
19+
.distinctUntilChanged()
1820
}
1921

2022
private fun handleQuery(
@@ -32,12 +34,7 @@ class SuggestionViewModel private constructor(
3234
query: String
3335
): Flowable<QueryViewState<String>> =
3436
repo.findByTerm(query)
35-
.map { suggestions ->
36-
suggestions.fold({
37-
QueryViewState.error<String>(it)
38-
}, {
39-
QueryViewState.result(it)
40-
})
41-
}.startWith(QueryViewState.loading())
37+
.map { it.toViewState() }
38+
.startWith(QueryViewState.loading())
4239
}
43-
}
40+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package es.ffgiraldez.comicsearch.comic.gen
2+
3+
import arrow.core.Either
4+
import es.ffgiraldez.comicsearch.comics.domain.ComicError
5+
import es.ffgiraldez.comicsearch.comics.domain.Volume
6+
import es.ffgiraldez.comicsearch.platform.left
7+
import es.ffgiraldez.comicsearch.platform.right
8+
import io.kotlintest.properties.Gen
9+
10+
class ComicErrorGenerator : Gen<ComicError> {
11+
override fun constants(): Iterable<ComicError> = emptyList()
12+
13+
override fun random(): Sequence<ComicError> = generateSequence {
14+
takeIf { Gen.bool().random().first() }
15+
?.let { ComicError.EmptyResultsError } ?: ComicError.NetworkError
16+
}
17+
}
18+
19+
class QueryGenerator : Gen<String> {
20+
override fun constants(): Iterable<String> = Gen.string().constants().filter { it.isNotEmpty() }
21+
22+
override fun random(): Sequence<String> = Gen.string().random().filter { it.isNotEmpty() }
23+
}
24+
25+
class SuggestionGenerator : Gen<Either<ComicError, List<String>>> {
26+
override fun constants(): Iterable<Either<ComicError, List<String>>> = emptyList()
27+
28+
override fun random(): Sequence<Either<ComicError, List<String>>> = generateSequence {
29+
generateEither(Gen.bool().random().first())
30+
}
31+
32+
private fun generateEither(it: Boolean): Either<ComicError, List<String>> {
33+
return if (it) {
34+
left(Gen.comicError().random().first())
35+
} else {
36+
right((1..10).fold(emptyList()) { acc, _ -> acc + Gen.query().random().iterator().next() })
37+
}
38+
}
39+
}
40+
41+
class VolumeGenerator : Gen<Volume> {
42+
override fun constants(): Iterable<Volume> = emptyList()
43+
44+
override fun random(): Sequence<Volume> = generateSequence {
45+
Volume(
46+
Gen.string().random().first(),
47+
Gen.string().random().first(),
48+
Gen.string().random().first()
49+
)
50+
}
51+
52+
}
53+
54+
class SearchGenerator : Gen<Either<ComicError, List<Volume>>> {
55+
override fun constants(): Iterable<Either<ComicError, List<Volume>>> = emptyList()
56+
57+
override fun random(): Sequence<Either<ComicError, List<Volume>>> = generateSequence {
58+
generateEither(Gen.bool().random().first())
59+
}
60+
61+
private fun generateEither(it: Boolean): Either<ComicError, List<Volume>> {
62+
return if (it) {
63+
left(Gen.comicError().random().first())
64+
} else {
65+
right((1..10).fold(emptyList()) { acc, _ -> acc + Gen.volume().random().iterator().next() })
66+
}
67+
}
68+
}
69+
70+
71+
fun Gen.Companion.suggestions(): Gen<Either<ComicError, List<String>>> = SuggestionGenerator()
72+
73+
fun Gen.Companion.search(): Gen<Either<ComicError, List<Volume>>> = SearchGenerator()
74+
75+
fun Gen.Companion.comicError(): Gen<ComicError> = ComicErrorGenerator()
76+
77+
fun Gen.Companion.query(): Gen<String> = QueryGenerator()
78+
79+
fun Gen.Companion.volume(): Gen<Volume> = VolumeGenerator()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package es.ffgiraldez.comicsearch.query.search.presentation
2+
3+
import arrow.core.Either
4+
import com.nhaarman.mockitokotlin2.mock
5+
import es.ffgiraldez.comicsearch.comic.gen.query
6+
import es.ffgiraldez.comicsearch.comic.gen.search
7+
import es.ffgiraldez.comicsearch.comics.domain.ComicError
8+
import es.ffgiraldez.comicsearch.comics.domain.Volume
9+
import es.ffgiraldez.comicsearch.platform.toFlowable
10+
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
11+
import es.ffgiraldez.comicsearch.query.base.presentation.toViewState
12+
import io.kotlintest.properties.Gen
13+
import io.kotlintest.properties.assertAll
14+
import io.kotlintest.specs.StringSpec
15+
import io.reactivex.Flowable
16+
import org.mockito.ArgumentMatchers.anyString
17+
18+
class SearchViewModelSpec :
19+
StringSpec({
20+
"Search ViewModel should trigger search for a query" {
21+
assertAll(Gen.search(), Gen.query()) { results, query ->
22+
val viewModel = givenSuggestionViewModel(results)
23+
val observer = viewModel.state.toFlowable().test()
24+
val viewState = results.toViewState()
25+
26+
viewModel.inputQuery(query)
27+
28+
observer.assertNotComplete()
29+
.assertNoErrors()
30+
.assertValues(QueryViewState.idle(), QueryViewState.loading(), viewState)
31+
}
32+
}
33+
34+
35+
})
36+
37+
private fun SearchViewModel.inputQuery(input: String) {
38+
query.value = input
39+
}
40+
41+
private fun givenSuggestionViewModel(results: Either<ComicError, List<Volume>>): SearchViewModel =
42+
SearchViewModel.invoke(mock {
43+
on { findByTerm(anyString()) }.thenReturn(Flowable.just(results))
44+
})
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package es.ffgiraldez.comicsearch.query.sugestion.presentation
2+
3+
import arrow.core.Either
4+
import com.nhaarman.mockitokotlin2.mock
5+
import es.ffgiraldez.comicsearch.comic.gen.query
6+
import es.ffgiraldez.comicsearch.comic.gen.suggestions
7+
import es.ffgiraldez.comicsearch.comics.domain.ComicError
8+
import es.ffgiraldez.comicsearch.platform.toFlowable
9+
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
10+
import es.ffgiraldez.comicsearch.query.base.presentation.toViewState
11+
import io.kotlintest.properties.Gen
12+
import io.kotlintest.properties.assertAll
13+
import io.kotlintest.provided.ProjectConfig
14+
import io.kotlintest.specs.StringSpec
15+
import io.reactivex.Flowable
16+
import org.mockito.ArgumentMatchers.anyString
17+
import java.util.concurrent.TimeUnit.SECONDS
18+
19+
class SuggestionViewModelSpec :
20+
StringSpec({
21+
"Suggestion ViewModel should not trigger search for empty query" {
22+
assertAll(Gen.suggestions()) { suggestions ->
23+
val viewModel = givenSuggestionViewModel(suggestions)
24+
val observer = viewModel.state.toFlowable().test()
25+
26+
viewModel.inputQuery("")
27+
28+
observer.assertNotComplete()
29+
.assertNoErrors()
30+
.assertValues(QueryViewState.idle())
31+
}
32+
}
33+
34+
"Suggestion ViewModel should trigger search for a valid query" {
35+
assertAll(Gen.suggestions(), Gen.query()) { suggestions, query ->
36+
val viewModel = givenSuggestionViewModel(suggestions)
37+
val observer = viewModel.state.toFlowable().test()
38+
val viewState = suggestions.toViewState()
39+
40+
viewModel.inputQuery(query)
41+
42+
observer.assertNotComplete()
43+
.assertNoErrors()
44+
.assertValues(QueryViewState.idle(), QueryViewState.loading(), viewState)
45+
}
46+
}
47+
48+
49+
})
50+
51+
private fun SuggestionViewModel.inputQuery(input: String) {
52+
query.value = input
53+
ProjectConfig.testScheduler.advanceTimeBy(10, SECONDS)
54+
}
55+
56+
private fun givenSuggestionViewModel(suggestions: Either<ComicError, List<String>>): SuggestionViewModel =
57+
SuggestionViewModel.invoke(mock {
58+
on { findByTerm(anyString()) }.thenReturn(Flowable.just(suggestions))
59+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package io.kotlintest.provided
2+
3+
import androidx.arch.core.executor.ArchTaskExecutor
4+
import androidx.arch.core.executor.TaskExecutor
5+
import io.kotlintest.AbstractProjectConfig
6+
import io.reactivex.plugins.RxJavaPlugins
7+
import io.reactivex.schedulers.Schedulers
8+
import io.reactivex.schedulers.TestScheduler
9+
10+
object ProjectConfig : AbstractProjectConfig() {
11+
12+
override fun parallelism(): Int = 2
13+
14+
override fun beforeAll() {
15+
super.beforeAll()
16+
ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
17+
override fun executeOnDiskIO(runnable: Runnable) {
18+
runnable.run()
19+
}
20+
21+
override fun postToMainThread(runnable: Runnable) {
22+
runnable.run()
23+
}
24+
25+
override fun isMainThread(): Boolean {
26+
return true
27+
}
28+
})
29+
30+
RxJavaPlugins.reset()
31+
RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() }
32+
RxJavaPlugins.setNewThreadSchedulerHandler { Schedulers.trampoline() }
33+
RxJavaPlugins.setComputationSchedulerHandler { testScheduler }
34+
35+
}
36+
37+
val testScheduler = TestScheduler()
38+
39+
override fun afterAll() {
40+
super.afterAll()
41+
ArchTaskExecutor.getInstance().setDelegate(null)
42+
RxJavaPlugins.reset()
43+
}
44+
}

0 commit comments

Comments
 (0)