Skip to content

Commit

Permalink
feat: ✅ create suggestion & search viewmodel test suite (ffgiraldez#28)
Browse files Browse the repository at this point in the history
  • Loading branch information
ffgiraldez authored Feb 26, 2019
1 parent c213837 commit 5dc2352
Show file tree
Hide file tree
Showing 11 changed files with 249 additions and 23 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
.DS_Store
/build
/captures
*.iml
*.iml
*/.kotlintest
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ dependencies {
testImplementation libs.arch_comp_room_test
testImplementation libs.arch_comp_test
testImplementation libs.junit_api
testImplementation libs.kotlintest
testImplementation libs.koin_test
testImplementation libs.mockito_kotlin

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import es.ffgiraldez.comicsearch.platform.left
import es.ffgiraldez.comicsearch.platform.right
import io.reactivex.Flowable

abstract class ComicRepository<T>(
abstract class ComicRepository<T> (
private val local: ComicLocalDataSource<T>,
private val remote: ComicRemoteDataSource<T>
) {
Expand All @@ -38,7 +38,7 @@ abstract class ComicRepository<T>(
results: Either<ComicError, List<T>>,
term: String
): Flowable<Either<ComicError, List<T>>> =
results.fold({ _ ->
results.fold({
Flowable.just(results)
}, {
local.insert(term, it).toFlowable<Either<ComicError, List<T>>>()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package es.ffgiraldez.comicsearch.query.base.presentation

import arrow.core.Either
import es.ffgiraldez.comicsearch.comics.domain.ComicError

sealed class QueryViewState<out T> {

companion object {
fun <T> result(volumeList: List<T>): QueryViewState<T> = Result(volumeList)
fun <T> result(results: List<T>): QueryViewState<T> = Result(results)
fun <T> idle(): QueryViewState<T> = Idle
fun <T> loading(): QueryViewState<T> = Loading
fun <T> error(error: ComicError): QueryViewState<T> = Error(error)
Expand All @@ -18,3 +19,7 @@ sealed class QueryViewState<out T> {

}

fun <T> Either<ComicError, List<T>>.toViewState(): QueryViewState<T> = fold(
{ QueryViewState.error(it) },
{ QueryViewState.result(it) }
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package es.ffgiraldez.comicsearch.query.search.presentation
import es.ffgiraldez.comicsearch.comics.domain.Volume
import es.ffgiraldez.comicsearch.query.base.presentation.QueryStateViewModel
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
import es.ffgiraldez.comicsearch.query.base.presentation.toViewState
import es.ffgiraldez.comicsearch.query.search.data.SearchRepository
import io.reactivex.Flowable
import org.reactivestreams.Publisher
Expand All @@ -11,20 +12,14 @@ class SearchViewModel private constructor(
queryToResult: (Flowable<String>) -> Publisher<QueryViewState<Volume>>
) : QueryStateViewModel<Volume>(queryToResult) {
companion object {
operator fun invoke(repo: SearchRepository): SearchViewModel = SearchViewModel { it ->
it.switchMap { handleQuery(repo, it) }
operator fun invoke(repo: SearchRepository): SearchViewModel = SearchViewModel {
it.switchMap { query -> handleQuery(repo, query) }
.startWith(QueryViewState.idle())
}

private fun handleQuery(repo: SearchRepository, it: String): Flowable<QueryViewState<Volume>> =
repo.findByTerm(it)
.map {
it.fold({
QueryViewState.error<Volume>(it)
}, {
QueryViewState.result(it)
})
}
private fun handleQuery(repo: SearchRepository, query: String): Flowable<QueryViewState<Volume>> =
repo.findByTerm(query)
.map { it.toViewState() }
.startWith(QueryViewState.loading())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package es.ffgiraldez.comicsearch.query.sugestion.presentation

import es.ffgiraldez.comicsearch.query.base.presentation.QueryStateViewModel
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
import es.ffgiraldez.comicsearch.query.base.presentation.toViewState
import es.ffgiraldez.comicsearch.query.sugestion.data.SuggestionRepository
import io.reactivex.Flowable
import org.reactivestreams.Publisher
Expand All @@ -15,6 +16,7 @@ class SuggestionViewModel private constructor(
it.debounce(400, TimeUnit.MILLISECONDS)
.switchMap { query -> handleQuery(query, repo) }
.startWith(QueryViewState.idle())
.distinctUntilChanged()
}

private fun handleQuery(
Expand All @@ -32,12 +34,7 @@ class SuggestionViewModel private constructor(
query: String
): Flowable<QueryViewState<String>> =
repo.findByTerm(query)
.map { suggestions ->
suggestions.fold({
QueryViewState.error<String>(it)
}, {
QueryViewState.result(it)
})
}.startWith(QueryViewState.loading())
.map { it.toViewState() }
.startWith(QueryViewState.loading())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package es.ffgiraldez.comicsearch.comic.gen

import arrow.core.Either
import es.ffgiraldez.comicsearch.comics.domain.ComicError
import es.ffgiraldez.comicsearch.comics.domain.Volume
import es.ffgiraldez.comicsearch.platform.left
import es.ffgiraldez.comicsearch.platform.right
import io.kotlintest.properties.Gen

class ComicErrorGenerator : Gen<ComicError> {
override fun constants(): Iterable<ComicError> = emptyList()

override fun random(): Sequence<ComicError> = generateSequence {
takeIf { Gen.bool().random().first() }
?.let { ComicError.EmptyResultsError } ?: ComicError.NetworkError
}
}

class QueryGenerator : Gen<String> {
override fun constants(): Iterable<String> = Gen.string().constants().filter { it.isNotEmpty() }

override fun random(): Sequence<String> = Gen.string().random().filter { it.isNotEmpty() }
}

class SuggestionGenerator : Gen<Either<ComicError, List<String>>> {
override fun constants(): Iterable<Either<ComicError, List<String>>> = emptyList()

override fun random(): Sequence<Either<ComicError, List<String>>> = generateSequence {
generateEither(Gen.bool().random().first())
}

private fun generateEither(it: Boolean): Either<ComicError, List<String>> {
return if (it) {
left(Gen.comicError().random().first())
} else {
right((1..10).fold(emptyList()) { acc, _ -> acc + Gen.query().random().iterator().next() })
}
}
}

class VolumeGenerator : Gen<Volume> {
override fun constants(): Iterable<Volume> = emptyList()

override fun random(): Sequence<Volume> = generateSequence {
Volume(
Gen.string().random().first(),
Gen.string().random().first(),
Gen.string().random().first()
)
}

}

class SearchGenerator : Gen<Either<ComicError, List<Volume>>> {
override fun constants(): Iterable<Either<ComicError, List<Volume>>> = emptyList()

override fun random(): Sequence<Either<ComicError, List<Volume>>> = generateSequence {
generateEither(Gen.bool().random().first())
}

private fun generateEither(it: Boolean): Either<ComicError, List<Volume>> {
return if (it) {
left(Gen.comicError().random().first())
} else {
right((1..10).fold(emptyList()) { acc, _ -> acc + Gen.volume().random().iterator().next() })
}
}
}


fun Gen.Companion.suggestions(): Gen<Either<ComicError, List<String>>> = SuggestionGenerator()

fun Gen.Companion.search(): Gen<Either<ComicError, List<Volume>>> = SearchGenerator()

fun Gen.Companion.comicError(): Gen<ComicError> = ComicErrorGenerator()

fun Gen.Companion.query(): Gen<String> = QueryGenerator()

fun Gen.Companion.volume(): Gen<Volume> = VolumeGenerator()
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package es.ffgiraldez.comicsearch.query.search.presentation

import arrow.core.Either
import com.nhaarman.mockitokotlin2.mock
import es.ffgiraldez.comicsearch.comic.gen.query
import es.ffgiraldez.comicsearch.comic.gen.search
import es.ffgiraldez.comicsearch.comics.domain.ComicError
import es.ffgiraldez.comicsearch.comics.domain.Volume
import es.ffgiraldez.comicsearch.platform.toFlowable
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
import es.ffgiraldez.comicsearch.query.base.presentation.toViewState
import io.kotlintest.properties.Gen
import io.kotlintest.properties.assertAll
import io.kotlintest.specs.StringSpec
import io.reactivex.Flowable
import org.mockito.ArgumentMatchers.anyString

class SearchViewModelSpec :
StringSpec({
"Search ViewModel should trigger search for a query" {
assertAll(Gen.search(), Gen.query()) { results, query ->
val viewModel = givenSuggestionViewModel(results)
val observer = viewModel.state.toFlowable().test()
val viewState = results.toViewState()

viewModel.inputQuery(query)

observer.assertNotComplete()
.assertNoErrors()
.assertValues(QueryViewState.idle(), QueryViewState.loading(), viewState)
}
}


})

private fun SearchViewModel.inputQuery(input: String) {
query.value = input
}

private fun givenSuggestionViewModel(results: Either<ComicError, List<Volume>>): SearchViewModel =
SearchViewModel.invoke(mock {
on { findByTerm(anyString()) }.thenReturn(Flowable.just(results))
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package es.ffgiraldez.comicsearch.query.sugestion.presentation

import arrow.core.Either
import com.nhaarman.mockitokotlin2.mock
import es.ffgiraldez.comicsearch.comic.gen.query
import es.ffgiraldez.comicsearch.comic.gen.suggestions
import es.ffgiraldez.comicsearch.comics.domain.ComicError
import es.ffgiraldez.comicsearch.platform.toFlowable
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
import es.ffgiraldez.comicsearch.query.base.presentation.toViewState
import io.kotlintest.properties.Gen
import io.kotlintest.properties.assertAll
import io.kotlintest.provided.ProjectConfig
import io.kotlintest.specs.StringSpec
import io.reactivex.Flowable
import org.mockito.ArgumentMatchers.anyString
import java.util.concurrent.TimeUnit.SECONDS

class SuggestionViewModelSpec :
StringSpec({
"Suggestion ViewModel should not trigger search for empty query" {
assertAll(Gen.suggestions()) { suggestions ->
val viewModel = givenSuggestionViewModel(suggestions)
val observer = viewModel.state.toFlowable().test()

viewModel.inputQuery("")

observer.assertNotComplete()
.assertNoErrors()
.assertValues(QueryViewState.idle())
}
}

"Suggestion ViewModel should trigger search for a valid query" {
assertAll(Gen.suggestions(), Gen.query()) { suggestions, query ->
val viewModel = givenSuggestionViewModel(suggestions)
val observer = viewModel.state.toFlowable().test()
val viewState = suggestions.toViewState()

viewModel.inputQuery(query)

observer.assertNotComplete()
.assertNoErrors()
.assertValues(QueryViewState.idle(), QueryViewState.loading(), viewState)
}
}


})

private fun SuggestionViewModel.inputQuery(input: String) {
query.value = input
ProjectConfig.testScheduler.advanceTimeBy(10, SECONDS)
}

private fun givenSuggestionViewModel(suggestions: Either<ComicError, List<String>>): SuggestionViewModel =
SuggestionViewModel.invoke(mock {
on { findByTerm(anyString()) }.thenReturn(Flowable.just(suggestions))
})
44 changes: 44 additions & 0 deletions app/src/test/java/io/kotlintest/provided/ProjectConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.kotlintest.provided

import androidx.arch.core.executor.ArchTaskExecutor
import androidx.arch.core.executor.TaskExecutor
import io.kotlintest.AbstractProjectConfig
import io.reactivex.plugins.RxJavaPlugins
import io.reactivex.schedulers.Schedulers
import io.reactivex.schedulers.TestScheduler

object ProjectConfig : AbstractProjectConfig() {

override fun parallelism(): Int = 2

override fun beforeAll() {
super.beforeAll()
ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
override fun executeOnDiskIO(runnable: Runnable) {
runnable.run()
}

override fun postToMainThread(runnable: Runnable) {
runnable.run()
}

override fun isMainThread(): Boolean {
return true
}
})

RxJavaPlugins.reset()
RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() }
RxJavaPlugins.setNewThreadSchedulerHandler { Schedulers.trampoline() }
RxJavaPlugins.setComputationSchedulerHandler { testScheduler }

}

val testScheduler = TestScheduler()

override fun afterAll() {
super.afterAll()
ArchTaskExecutor.getInstance().setDelegate(null)
RxJavaPlugins.reset()
}
}
1 change: 1 addition & 0 deletions dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ ext {
koin_architecture : [group: 'org.koin', name: 'koin-android-viewmodel', version: versions.koin],
koin_test : [group: 'org.koin', name: 'koin-test', version: versions.koin],
kotlin_stdlib : [group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk7', version: versions.kotlin],
kotlintest : [group: 'io.kotlintest', name: 'kotlintest-runner-junit5', version: '3.2.1'],
mockito_kotlin : [group: 'com.nhaarman.mockitokotlin2', name: 'mockito-kotlin', version: '2.1.0'],
picasso : [group: 'com.squareup.picasso', name: 'picasso', version: '2.5.2'],
retrofit : [group: 'com.squareup.retrofit2', name: 'retrofit', version: versions.retrofit],
Expand Down

0 comments on commit 5dc2352

Please sign in to comment.