Issue#49 Add first integration test to core
This commit is contained in:
parent
c4c2ea7c26
commit
a69fdce26c
6 changed files with 428 additions and 0 deletions
|
|
@ -26,4 +26,6 @@ dependencies {
|
|||
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
|
||||
testImplementation "com.squareup.retrofit2:retrofit:$retrofit_version"
|
||||
testImplementation "app.cash.turbine:turbine:$turbine_version"
|
||||
|
||||
testImplementation project(':mockserver')
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package org.fnives.test.showcase.core.content
|
|||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
|
||||
import org.fnives.test.showcase.model.content.Content
|
||||
import org.fnives.test.showcase.model.content.ContentId
|
||||
|
|
@ -18,6 +19,7 @@ class GetAllContentUseCase internal constructor(
|
|||
favouriteContentLocalStorage.observeFavourites(),
|
||||
::combineContentWithFavourites
|
||||
)
|
||||
.distinctUntilChanged()
|
||||
|
||||
companion object {
|
||||
private fun combineContentWithFavourites(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,364 @@
|
|||
package org.fnives.test.showcase.core.integration
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase
|
||||
import org.fnives.test.showcase.core.content.FetchContentUseCase
|
||||
import org.fnives.test.showcase.core.content.GetAllContentUseCase
|
||||
import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase
|
||||
import org.fnives.test.showcase.core.di.koin.createCoreModule
|
||||
import org.fnives.test.showcase.core.integration.fake.FakeFavouriteContentLocalStorage
|
||||
import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage
|
||||
import org.fnives.test.showcase.core.session.SessionExpirationListener
|
||||
import org.fnives.test.showcase.core.testutil.AwaitElementEmitCount
|
||||
import org.fnives.test.showcase.model.content.ContentId
|
||||
import org.fnives.test.showcase.model.content.FavouriteContent
|
||||
import org.fnives.test.showcase.model.network.BaseUrl
|
||||
import org.fnives.test.showcase.model.session.Session
|
||||
import org.fnives.test.showcase.model.shared.Resource
|
||||
import org.fnives.test.showcase.network.mockserver.ContentData
|
||||
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
|
||||
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
|
||||
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
|
||||
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.test.KoinTest
|
||||
import org.koin.test.inject
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.verifyNoMoreInteractions
|
||||
import org.mockito.kotlin.verifyZeroInteractions
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ContentIntegrationTest : KoinTest {
|
||||
|
||||
private lateinit var mockServerScenarioSetup: MockServerScenarioSetup
|
||||
private lateinit var fakeFavouriteContentLocalStorage: FakeFavouriteContentLocalStorage
|
||||
private lateinit var mockSessionExpirationListener: SessionExpirationListener
|
||||
private lateinit var fakeUserDataLocalStorage: FakeUserDataLocalStorage
|
||||
private val addContentToFavouriteUseCase by inject<AddContentToFavouriteUseCase>()
|
||||
private val fetchContentUseCase by inject<FetchContentUseCase>()
|
||||
private val getAllContentUseCase by inject<GetAllContentUseCase>()
|
||||
private val removeContentFromFavouritesUseCase by inject<RemoveContentFromFavouritesUseCase>()
|
||||
private val session = Session(accessToken = "login-access", refreshToken = "login-refresh")
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockSessionExpirationListener = mock()
|
||||
mockServerScenarioSetup = MockServerScenarioSetup()
|
||||
val url = mockServerScenarioSetup.start(false)
|
||||
fakeFavouriteContentLocalStorage = FakeFavouriteContentLocalStorage()
|
||||
fakeUserDataLocalStorage = FakeUserDataLocalStorage(session)
|
||||
|
||||
startKoin {
|
||||
modules(
|
||||
createCoreModule(
|
||||
baseUrl = BaseUrl(url),
|
||||
enableNetworkLogging = true,
|
||||
favouriteContentLocalStorageProvider = { fakeFavouriteContentLocalStorage },
|
||||
sessionExpirationListenerProvider = { mockSessionExpirationListener },
|
||||
userDataLocalStorageProvider = { fakeUserDataLocalStorage }
|
||||
).toList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
stopKoin()
|
||||
mockServerScenarioSetup.stop()
|
||||
}
|
||||
|
||||
@DisplayName("GIVEN normal response without favourites WHEN observed THEN data is returned")
|
||||
@Test
|
||||
fun withoutFavouritesDataIsReturned() = runTest {
|
||||
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
|
||||
val contentData = ContentData.contentSuccess.map { FavouriteContent(it, false) }
|
||||
val expected = listOf(
|
||||
Resource.Loading(),
|
||||
Resource.Success(contentData)
|
||||
)
|
||||
|
||||
val actual = async {
|
||||
getAllContentUseCase.get()
|
||||
.take(2)
|
||||
.toList()
|
||||
}
|
||||
|
||||
Assertions.assertEquals(expected, actual.await())
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
Assertions.assertSame(session, fakeUserDataLocalStorage.session)
|
||||
}
|
||||
|
||||
@DisplayName("GIVEN normal response without favourites matching WHEN observed THEN data is returned")
|
||||
@Test
|
||||
fun withoutFavouritesMatchingDataIsReturned() = runTest {
|
||||
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
|
||||
addContentToFavouriteUseCase.invoke(ContentId("non-existent-content-id"))
|
||||
advanceUntilIdle()
|
||||
val contentData = ContentData.contentSuccess.map { FavouriteContent(it, false) }
|
||||
val expected = listOf(
|
||||
Resource.Loading(),
|
||||
Resource.Success(contentData)
|
||||
)
|
||||
|
||||
val actual = async {
|
||||
getAllContentUseCase.get()
|
||||
.take(2)
|
||||
.toList()
|
||||
}
|
||||
|
||||
Assertions.assertEquals(expected, actual.await())
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
Assertions.assertSame(session, fakeUserDataLocalStorage.session)
|
||||
}
|
||||
|
||||
@DisplayName("GIVEN normal response without favourites matching WHEN observed loading and modifying favourites THEN no extra loading is emitted")
|
||||
@Test
|
||||
fun modifyingFavouritesWhileLoadingDoesntEmitNewValue() = runTest {
|
||||
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
|
||||
addContentToFavouriteUseCase.invoke(ContentId("non-existent-content-id"))
|
||||
advanceUntilIdle()
|
||||
val contentData = ContentData.contentSuccess.mapIndexed { index, it ->
|
||||
FavouriteContent(it, index == 0)
|
||||
}
|
||||
val expected = listOf(
|
||||
Resource.Loading(),
|
||||
Resource.Success(contentData)
|
||||
)
|
||||
|
||||
val actual = async {
|
||||
getAllContentUseCase.get()
|
||||
.onEach {
|
||||
if (it is Resource.Loading) {
|
||||
addContentToFavouriteUseCase.invoke(contentData.first().content.id)
|
||||
}
|
||||
}
|
||||
.take(2)
|
||||
.toList()
|
||||
}
|
||||
|
||||
Assertions.assertEquals(expected, actual.await())
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
Assertions.assertSame(session, fakeUserDataLocalStorage.session)
|
||||
}
|
||||
|
||||
@DisplayName("GIVEN normal response without favourites WHEN adding favourite and removing THEN we get proper updates")
|
||||
@Test
|
||||
fun addingRemoving() = runTest {
|
||||
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
|
||||
|
||||
val startContentData = ContentData.contentSuccess.map {
|
||||
FavouriteContent(it, isFavourite = false)
|
||||
}
|
||||
val addedFavouriteData = startContentData.mapIndexed { index, it ->
|
||||
if (index == 0) it.copy(isFavourite = true) else it
|
||||
}
|
||||
val added2ndFavouriteData = addedFavouriteData.mapIndexed { index, it ->
|
||||
if (index == 1) it.copy(isFavourite = true) else it
|
||||
}
|
||||
val removedFirstFavouriteData = added2ndFavouriteData.mapIndexed { index, it ->
|
||||
if (index == 0) it.copy(isFavourite = false) else it
|
||||
}
|
||||
val expected = listOf(
|
||||
Resource.Loading(),
|
||||
Resource.Success(startContentData),
|
||||
Resource.Success(addedFavouriteData),
|
||||
Resource.Success(added2ndFavouriteData),
|
||||
Resource.Success(removedFirstFavouriteData)
|
||||
)
|
||||
|
||||
val actual = async {
|
||||
getAllContentUseCase.get()
|
||||
.take(5)
|
||||
.toList()
|
||||
}
|
||||
getAllContentUseCase.get().take(2).toList() // let's await success request
|
||||
|
||||
addContentToFavouriteUseCase.invoke(startContentData.first().content.id)
|
||||
advanceUntilIdle()
|
||||
addContentToFavouriteUseCase.invoke(startContentData.drop(1).first().content.id)
|
||||
advanceUntilIdle()
|
||||
removeContentFromFavouritesUseCase.invoke(startContentData.first().content.id)
|
||||
advanceUntilIdle()
|
||||
|
||||
val verifyCaching = async {
|
||||
getAllContentUseCase.get().take(1).first()
|
||||
}
|
||||
|
||||
Assertions.assertIterableEquals(expected, actual.await())
|
||||
Assertions.assertEquals(expected.last(), verifyCaching.await())
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
Assertions.assertSame(session, fakeUserDataLocalStorage.session)
|
||||
}
|
||||
|
||||
@DisplayName("GIVEN normal response with favourites WHEN getting the data THEN we get proper updates")
|
||||
@Test
|
||||
fun alreadySavedFavourites() = runTest {
|
||||
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
|
||||
addContentToFavouriteUseCase.invoke(ContentData.contentSuccess.first().id)
|
||||
addContentToFavouriteUseCase.invoke(ContentData.contentSuccess.takeLast(1).first().id)
|
||||
val favouritedIndexes = listOf(0, ContentData.contentSuccess.size - 1)
|
||||
|
||||
val expectedContents = ContentData.contentSuccess.mapIndexed { index, content ->
|
||||
FavouriteContent(content, favouritedIndexes.contains(index))
|
||||
}
|
||||
|
||||
val expected = listOf(
|
||||
Resource.Loading(),
|
||||
Resource.Success(expectedContents),
|
||||
)
|
||||
|
||||
val actual = async {
|
||||
getAllContentUseCase.get()
|
||||
.take(2)
|
||||
.toList()
|
||||
}
|
||||
|
||||
Assertions.assertIterableEquals(expected, actual.await())
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
Assertions.assertSame(session, fakeUserDataLocalStorage.session)
|
||||
}
|
||||
|
||||
@DisplayName("GIVEN error response WHEN fetching THEN the data is received")
|
||||
@Test
|
||||
fun errorFetch() = runTest {
|
||||
mockServerScenarioSetup.setScenario(
|
||||
ContentScenario.Error(usingRefreshedToken = false)
|
||||
.then(ContentScenario.Success(usingRefreshedToken = false))
|
||||
)
|
||||
|
||||
val expectedContents = ContentData.contentSuccess.map { content ->
|
||||
FavouriteContent(content, false)
|
||||
}
|
||||
val expected = listOf(
|
||||
Resource.Loading(),
|
||||
Resource.Error(mock()),
|
||||
Resource.Loading(),
|
||||
Resource.Success(expectedContents),
|
||||
)
|
||||
|
||||
val awaitElementEmitionCount = AwaitElementEmitCount(2)
|
||||
val actual = async {
|
||||
getAllContentUseCase.get()
|
||||
.take(4)
|
||||
.let(awaitElementEmitionCount::attach)
|
||||
.toList()
|
||||
}
|
||||
awaitElementEmitionCount.await() // await 2 emissions, aka the request to finish
|
||||
|
||||
fetchContentUseCase.invoke()
|
||||
|
||||
val actualValues = actual.await()
|
||||
Assertions.assertEquals(expected[0], actualValues[0])
|
||||
Assertions.assertTrue(actualValues[1] is Resource.Error, "Resource is Error")
|
||||
Assertions.assertTrue((actualValues[1] as Resource.Error).error is NetworkException, "Resource is Network Error")
|
||||
Assertions.assertEquals(expected[2], actualValues[2])
|
||||
Assertions.assertEquals(expected[3], actualValues[3])
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
Assertions.assertSame(session, fakeUserDataLocalStorage.session)
|
||||
}
|
||||
|
||||
@DisplayName("GIVEN proper response WHEN fetching THEN the data is received")
|
||||
@Test
|
||||
fun fetchingAgain() = runTest {
|
||||
mockServerScenarioSetup.setScenario(
|
||||
ContentScenario.Success(usingRefreshedToken = false)
|
||||
.then(ContentScenario.SuccessWithMissingFields(usingRefreshedToken = false))
|
||||
)
|
||||
|
||||
val expectedContents = ContentData.contentSuccess.map { content ->
|
||||
FavouriteContent(content, false)
|
||||
}
|
||||
val expectedContents2 = ContentData.contentSuccessWithMissingFields.map { content ->
|
||||
FavouriteContent(content, false)
|
||||
}
|
||||
val expected = listOf(
|
||||
Resource.Loading(),
|
||||
Resource.Success(expectedContents),
|
||||
Resource.Loading(),
|
||||
Resource.Success(expectedContents2),
|
||||
)
|
||||
|
||||
val awaitElementEmitionCount = AwaitElementEmitCount(2)
|
||||
val actual = async {
|
||||
getAllContentUseCase.get()
|
||||
.take(4)
|
||||
.let(awaitElementEmitionCount::attach)
|
||||
.toList()
|
||||
}
|
||||
awaitElementEmitionCount.await() // await 2 emissions, aka the request to finish
|
||||
|
||||
fetchContentUseCase.invoke()
|
||||
|
||||
Assertions.assertIterableEquals(expected, actual.await())
|
||||
}
|
||||
|
||||
@DisplayName("GIVEN session expiration then proper response WHEN observing THEN the data is received")
|
||||
@Test
|
||||
fun sessionRefreshing() = runTest {
|
||||
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success)
|
||||
.setScenario(
|
||||
ContentScenario.Unauthorized(usingRefreshedToken = false)
|
||||
.then(ContentScenario.Success(usingRefreshedToken = true))
|
||||
)
|
||||
|
||||
val expectedContents = ContentData.contentSuccess.map { content ->
|
||||
FavouriteContent(content, false)
|
||||
}
|
||||
val expected = listOf(
|
||||
Resource.Loading(),
|
||||
Resource.Success(expectedContents)
|
||||
)
|
||||
|
||||
val actual = async {
|
||||
getAllContentUseCase.get()
|
||||
.take(2)
|
||||
.toList()
|
||||
}
|
||||
|
||||
Assertions.assertIterableEquals(expected, actual.await())
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
val expectedSession = Session(accessToken = "refreshed-access", refreshToken = "refreshed-refresh")
|
||||
Assertions.assertEquals(expectedSession, fakeUserDataLocalStorage.session)
|
||||
}
|
||||
|
||||
@DisplayName("GIVEN session expiration and failing token-refresh response WHEN observing THEN session expiration is attached")
|
||||
@Test
|
||||
fun sessionExpiration() = runTest {
|
||||
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error)
|
||||
.setScenario(
|
||||
ContentScenario.Unauthorized(usingRefreshedToken = false)
|
||||
.then(ContentScenario.Success(usingRefreshedToken = true))
|
||||
)
|
||||
|
||||
val actual = async {
|
||||
getAllContentUseCase.get()
|
||||
.take(2)
|
||||
.toList()
|
||||
}
|
||||
|
||||
val actualValues = actual.await()
|
||||
Assertions.assertEquals(Resource.Loading<List<FavouriteContent>>(), actualValues[0])
|
||||
Assertions.assertTrue(actualValues[1] is Resource.Error, "Resource is Error")
|
||||
Assertions.assertTrue((actualValues[1] as Resource.Error).error is NetworkException, "Resource is Network Error")
|
||||
|
||||
verify(mockSessionExpirationListener, times(1)).onSessionExpired()
|
||||
verifyNoMoreInteractions(mockSessionExpirationListener)
|
||||
|
||||
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package org.fnives.test.showcase.core.integration.fake
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
|
||||
import org.fnives.test.showcase.model.content.ContentId
|
||||
|
||||
class FakeFavouriteContentLocalStorage : FavouriteContentLocalStorage {
|
||||
|
||||
private val dataFlow = MutableSharedFlow<List<ContentId>>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
init {
|
||||
dataFlow.tryEmit(emptyList())
|
||||
}
|
||||
|
||||
override fun observeFavourites(): Flow<List<ContentId>> = dataFlow.asSharedFlow()
|
||||
|
||||
override suspend fun markAsFavourite(contentId: ContentId) {
|
||||
dataFlow.emit(dataFlow.replayCache.first().plus(contentId))
|
||||
}
|
||||
|
||||
override suspend fun deleteAsFavourite(contentId: ContentId) {
|
||||
dataFlow.emit(dataFlow.replayCache.first().minus(contentId))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.fnives.test.showcase.core.integration.fake
|
||||
|
||||
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
|
||||
import org.fnives.test.showcase.model.session.Session
|
||||
|
||||
class FakeUserDataLocalStorage(override var session: Session? = null) : UserDataLocalStorage
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package org.fnives.test.showcase.core.testutil
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
class AwaitElementEmitCount(private var counter: Int) {
|
||||
|
||||
private val completableDeferred = CompletableDeferred<Unit>()
|
||||
|
||||
init {
|
||||
assert(counter > 0)
|
||||
}
|
||||
|
||||
fun <T> attach(flow: Flow<T>): Flow<T> =
|
||||
flow.onEach {
|
||||
counter--
|
||||
if (counter == 0) {
|
||||
completableDeferred.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun await() = completableDeferred.await()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue