From 3c80744f6d90d00e6c32c89c712e29cf923b1198 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Sat, 22 Jan 2022 10:55:02 +0200 Subject: [PATCH] Update core tests to the coroutines tests 1.6.0 --- codekata/core.instructionset | 109 +++++--- core/build.gradle | 3 +- .../AddContentToFavouriteUseCaseTest.kt | 7 +- .../content/CodeKataContentRepositoryTest.kt | 12 +- .../core/content/ContentRepositoryTest.kt | 96 ++++--- .../core/content/FetchContentUseCaseTest.kt | 6 +- .../core/content/GetAllContentUseCaseTest.kt | 242 ++++++++---------- .../RemoveContentFromFavouritesUseCaseTest.kt | 5 +- .../showcase/core/login/LoginUseCaseTest.kt | 12 +- .../core/login/hilt/LogoutUseCaseTest.kt | 5 +- .../core/login/koin/LogoutUseCaseTest.kt | 3 +- .../showcase/core/shared/AnswerUtilsKtTest.kt | 9 +- gradlescripts/versions.gradle | 3 +- 13 files changed, 272 insertions(+), 240 deletions(-) diff --git a/codekata/core.instructionset b/codekata/core.instructionset index 1f505fc..c85b86a 100644 --- a/codekata/core.instructionset +++ b/codekata/core.instructionset @@ -133,12 +133,12 @@ verifyZeroInteractions(mockUserDataLocalStorage) // assert we didn't modify our But something is wrong, the invoke method cannot be executed since it's a suspending function. -To test coroutines we will use `runBlockingTest`, this creates a blocking coroutine for us to test suspend functions, together it will look like: +To test coroutines we will use `runTest`, this creates a blocking coroutine for us to test suspend functions, together it will look like: ```kotlin @DisplayName("GIVEN empty username WHEN trying to login THEN invalid username is returned") @Test -fun emptyUserNameReturnsLoginStatusError() = runBlockingTest { +fun emptyUserNameReturnsLoginStatusError() = runTest { val expected = Answer.Success(LoginStatus.INVALID_USERNAME) val actual = sut.invoke(LoginCredentials("", "a")) @@ -160,7 +160,7 @@ This is really similar, so try to write it on your own, but for progress the cod ```kotlin @DisplayName("GIVEN empty password WHEN trying to login THEN invalid password is returned") @Test -fun emptyPasswordNameReturnsLoginStatusError() = runBlockingTest { +fun emptyPasswordNameReturnsLoginStatusError() = runTest { val expected = Answer.Success(LoginStatus.INVALID_PASSWORD) val actual = sut.invoke(LoginCredentials("a", "")) @@ -212,7 +212,7 @@ Together: ```kotlin @DisplayName("GIVEN invalid credentials response WHEN trying to login THEN invalid credentials is returned") @Test -fun invalidLoginResponseReturnInvalidCredentials() = runBlockingTest { +fun invalidLoginResponseReturnInvalidCredentials() = runTest { val expected = Answer.Success(LoginStatus.INVALID_CREDENTIALS) whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) .doReturn(LoginStatusResponses.InvalidCredentials) @@ -240,7 +240,7 @@ The full code: ```kotlin @DisplayName("GIVEN success response WHEN trying to login THEN session is saved and success is returned") @Test -fun validResponseResultsInSavingSessionAndSuccessReturned() = runBlockingTest { +fun validResponseResultsInSavingSessionAndSuccessReturned() = runTest { val expected = Answer.Success(LoginStatus.SUCCESS) whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) .doReturn(LoginStatusResponses.Success(Session("c", "d"))) @@ -297,7 +297,7 @@ together: ```kotlin @DisplayName("GIVEN error resposne WHEN trying to login THEN session is not touched and error is returned") @Test -fun invalidResponseResultsInErrorReturned() = runBlockingTest { +fun invalidResponseResultsInErrorReturned() = runTest { val exception = RuntimeException() val expected = Answer.Error(UnexpectedException(exception)) whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) @@ -500,34 +500,9 @@ suspendedRequest.complete(Unit) ### 6. `whenFetchingRequestIsCalledAgain` -We still didn't even touch the fetch method so let's test the that behaviour next: +We still didn't even touch the fetch method so let's test the behaviour next: -However the main issue here is, when to call fetch. If we call after `take()` we will never reach it, but if we call it before then it doesn't test the right behaviour. -We need to do it async, but async means it's not linear, thus our request could become shaky. For this we will use TestCoroutineDispatcher. - -Let's add this to our setup: -```kotlin -private lateinit var sut: ContentRepository -private lateinit var mockContentRemoteSource: ContentRemoteSource -private lateinit var testDispatcher: TestCoroutineDispatcher - -@BeforeEach -fun setUp() { - testDispatcher = TestCoroutineDispatcher() - testDispatcher.pauseDispatcher() // we pause the dispatcher so we have full control over it - mockContentRemoteSource = mock() - sut = ContentRepository(mockContentRemoteSource) -} -``` - -Next we should use the same dispatcher in our test so: -```kotlin -fun whenFetchingRequestIsCalledAgain() = runBlockingTest(testDispatcher) { - -} -``` - -Okay with this we should write our setup: +We want to get the first result triggered by the subscription to the flow, and the again another loading and result after a call to `fetch`, so the setup would be: ```kotlin val exception = RuntimeException() val expected = listOf( @@ -542,19 +517,42 @@ whenever(mockContentRemoteSource.get()).doAnswer { } ``` -Our action will need to use async and advance to coroutines so we can are testing the correct behaviour: +However the main issue here is, when to call fetch. If we call after `take()` we will never reach it, but if we call it before then it doesn't test the right behaviour. +We need to do it async: + ```kotlin -val actual = async(testDispatcher) { sut.contents.take(4).toList() } -testDispatcher.advanceUntilIdle() // we ensure the async is progressing as much as it can (thus receiving the first to values) +val actual = async { sut.contents.take(4).toList() } sut.fetch() -testDispatcher.advanceUntilIdle() // ensure the async progresses further now, since we give it additional action to take. ``` -Our verification as usual is really simple +And the verification as usual is really simple ```kotlin Assertions.assertEquals(expected, actual.await()) ``` +However this test will hang. This is because `runTest` uses by default `StandardTestDispatcher` which doesn't enter child coroutines immediately and the async block will only be executed after the call to fetch. +This is a good thing because it gives us more control over the order of execution and as a result our test are not shaky. +To make sure that `fetch` is called only when `take` suspends, we can call `advanceUntilIdle` which will give the opportunity of the async block to execute. +So our test becomes: +```kotlin +val actual = async { sut.contents.take(4).toList() } +advanceUntilIdle() +sut.fetch() +``` + +Alternatively we can make `runTest` use `UnconfinedTestDispatcher` which will enter child coroutines eagerly, so our async will be executed until it suspends and only after the main execution path will continue with the call to `fetch` and we don't need `advanceUntilIdle` anymore. +```kotlin +@Test +fun whenFetchingRequestIsCalledAgain() = runTest(UnconfinedTestDispatcher()) { + ... // setup here + + val actual = async { sut.contents.take(4).toList() } + sut.fetch() + + Assertions.assertEquals(expected, actual.await()) +} +``` + Now we can test even complicated interactions between methods and classes with TestCoroutineDispatcher. ### 7. `noAdditionalItemsEmitted` @@ -564,6 +562,7 @@ So we also need to test that this assumption is correct. I think the best place to start from is our most complicated test `whenFetchingRequestIsCalledAgain` since this is the one most likely add additional unexpected values. +# TODO this doesn't apply to `runTest` Luckily `runBlockingTest` is helpful here: if a coroutine didn't finish properly it will throw an IllegalStateException. So all we need to do is to request more than elements it should send out and expect an IllegalStateException from runBlocking. @@ -594,6 +593,40 @@ Assertions.assertThrows(IllegalStateException::class.java) { } ``` +### 8. `noAdditionalItemsEmittedWithTurbine` + +Turbine is library that provides some testing utilities for Flow. +The entrypoint is the `test` extension which collects the flow and gives you the opportunity to +assert the collected events. + +To receive a new item from the flow we call `awaitItem()`, and to verify that no more items are +emitted we expect the result of `cancelAndConsumeRemainingEvents()` to be an empty list. + +Keeping the same setup as before we can use turbine to test `contents` as follows: +```kotlin +sut.contents.test { + Assertions.assertEquals(expected[0], awaitItem()) + Assertions.assertEquals(expected[1], awaitItem()) + sut.fetch() + Assertions.assertEquals(expected[2], awaitItem()) + Assertions.assertEquals(expected[3], awaitItem()) + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) +} +``` + +We can reorganize a bit the code. We can move the `fetch` before the first `awaitItem`, +because `test` will immediately collect and buffer the first Loading and Success, so we can +assert the items in a for loop like this: +```kotlin +sut.contents.test { + sut.fetch() + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) +} +``` + ## Conclusion Here we went over most common cases when you need to test simple java / kotlin files with no reference to networking or android: diff --git a/core/build.gradle b/core/build.gradle index 5789c72..33ca51c 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -11,7 +11,7 @@ java { compileKotlin { kotlinOptions { - freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" + freeCompilerArgs += ['-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi'] } } @@ -38,4 +38,5 @@ dependencies { testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version" kaptTest "com.google.dagger:dagger-compiler:$hilt_version" testImplementation "com.squareup.retrofit2:retrofit:$retrofit_version" + testImplementation "app.cash.turbine:turbine:$turbine_version" } \ No newline at end of file diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCaseTest.kt index 129d812..98815f0 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCaseTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCaseTest.kt @@ -1,9 +1,10 @@ package org.fnives.test.showcase.core.content import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage import org.fnives.test.showcase.model.content.ContentId +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -36,7 +37,7 @@ internal class AddContentToFavouriteUseCaseTest { @DisplayName("GIVEN contentId WHEN called THEN storage is called") @Test - fun contentIdIsDelegatedToStorage() = runBlockingTest { + fun contentIdIsDelegatedToStorage() = runTest { sut.invoke(ContentId("a")) verify(mockFavouriteContentLocalStorage, times(1)).markAsFavourite(ContentId("a")) @@ -45,7 +46,7 @@ internal class AddContentToFavouriteUseCaseTest { @DisplayName("GIVEN throwing local storage WHEN thrown THEN its propagated") @Test - fun storageThrowingIsPropagated() = runBlockingTest { + fun storageThrowingIsPropagated() = runTest { whenever(mockFavouriteContentLocalStorage.markAsFavourite(ContentId("a"))).doThrow( RuntimeException() ) diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/CodeKataContentRepositoryTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/CodeKataContentRepositoryTest.kt index fb70a9a..ee79ad0 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/content/CodeKataContentRepositoryTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/content/CodeKataContentRepositoryTest.kt @@ -1,6 +1,6 @@ package org.fnives.test.showcase.core.content -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.DisplayName @@ -20,27 +20,27 @@ class CodeKataContentRepositoryTest { @DisplayName("GIVEN content response WHEN content observed THEN loading AND data is returned") @Test - fun happyFlow() = runBlockingTest { + fun happyFlow() = runTest { } @DisplayName("GIVEN content error WHEN content observed THEN loading AND data is returned") @Test - fun errorFlow() = runBlockingTest { + fun errorFlow() = runTest { } @DisplayName("GIVEN saved cache WHEN collected THEN cache is returned") @Test - fun verifyCaching() = runBlockingTest { + fun verifyCaching() = runTest { } @DisplayName("GIVEN no response from remote source WHEN content observed THEN loading is returned") @Test - fun loadingIsShownBeforeTheRequestIsReturned() = runBlockingTest { + fun loadingIsShownBeforeTheRequestIsReturned() = runTest { } @DisplayName("GIVEN content response THEN error WHEN fetched THEN returned states are loading data loading error") @Test - fun whenFetchingRequestIsCalledAgain() = runBlockingTest { + fun whenFetchingRequestIsCalledAgain() = runTest { } @DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted") diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/ContentRepositoryTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/ContentRepositoryTest.kt index eaeb901..ec2f417 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/content/ContentRepositoryTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/content/ContentRepositoryTest.kt @@ -1,11 +1,12 @@ package org.fnives.test.showcase.core.content +import app.cash.turbine.test import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.shared.UnexpectedException import org.fnives.test.showcase.model.content.Content import org.fnives.test.showcase.model.content.ContentId @@ -31,12 +32,9 @@ internal class ContentRepositoryTest { private lateinit var sut: ContentRepository private lateinit var mockContentRemoteSource: ContentRemoteSource - private lateinit var testDispatcher: TestCoroutineDispatcher @BeforeEach fun setUp() { - testDispatcher = TestCoroutineDispatcher() - testDispatcher.pauseDispatcher() mockContentRemoteSource = mock() sut = ContentRepository(mockContentRemoteSource) } @@ -49,20 +47,13 @@ internal class ContentRepositoryTest { @DisplayName("GIVEN content response WHEN content observed THEN loading AND data is returned") @Test - fun happyFlow() = runBlockingTest { + fun happyFlow() = runTest { val expected = listOf( Resource.Loading(), Resource.Success(listOf(Content(ContentId("a"), "", "", ImageUrl("")))) ) whenever(mockContentRemoteSource.get()).doReturn( - listOf( - Content( - ContentId("a"), - "", - "", - ImageUrl("") - ) - ) + listOf(Content(ContentId("a"), "", "", ImageUrl(""))) ) val actual = sut.contents.take(2).toList() @@ -72,7 +63,7 @@ internal class ContentRepositoryTest { @DisplayName("GIVEN content error WHEN content observed THEN loading AND data is returned") @Test - fun errorFlow() = runBlockingTest { + fun errorFlow() = runTest { val exception = RuntimeException() val expected = listOf( Resource.Loading(), @@ -87,7 +78,7 @@ internal class ContentRepositoryTest { @DisplayName("GIVEN saved cache WHEN collected THEN cache is returned") @Test - fun verifyCaching() = runBlockingTest { + fun verifyCaching() = runTest { val content = Content(ContentId("1"), "", "", ImageUrl("")) val expected = listOf(Resource.Success(listOf(content))) whenever(mockContentRemoteSource.get()).doReturn(listOf(content)) @@ -101,7 +92,7 @@ internal class ContentRepositoryTest { @DisplayName("GIVEN no response from remote source WHEN content observed THEN loading is returned") @Test - fun loadingIsShownBeforeTheRequestIsReturned() = runBlockingTest { + fun loadingIsShownBeforeTheRequestIsReturned() = runTest { val expected = Resource.Loading>() val suspendedRequest = CompletableDeferred() whenever(mockContentRemoteSource.get()).doSuspendableAnswer { @@ -117,33 +108,32 @@ internal class ContentRepositoryTest { @DisplayName("GIVEN content response THEN error WHEN fetched THEN returned states are loading data loading error") @Test - fun whenFetchingRequestIsCalledAgain() = - runBlockingTest(testDispatcher) { - val exception = RuntimeException() - val expected = listOf( - Resource.Loading(), - Resource.Success(emptyList()), - Resource.Loading(), - Resource.Error>(UnexpectedException(exception)) - ) - var first = true - whenever(mockContentRemoteSource.get()).doAnswer { - if (first) emptyList().also { first = false } else throw exception - } - - val actual = async(testDispatcher) { sut.contents.take(4).toList() } - testDispatcher.advanceUntilIdle() - sut.fetch() - testDispatcher.advanceUntilIdle() - - Assertions.assertEquals(expected, actual.await()) + fun whenFetchingRequestIsCalledAgain() = runTest(UnconfinedTestDispatcher()) { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Success(emptyList()), + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception } + val actual = async { + sut.contents.take(4).toList() + } + sut.fetch() + + Assertions.assertEquals(expected, actual.await()) + } + @DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted") @Test fun noAdditionalItemsEmitted() { Assertions.assertThrows(IllegalStateException::class.java) { - runBlockingTest(testDispatcher) { + runTest(UnconfinedTestDispatcher()) { val exception = RuntimeException() val expected = listOf( Resource.Loading(), @@ -156,13 +146,37 @@ internal class ContentRepositoryTest { if (first) emptyList().also { first = false } else throw exception } - val actual = async(testDispatcher) { sut.contents.take(5).toList() } - testDispatcher.advanceUntilIdle() + val actual = async { + sut.contents.take(5).toList() + } sut.fetch() - testDispatcher.advanceUntilIdle() Assertions.assertEquals(expected, actual.await()) } } } + + @DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted") + @Test + fun noAdditionalItemsEmittedWithTurbine() = runTest { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Success(emptyList()), + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception + } + + sut.contents.test { + sut.fetch() + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } } diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/FetchContentUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/FetchContentUseCaseTest.kt index 2490ec1..46adab1 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/content/FetchContentUseCaseTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/content/FetchContentUseCaseTest.kt @@ -1,7 +1,7 @@ package org.fnives.test.showcase.core.content import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -34,7 +34,7 @@ internal class FetchContentUseCaseTest { @DisplayName("WHEN called THEN repository is called") @Test - fun whenCalledRepositoryIsFetched() = runBlockingTest { + fun whenCalledRepositoryIsFetched() = runTest { sut.invoke() verify(mockContentRepository, times(1)).fetch() @@ -43,7 +43,7 @@ internal class FetchContentUseCaseTest { @DisplayName("GIVEN throwing local storage WHEN thrown THEN its thrown") @Test - fun whenRepositoryThrowsUseCaseAlsoThrows() = runBlockingTest { + fun whenRepositoryThrowsUseCaseAlsoThrows() = runTest { whenever(mockContentRepository.fetch()).doThrow(RuntimeException()) assertThrows(RuntimeException::class.java) { diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/GetAllContentUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/GetAllContentUseCaseTest.kt index c673592..4f51d4d 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/content/GetAllContentUseCaseTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/content/GetAllContentUseCaseTest.kt @@ -1,12 +1,12 @@ package org.fnives.test.showcase.core.content -import kotlinx.coroutines.ExperimentalCoroutinesApi +import app.cash.turbine.test import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.TestCoroutineDispatcher import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest 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 @@ -22,7 +22,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @Suppress("TestFunctionName") -@OptIn(ExperimentalCoroutinesApi::class) internal class GetAllContentUseCaseTest { private lateinit var sut: GetAllContentUseCase @@ -30,11 +29,9 @@ internal class GetAllContentUseCaseTest { private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage private lateinit var contentFlow: MutableStateFlow>> private lateinit var favouriteContentIdFlow: MutableStateFlow> - private lateinit var testDispatcher: TestCoroutineDispatcher @BeforeEach fun setUp() { - testDispatcher = TestCoroutineDispatcher() mockFavouriteContentLocalStorage = mock() mockContentRepository = mock() favouriteContentIdFlow = MutableStateFlow(emptyList()) @@ -48,187 +45,168 @@ internal class GetAllContentUseCaseTest { @DisplayName("GIVEN loading AND empty favourite WHEN observed THEN loading is shown") @Test - fun loadingResourceWithNoFavouritesResultsInLoadingResource() = - runBlockingTest(testDispatcher) { - favouriteContentIdFlow.value = emptyList() - contentFlow.value = Resource.Loading() - val expected = Resource.Loading>() + fun loadingResourceWithNoFavouritesResultsInLoadingResource() = runTest { + favouriteContentIdFlow.value = emptyList() + contentFlow.value = Resource.Loading() + val expected = Resource.Loading>() - val actual = sut.get().take(1).toList() + val actual = sut.get().take(1).toList() - Assertions.assertEquals(listOf(expected), actual) - } + Assertions.assertEquals(listOf(expected), actual) + } @DisplayName("GIVEN loading AND listOfFavourite WHEN observed THEN loading is shown") @Test - fun loadingResourceWithFavouritesResultsInLoadingResource() = - runBlockingTest(testDispatcher) { - favouriteContentIdFlow.value = listOf(ContentId("a")) - contentFlow.value = Resource.Loading() - val expected = Resource.Loading>() + fun loadingResourceWithFavouritesResultsInLoadingResource() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + contentFlow.value = Resource.Loading() + val expected = Resource.Loading>() - val actual = sut.get().take(1).toList() + val actual = sut.get().take(1).toList() - Assertions.assertEquals(listOf(expected), actual) - } + Assertions.assertEquals(listOf(expected), actual) + } @DisplayName("GIVEN error AND empty favourite WHEN observed THEN error is shown") @Test - fun errorResourceWithNoFavouritesResultsInErrorResource() = - runBlockingTest(testDispatcher) { - favouriteContentIdFlow.value = emptyList() - val exception = Throwable() - contentFlow.value = Resource.Error(exception) - val expected = Resource.Error>(exception) + fun errorResourceWithNoFavouritesResultsInErrorResource() = runTest { + favouriteContentIdFlow.value = emptyList() + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = Resource.Error>(exception) - val actual = sut.get().take(1).toList() + val actual = sut.get().take(1).toList() - Assertions.assertEquals(listOf(expected), actual) - } + Assertions.assertEquals(listOf(expected), actual) + } @DisplayName("GIVEN error AND listOfFavourite WHEN observed THEN error is shown") @Test - fun errorResourceWithFavouritesResultsInErrorResource() = - runBlockingTest(testDispatcher) { - favouriteContentIdFlow.value = listOf(ContentId("b")) - val exception = Throwable() - contentFlow.value = Resource.Error(exception) - val expected = Resource.Error>(exception) + fun errorResourceWithFavouritesResultsInErrorResource() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("b")) + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = Resource.Error>(exception) - val actual = sut.get().take(1).toList() + val actual = sut.get().take(1).toList() - Assertions.assertEquals(listOf(expected), actual) - } + Assertions.assertEquals(listOf(expected), actual) + } @DisplayName("GIVEN listOfContent AND empty favourite WHEN observed THEN favourites are returned") @Test - fun successResourceWithNoFavouritesResultsInNoFavouritedItems() = - runBlockingTest(testDispatcher) { - favouriteContentIdFlow.value = emptyList() - val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) - contentFlow.value = Resource.Success(listOf(content)) - val items = listOf( - FavouriteContent(content, false) - ) - val expected = Resource.Success(items) + fun successResourceWithNoFavouritesResultsInNoFavouritedItems() = runTest { + favouriteContentIdFlow.value = emptyList() + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, false) + ) + val expected = Resource.Success(items) - val actual = sut.get().take(1).toList() + val actual = sut.get().take(1).toList() - Assertions.assertEquals(listOf(expected), actual) - } + Assertions.assertEquals(listOf(expected), actual) + } @DisplayName("GIVEN listOfContent AND other favourite id WHEN observed THEN favourites are returned") @Test - fun successResourceWithDifferentFavouritesResultsInNoFavouritedItems() = - runBlockingTest(testDispatcher) { - favouriteContentIdFlow.value = listOf(ContentId("x")) - val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) - contentFlow.value = Resource.Success(listOf(content)) - val items = listOf( - FavouriteContent(content, false) - ) - val expected = Resource.Success(items) + fun successResourceWithDifferentFavouritesResultsInNoFavouritedItems() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("x")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, false) + ) + val expected = Resource.Success(items) - val actual = sut.get().take(1).toList() + val actual = sut.get().take(1).toList() - Assertions.assertEquals(listOf(expected), actual) - } + Assertions.assertEquals(listOf(expected), actual) + } @DisplayName("GIVEN listOfContent AND same favourite id WHEN observed THEN favourites are returned") @Test - fun successResourceWithSameFavouritesResultsInFavouritedItems() = - runBlockingTest(testDispatcher) { - favouriteContentIdFlow.value = listOf(ContentId("a")) - val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) - contentFlow.value = Resource.Success(listOf(content)) - val items = listOf( - FavouriteContent(content, true) - ) - val expected = Resource.Success(items) + fun successResourceWithSameFavouritesResultsInFavouritedItems() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, true) + ) + val expected = Resource.Success(items) - val actual = sut.get().take(1).toList() + val actual = sut.get().take(1).toList() - Assertions.assertEquals(listOf(expected), actual) - } + Assertions.assertEquals(listOf(expected), actual) + } @DisplayName("GIVEN loading then data then added favourite WHEN observed THEN loading then correct favourites are returned") @Test - fun whileLoadingAndAddingItemsReactsProperly() = - runBlockingTest(testDispatcher) { - favouriteContentIdFlow.value = emptyList() - val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) - contentFlow.value = Resource.Loading() - val expected = listOf( - Resource.Loading(), - Resource.Success(listOf(FavouriteContent(content, false))), - Resource.Success(listOf(FavouriteContent(content, true))) - ) - - val actual = async(testDispatcher) { - sut.get().take(3).toList() - } - testDispatcher.advanceUntilIdle() + fun whileLoadingAndAddingItemsReactsProperly() = runTest { + favouriteContentIdFlow.value = emptyList() + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, false))), + Resource.Success(listOf(FavouriteContent(content, true))) + ) + sut.get().test { contentFlow.value = Resource.Success(listOf(content)) - testDispatcher.advanceUntilIdle() - favouriteContentIdFlow.value = listOf(ContentId("a")) - testDispatcher.advanceUntilIdle() - Assertions.assertEquals(expected, actual.await()) + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) } + } @DisplayName("GIVEN loading then data then removed favourite WHEN observed THEN loading then correct favourites are returned") @Test - fun whileLoadingAndRemovingItemsReactsProperly() = - runBlockingTest(testDispatcher) { - favouriteContentIdFlow.value = listOf(ContentId("a")) - val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) - contentFlow.value = Resource.Loading() - val expected = listOf( - Resource.Loading(), - Resource.Success(listOf(FavouriteContent(content, true))), - Resource.Success(listOf(FavouriteContent(content, false))) - ) - - val actual = async(testDispatcher) { - sut.get().take(3).toList() - } - testDispatcher.advanceUntilIdle() + fun whileLoadingAndRemovingItemsReactsProperly() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, true))), + Resource.Success(listOf(FavouriteContent(content, false))) + ) + sut.get().test { contentFlow.value = Resource.Success(listOf(content)) - testDispatcher.advanceUntilIdle() - favouriteContentIdFlow.value = emptyList() - testDispatcher.advanceUntilIdle() - Assertions.assertEquals(expected, actual.await()) + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) } + } @DisplayName("GIVEN loading then data then loading WHEN observed THEN loading then correct favourites then loading are returned") @Test - fun loadingThenDataThenLoadingReactsProperly() = - runBlockingTest(testDispatcher) { - favouriteContentIdFlow.value = listOf(ContentId("a")) - val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) - contentFlow.value = Resource.Loading() - val expected = listOf( - Resource.Loading(), - Resource.Success(listOf(FavouriteContent(content, true))), - Resource.Loading() - ) - - val actual = async(testDispatcher) { - sut.get().take(3).toList() - } - testDispatcher.advanceUntilIdle() + fun loadingThenDataThenLoadingReactsProperly() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, true))), + Resource.Loading() + ) + sut.get().test { contentFlow.value = Resource.Success(listOf(content)) - testDispatcher.advanceUntilIdle() - contentFlow.value = Resource.Loading() - testDispatcher.advanceUntilIdle() - Assertions.assertEquals(expected, actual.await()) + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) } + } } diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCaseTest.kt index 3b896ce..d020b03 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCaseTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCaseTest.kt @@ -2,6 +2,7 @@ package org.fnives.test.showcase.core.content import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage import org.fnives.test.showcase.model.content.ContentId import org.junit.jupiter.api.Assertions @@ -36,7 +37,7 @@ internal class RemoveContentFromFavouritesUseCaseTest { @DisplayName("GIVEN contentId WHEN called THEN storage is called") @Test - fun givenContentIdCallsStorage() = runBlockingTest { + fun givenContentIdCallsStorage() = runTest { sut.invoke(ContentId("a")) verify(mockFavouriteContentLocalStorage, times(1)).deleteAsFavourite(ContentId("a")) @@ -45,7 +46,7 @@ internal class RemoveContentFromFavouritesUseCaseTest { @DisplayName("GIVEN throwing local storage WHEN thrown THEN its propogated") @Test - fun storageExceptionThrowingIsPropogated() = runBlockingTest { + fun storageExceptionThrowingIsPropogated() = runTest { whenever(mockFavouriteContentLocalStorage.deleteAsFavourite(ContentId("a"))).doThrow(RuntimeException()) Assertions.assertThrows(RuntimeException::class.java) { diff --git a/core/src/test/java/org/fnives/test/showcase/core/login/LoginUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/login/LoginUseCaseTest.kt index 142593f..b06d399 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/login/LoginUseCaseTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/login/LoginUseCaseTest.kt @@ -1,6 +1,6 @@ package org.fnives.test.showcase.core.login -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.shared.UnexpectedException import org.fnives.test.showcase.core.storage.UserDataLocalStorage import org.fnives.test.showcase.model.auth.LoginCredentials @@ -38,7 +38,7 @@ internal class LoginUseCaseTest { @DisplayName("GIVEN empty username WHEN trying to login THEN invalid username is returned") @Test - fun emptyUserNameReturnsLoginStatusError() = runBlockingTest { + fun emptyUserNameReturnsLoginStatusError() = runTest { val expected = Answer.Success(LoginStatus.INVALID_USERNAME) val actual = sut.invoke(LoginCredentials("", "a")) @@ -50,7 +50,7 @@ internal class LoginUseCaseTest { @DisplayName("GIVEN empty password WHEN trying to login THEN invalid password is returned") @Test - fun emptyPasswordNameReturnsLoginStatusError() = runBlockingTest { + fun emptyPasswordNameReturnsLoginStatusError() = runTest { val expected = Answer.Success(LoginStatus.INVALID_PASSWORD) val actual = sut.invoke(LoginCredentials("a", "")) @@ -62,7 +62,7 @@ internal class LoginUseCaseTest { @DisplayName("GIVEN invalid credentials response WHEN trying to login THEN invalid credentials is returned ") @Test - fun invalidLoginResponseReturnInvalidCredentials() = runBlockingTest { + fun invalidLoginResponseReturnInvalidCredentials() = runTest { val expected = Answer.Success(LoginStatus.INVALID_CREDENTIALS) whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) .doReturn(LoginStatusResponses.InvalidCredentials) @@ -75,7 +75,7 @@ internal class LoginUseCaseTest { @DisplayName("GIVEN success response WHEN trying to login THEN session is saved and success is returned") @Test - fun validResponseResultsInSavingSessionAndSuccessReturned() = runBlockingTest { + fun validResponseResultsInSavingSessionAndSuccessReturned() = runTest { val expected = Answer.Success(LoginStatus.SUCCESS) whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) .doReturn(LoginStatusResponses.Success(Session("c", "d"))) @@ -89,7 +89,7 @@ internal class LoginUseCaseTest { @DisplayName("GIVEN error resposne WHEN trying to login THEN session is not touched and error is returned") @Test - fun invalidResponseResultsInErrorReturned() = runBlockingTest { + fun invalidResponseResultsInErrorReturned() = runTest { val exception = RuntimeException() val expected = Answer.Error(UnexpectedException(exception)) whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) diff --git a/core/src/test/java/org/fnives/test/showcase/core/login/hilt/LogoutUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/login/hilt/LogoutUseCaseTest.kt index c4fff8c..b9d817e 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/login/hilt/LogoutUseCaseTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/login/hilt/LogoutUseCaseTest.kt @@ -1,6 +1,6 @@ package org.fnives.test.showcase.core.login.hilt -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.content.ContentRepository import org.fnives.test.showcase.core.login.LogoutUseCase import org.fnives.test.showcase.core.storage.UserDataLocalStorage @@ -22,6 +22,7 @@ internal class LogoutUseCaseTest { lateinit var sut: LogoutUseCase private lateinit var mockUserDataLocalStorage: UserDataLocalStorage private lateinit var testCoreComponent: TestCoreComponent + @Inject lateinit var contentRepository: ContentRepository @@ -45,7 +46,7 @@ internal class LogoutUseCaseTest { @DisplayName("WHEN logout invoked THEN storage is cleared") @Test - fun logoutResultsInStorageCleaning() = runBlockingTest { + fun logoutResultsInStorageCleaning() = runTest { val repositoryBefore = contentRepository sut.invoke() diff --git a/core/src/test/java/org/fnives/test/showcase/core/login/koin/LogoutUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/login/koin/LogoutUseCaseTest.kt index 62a8aba..7432626 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/login/koin/LogoutUseCaseTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/login/koin/LogoutUseCaseTest.kt @@ -1,6 +1,7 @@ package org.fnives.test.showcase.core.login.koin import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.content.ContentRepository import org.fnives.test.showcase.core.di.koin.createCoreModule import org.fnives.test.showcase.core.login.LogoutUseCase @@ -56,7 +57,7 @@ internal class LogoutUseCaseTest : KoinTest { @DisplayName("WHEN logout invoked THEN storage is cleared") @Test - fun logoutResultsInStorageCleaning() = runBlockingTest { + fun logoutResultsInStorageCleaning() = runTest { val repositoryBefore = getKoin().get() sut.invoke() diff --git a/core/src/test/java/org/fnives/test/showcase/core/shared/AnswerUtilsKtTest.kt b/core/src/test/java/org/fnives/test/showcase/core/shared/AnswerUtilsKtTest.kt index f56e137..0ce5312 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/shared/AnswerUtilsKtTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/shared/AnswerUtilsKtTest.kt @@ -2,6 +2,7 @@ package org.fnives.test.showcase.core.shared import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.model.shared.Answer import org.fnives.test.showcase.model.shared.Resource import org.fnives.test.showcase.network.shared.exceptions.NetworkException @@ -15,7 +16,7 @@ internal class AnswerUtilsKtTest { @DisplayName("GIVEN network exception thrown WHEN wrapped into answer THEN answer error is returned") @Test - fun networkExceptionThrownResultsInError() = runBlocking { + fun networkExceptionThrownResultsInError() = runTest { val exception = NetworkException(Throwable()) val expected = Answer.Error(exception) @@ -26,7 +27,7 @@ internal class AnswerUtilsKtTest { @DisplayName("GIVEN parsing exception thrown WHEN wrapped into answer THEN answer error is returned") @Test - fun parsingExceptionThrownResultsInError() = runBlocking { + fun parsingExceptionThrownResultsInError() = runTest { val exception = ParsingException(Throwable()) val expected = Answer.Error(exception) @@ -37,7 +38,7 @@ internal class AnswerUtilsKtTest { @DisplayName("GIVEN unexpected throwable thrown WHEN wrapped into answer THEN answer error is returned") @Test - fun unexpectedExceptionThrownResultsInError() = runBlocking { + fun unexpectedExceptionThrownResultsInError() = runTest { val exception = Throwable() val expected = Answer.Error(UnexpectedException(exception)) @@ -48,7 +49,7 @@ internal class AnswerUtilsKtTest { @DisplayName("GIVEN string WHEN wrapped into answer THEN string answer is returned") @Test - fun stringIsReturnedWrappedIntoSuccess() = runBlocking { + fun stringIsReturnedWrappedIntoSuccess() = runTest { val expected = Answer.Success("banan") val actual = wrapIntoAnswer { "banan" } diff --git a/gradlescripts/versions.gradle b/gradlescripts/versions.gradle index 725f1a2..b7ac346 100644 --- a/gradlescripts/versions.gradle +++ b/gradlescripts/versions.gradle @@ -8,7 +8,8 @@ project.ext { androidx_room_version = "2.4.0" activity_ktx_version = "1.4.0" - coroutines_version = "1.5.2" + coroutines_version = "1.6.0" + turbine_version = "0.7.0" koin_version = "3.1.2" coil_version = "1.1.1" retrofit_version = "2.9.0"