From a69fdce26c3bd033c038a37bb5d0cbf6bbf9a263 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Thu, 27 Jan 2022 14:51:15 +0200 Subject: [PATCH] Issue#49 Add first integration test to core --- core/build.gradle | 2 + .../core/content/GetAllContentUseCase.kt | 2 + .../integration/ContentIntegrationTest.kt | 364 ++++++++++++++++++ .../fake/FakeFavouriteContentLocalStorage.kt | 30 ++ .../fake/FakeUserDataLocalStorage.kt | 6 + .../core/testutil/AwaitElementEmitCount.kt | 24 ++ 6 files changed, 428 insertions(+) create mode 100644 core/src/test/java/org/fnives/test/showcase/core/integration/ContentIntegrationTest.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/integration/fake/FakeFavouriteContentLocalStorage.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/integration/fake/FakeUserDataLocalStorage.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/testutil/AwaitElementEmitCount.kt diff --git a/core/build.gradle b/core/build.gradle index e2b19e3..3ca195f 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -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') } \ No newline at end of file diff --git a/core/src/main/java/org/fnives/test/showcase/core/content/GetAllContentUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/content/GetAllContentUseCase.kt index 24a4b63..7487d94 100644 --- a/core/src/main/java/org/fnives/test/showcase/core/content/GetAllContentUseCase.kt +++ b/core/src/main/java/org/fnives/test/showcase/core/content/GetAllContentUseCase.kt @@ -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( diff --git a/core/src/test/java/org/fnives/test/showcase/core/integration/ContentIntegrationTest.kt b/core/src/test/java/org/fnives/test/showcase/core/integration/ContentIntegrationTest.kt new file mode 100644 index 0000000..00c280d --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/integration/ContentIntegrationTest.kt @@ -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() + private val fetchContentUseCase by inject() + private val getAllContentUseCase by inject() + private val removeContentFromFavouritesUseCase by inject() + 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>(), 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) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/integration/fake/FakeFavouriteContentLocalStorage.kt b/core/src/test/java/org/fnives/test/showcase/core/integration/fake/FakeFavouriteContentLocalStorage.kt new file mode 100644 index 0000000..a420c27 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/integration/fake/FakeFavouriteContentLocalStorage.kt @@ -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>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + init { + dataFlow.tryEmit(emptyList()) + } + + override fun observeFavourites(): Flow> = 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)) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/integration/fake/FakeUserDataLocalStorage.kt b/core/src/test/java/org/fnives/test/showcase/core/integration/fake/FakeUserDataLocalStorage.kt new file mode 100644 index 0000000..a7a66a0 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/integration/fake/FakeUserDataLocalStorage.kt @@ -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 diff --git a/core/src/test/java/org/fnives/test/showcase/core/testutil/AwaitElementEmitCount.kt b/core/src/test/java/org/fnives/test/showcase/core/testutil/AwaitElementEmitCount.kt new file mode 100644 index 0000000..fee7332 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/testutil/AwaitElementEmitCount.kt @@ -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() + + init { + assert(counter > 0) + } + + fun attach(flow: Flow): Flow = + flow.onEach { + counter-- + if (counter == 0) { + completableDeferred.complete(Unit) + } + } + + suspend fun await() = completableDeferred.await() +}