From 3c80744f6d90d00e6c32c89c712e29cf923b1198 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Sat, 22 Jan 2022 10:55:02 +0200 Subject: [PATCH 01/10] 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" From 9700a09c95b0e3bd2dcaf618ee768683a7dad400 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Sat, 22 Jan 2022 11:15:18 +0200 Subject: [PATCH 02/10] Proof read core instruction set --- codekata/core.instructionset | 58 ++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/codekata/core.instructionset b/codekata/core.instructionset index c85b86a..8dfeb6d 100644 --- a/codekata/core.instructionset +++ b/codekata/core.instructionset @@ -2,7 +2,7 @@ In this testing instruction set you will learn how to write simple tests using mockito. -Every test will be around one class and all of it's dependencies will be mocked out. +Every test will be around one class and all of its dependencies will be mocked out. Also suspend functions will be tested so you will see how to do that as well. I would suggest to open this document in your browser, while working in Android Studio. @@ -15,7 +15,7 @@ I would suggest to open this document in your browser, while working in Android org.fnives.test.showcase.core.session.SessionExpirationAdapter ``` - As you can see it's a simple adapter between an interface and it's received parameter. + As you can see it's a simple adapter between an interface and its received parameter. - Now navigate to the test class: @@ -32,8 +32,8 @@ private lateinit var sut: SessionExpirationAdapter // System Under Testing private lateinit var mockSessionExpirationListener: SessionExpirationListener ``` -Now we need to initialize it, create a method names `setUp` and annotate it with `@BeforeEach` -and initialize the `sut` variable, we will see that the adapter expects a constructor argument +Now we need to initialize it. Create a method named `setUp` and annotate it with `@BeforeEach` +and initialize the `sut` variable. We will see that the adapter expects a constructor argument. ```kotlin @BeforeEach // this means this method will be invoked before each test in this class @@ -43,8 +43,8 @@ fun setUp() { } ``` -Great, now what is that mock? Simply put, it's a empty implementation of the interface. We can manipulate -that mock object to return what we want and verify it's method calls. +Great, now what is that mock? Simply put, it's an empty implementation of the interface. We can manipulate +that mock object to return what we want and verify its method calls. ### 2. First simple test @@ -105,25 +105,25 @@ Now this is a bit more complicated, let's open our test file: org.fnives.test.showcase.core.login.CodeKataSecondLoginUseCaseTest ``` -- declare the `sut` variable and it's dependencies, you should be familiar how to do this by now. +- declare the `sut` variable and its dependencies, you should be familiar how to do this by now. ### 1. `emptyUserNameReturnsLoginStatusError` -now let's write our first test: `emptyUserNameReturnsLoginStatusError` +Now let's write our first test: `emptyUserNameReturnsLoginStatusError` -first we declare what kind of result we expect: +First we declare what kind of result we expect: ```kotlin val expected = Answer.Success(LoginStatus.INVALID_USERNAME) ``` -next we do the actual invokation: +Next we do the actual invocation: ```kotlin val actual = sut.invoke(LoginCredentials("", "a")) ``` -lastly we add verification: +Lastly we add verification: ```kotlin Assertions.assertEquals(expected, actual) // assert the result is what we expected @@ -228,7 +228,7 @@ Now we see how we can mock responses. ### 4. `validResponseResultsInSavingSessionAndSuccessReturned`, -Now continue with `validResponseResultsInSavingSessionAndSuccessReturned`, You should have almost every tool to do this test: +Now continue with `validResponseResultsInSavingSessionAndSuccessReturned`. You should have almost every tool to do this test: - declare the expected value - do the mock response - call the system under test @@ -255,15 +255,15 @@ fun validResponseResultsInSavingSessionAndSuccessReturned() = runTest { ### 5. `invalidResponseResultsInErrorReturned` -this is really similar to our previous test, however now somehow we have to mock throwing an exception +This is really similar to our previous test, however now somehow we have to mock throwing an exception -to do this let's create an exception: +To do this let's create an exception: ```kotlin val exception = RuntimeException() ``` -declare our expected value: +Declare our expected value: ```kotlin val expected = Answer.Error(UnexpectedException(exception)) @@ -275,13 +275,13 @@ Do the mocking: whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))).doThrow(exception) ``` -invocation: +Invocation: ```kotlin val actual = sut.invoke(LoginCredentials("a", "b")) ``` -verification: +Verification: ```kotlin Assertions.assertEquals(expected, actual) @@ -292,7 +292,7 @@ verifyZeroInteractions(mockUserDataLocalStorage) - and the pattern of GIVEN-WHEN-THEN description. ``` -together: +Together: ```kotlin @DisplayName("GIVEN error resposne WHEN trying to login THEN session is not touched and error is returned") @@ -323,7 +323,7 @@ Additionally the Content is cached. So observing again should not yield loading. The inner workings of the class shouldn't matter, just the public apis, since that's what we want to test. -For setup we declare the system under test and it's mock argument. +For setup we declare the system under test and its mock argument. ```kotlin private lateinit var sut: ContentRepository @@ -368,7 +368,7 @@ Next the action: val actual = sut.contents.take(2).toList() ``` -Now just the verifications +Now just the verifications: ```kotlin Assertions.assertEquals(expected, actual) @@ -398,7 +398,7 @@ Assertions.assertEquals(expected, actual) ### 4. `verifyCaching` -Still sticking to just that function, we should verify it's caching behaviour, aka if a data was loaded once the next time we observe the flow that data is returned: +Still sticking to just that function, we should verify its caching behaviour, aka if a data was loaded once the next time we observe the flow that data is returned: The setup is similar to the happy flow, but take a look at the last line closely ```kotlin @@ -413,7 +413,7 @@ The action will only take one element which we expect to be the cache val actual = sut.contents.take(1).toList() ``` -In the verification state, we will also make sure the request indead was called only once: +In the verification state, we will also make sure the request indeed was called only once: ```kotlin verify(mockContentRemoteSource, times(1)).get() Assertions.assertEquals(expected, actual) @@ -421,7 +421,7 @@ Assertions.assertEquals(expected, actual) ### 5. `loadingIsShownBeforeTheRequestIsReturned` -So far we just expected the first element is "loading", but it could easily happen that the flow set up in such a way that the loading is not emitted +So far we just expected the first element is "loading", but it could easily happen that the flow is set up in such a way that the loading is not emitted before the request already finished. This can be an easy mistake with such flows, but would be really bad UX, so let's see how we can verify something like that: @@ -433,7 +433,7 @@ Generally we could still use mockito mocks OR we could create our own Mock. #### Creating our own mock. -We can simply implement the interface of ContentRemoteSource. Have a it's method suspend until a signal. +We can simply implement the interface of ContentRemoteSource. Have it's method suspend until a signal. Something along the way of: @@ -460,13 +460,13 @@ In this case we should recreate our sut in the test and feed it our own remote s To mock such behaviour with mockito with our current tool set is not as straight forward as creating our own. That's because how we used mockito so far it is not aware of the nature of suspend functions, like our code is in the custom mock. -However mockito give us the arguments passed into the function. +However mockito gives us the arguments passed into the function. And since we know the Continuation object is passed as a last argument in suspend functions we can take advantage of that. This then can be abstracted away and used wherever without needing to create Custom Mocks for every such case. To get arguments when creating a response for the mock you need to use thenAnswer { } and this lambda will receive InvocationOnMock containing the arguments. -Luckily this has already be done in "org.mockito.kotlin" and it's called `doSuspendableAnswer` +Luckily this has already been done in "org.mockito.kotlin" and it's called `doSuspendableAnswer` The point here is that we can get arguments while mocking with mockito, and we are able to extend it in a way that helps us in common patterns. @@ -502,7 +502,7 @@ suspendedRequest.complete(Unit) We still didn't even touch the fetch method so let's test the behaviour next: -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: +We want to get the first result triggered by the subscription to the flow, and then again another loading and result after a call to `fetch`, so the setup would be: ```kotlin val exception = RuntimeException() val expected = listOf( @@ -531,7 +531,7 @@ 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. +This is a good thing because it gives us more control over the order of execution and as a result our tests 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 @@ -553,7 +553,7 @@ fun whenFetchingRequestIsCalledAgain() = runTest(UnconfinedTestDispatcher()) { } ``` -Now we can test even complicated interactions between methods and classes with TestCoroutineDispatcher. +Now we can test even complicated interactions between methods and classes with test dispatchers. ### 7. `noAdditionalItemsEmitted` From 968ccb647dcf63e17d89a545ffce46cbe8609f09 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Sun, 23 Jan 2022 20:05:59 +0200 Subject: [PATCH 03/10] Issue#11 Adjust ContentRepositoryTest by using runTest correctly instead of deprecated TestDispatcher --- .../FavouriteContentLocalStorageImplTest.kt | 4 +- .../core/content/ContentRepositoryTest.kt | 38 ++++++++----------- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/app/src/robolectricTestHilt/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt b/app/src/robolectricTestHilt/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt index b58f4a3..a1d79de 100644 --- a/app/src/robolectricTestHilt/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt +++ b/app/src/robolectricTestHilt/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt @@ -78,7 +78,7 @@ internal class FavouriteContentLocalStorageImplTest { sut.markAsFavourite(ContentId("a")) - Assert.assertEquals(expected, actual.await()) + Assert.assertEquals(expected, actual.getCompleted()) } /** GIVEN non empty database WHILE observing content WHEN favourite removed THEN change is emitted */ @@ -96,6 +96,6 @@ internal class FavouriteContentLocalStorageImplTest { sut.deleteAsFavourite(ContentId("a")) - Assert.assertEquals(expected, actual.await()) + Assert.assertEquals(expected, actual.getCompleted()) } } 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 ec2f417..89d2c27 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 @@ -6,6 +6,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.shared.UnexpectedException import org.fnives.test.showcase.model.content.Content @@ -126,34 +127,25 @@ internal class ContentRepositoryTest { } sut.fetch() - Assertions.assertEquals(expected, actual.await()) + Assertions.assertEquals(expected, actual.getCompleted()) } @DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted") @Test - fun noAdditionalItemsEmitted() { - Assertions.assertThrows(IllegalStateException::class.java) { - 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(5).toList() - } - sut.fetch() - - Assertions.assertEquals(expected, actual.await()) - } + fun noAdditionalItemsEmitted() = runTest { + val exception = RuntimeException() + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception } + + val actual = async(coroutineContext) { sut.contents.take(5).toList() } + advanceUntilIdle() + sut.fetch() + advanceUntilIdle() + + Assertions.assertFalse(actual.isCompleted) + actual.cancel() } @DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted") From 3c85431d96b59f487685d2d05b5168b04890b914 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Sun, 23 Jan 2022 20:28:58 +0200 Subject: [PATCH 04/10] Issue#11 Adjust FavouriteContentLocalStorage by using runTest instead of deprecated TestDispatchers --- .../FavouriteContentLocalStorageImplTest.kt | 76 +++++++++---------- .../FavouriteContentLocalStorageImplTest.kt | 50 ++++++------ 2 files changed, 63 insertions(+), 63 deletions(-) diff --git a/app/src/robolectricTestHilt/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt b/app/src/robolectricTestHilt/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt index a1d79de..d67dd1b 100644 --- a/app/src/robolectricTestHilt/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt +++ b/app/src/robolectricTestHilt/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt @@ -8,8 +8,11 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage import org.fnives.test.showcase.model.content.ContentId import org.fnives.test.showcase.storage.database.DatabaseInitialization @@ -31,18 +34,18 @@ internal class FavouriteContentLocalStorageImplTest { @Inject lateinit var sut: FavouriteContentLocalStorage - private lateinit var testDispatcher: TestCoroutineDispatcher + private lateinit var testDispatcher: TestDispatcher @Before fun setUp() { - testDispatcher = TestCoroutineDispatcher() + testDispatcher = StandardTestDispatcher(TestCoroutineScheduler()) DatabaseInitialization.dispatcher = testDispatcher hiltRule.inject() } /** GIVEN content_id WHEN added to Favourite THEN it can be read out */ @Test - fun addingContentIdToFavouriteCanBeLaterReadOut() = runBlocking { + fun addingContentIdToFavouriteCanBeLaterReadOut() = runTest(testDispatcher) { val expected = listOf(ContentId("a")) sut.markAsFavourite(ContentId("a")) @@ -53,49 +56,46 @@ internal class FavouriteContentLocalStorageImplTest { /** GIVEN content_id added WHEN removed to Favourite THEN it no longer can be read out */ @Test - fun contentIdAddedThenRemovedCanNoLongerBeReadOut() = - runBlocking { - val expected = listOf() - sut.markAsFavourite(ContentId("b")) + fun contentIdAddedThenRemovedCanNoLongerBeReadOut() = runTest(testDispatcher) { + val expected = listOf() + sut.markAsFavourite(ContentId("b")) - sut.deleteAsFavourite(ContentId("b")) - val actual = sut.observeFavourites().first() + sut.deleteAsFavourite(ContentId("b")) + val actual = sut.observeFavourites().first() - Assert.assertEquals(expected, actual) - } + Assert.assertEquals(expected, actual) + } /** GIVEN empty database WHILE observing content WHEN favourite added THEN change is emitted */ @Test - fun addingFavouriteUpdatesExistingObservers() = - runBlocking { - val expected = listOf(listOf(), listOf(ContentId("a"))) + fun addingFavouriteUpdatesExistingObservers() = runTest(testDispatcher) { + val expected = listOf(listOf(), listOf(ContentId("a"))) - val testDispatcher = TestCoroutineDispatcher() - val actual = async(testDispatcher) { - sut.observeFavourites().take(2).toList() - } - testDispatcher.advanceUntilIdle() - - sut.markAsFavourite(ContentId("a")) - - Assert.assertEquals(expected, actual.getCompleted()) + val actual = async(coroutineContext) { + sut.observeFavourites().take(2).toList() } + advanceUntilIdle() + + sut.markAsFavourite(ContentId("a")) + advanceUntilIdle() + + Assert.assertEquals(expected, actual.getCompleted()) + } /** GIVEN non empty database WHILE observing content WHEN favourite removed THEN change is emitted */ @Test - fun removingFavouriteUpdatesExistingObservers() = - runBlocking { - val expected = listOf(listOf(ContentId("a")), listOf()) - sut.markAsFavourite(ContentId("a")) + fun removingFavouriteUpdatesExistingObservers() = runTest(testDispatcher) { + val expected = listOf(listOf(ContentId("a")), listOf()) + sut.markAsFavourite(ContentId("a")) - val testDispatcher = TestCoroutineDispatcher() - val actual = async(testDispatcher) { - sut.observeFavourites().take(2).toList() - } - testDispatcher.advanceUntilIdle() - - sut.deleteAsFavourite(ContentId("a")) - - Assert.assertEquals(expected, actual.getCompleted()) + val actual = async(coroutineContext) { + sut.observeFavourites().take(2).toList() } + advanceUntilIdle() + + sut.deleteAsFavourite(ContentId("a")) + advanceUntilIdle() + + Assert.assertEquals(expected, actual.getCompleted()) + } } diff --git a/app/src/robolectricTestKoin/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt b/app/src/robolectricTestKoin/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt index dc5073e..c05948f 100644 --- a/app/src/robolectricTestKoin/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt +++ b/app/src/robolectricTestKoin/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt @@ -6,8 +6,11 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage import org.fnives.test.showcase.model.content.ContentId import org.fnives.test.showcase.storage.database.DatabaseInitialization @@ -26,11 +29,11 @@ import org.koin.test.inject internal class FavouriteContentLocalStorageImplTest : KoinTest { private val sut by inject() - private lateinit var testDispatcher: TestCoroutineDispatcher + private lateinit var testDispatcher: TestDispatcher @Before fun setUp() { - testDispatcher = TestCoroutineDispatcher() + testDispatcher = StandardTestDispatcher(TestCoroutineScheduler()) DatabaseInitialization.dispatcher = testDispatcher } @@ -41,7 +44,7 @@ internal class FavouriteContentLocalStorageImplTest : KoinTest { /** GIVEN content_id WHEN added to Favourite THEN it can be read out */ @Test - fun addingContentIdToFavouriteCanBeLaterReadOut() = runBlocking { + fun addingContentIdToFavouriteCanBeLaterReadOut() = runTest(testDispatcher) { val expected = listOf(ContentId("a")) sut.markAsFavourite(ContentId("a")) @@ -52,8 +55,7 @@ internal class FavouriteContentLocalStorageImplTest : KoinTest { /** GIVEN content_id added WHEN removed to Favourite THEN it no longer can be read out */ @Test - fun contentIdAddedThenRemovedCanNoLongerBeReadOut() = - runBlocking { + fun contentIdAddedThenRemovedCanNoLongerBeReadOut() = runTest(testDispatcher) { val expected = listOf() sut.markAsFavourite(ContentId("b")) @@ -65,36 +67,34 @@ internal class FavouriteContentLocalStorageImplTest : KoinTest { /** GIVEN empty database WHILE observing content WHEN favourite added THEN change is emitted */ @Test - fun addingFavouriteUpdatesExistingObservers() = - runBlocking { - val expected = listOf(listOf(), listOf(ContentId("a"))) + fun addingFavouriteUpdatesExistingObservers() = runTest(testDispatcher) { + val expected = listOf(listOf(), listOf(ContentId("a"))) - val testDispatcher = TestCoroutineDispatcher() - val actual = async(testDispatcher) { - sut.observeFavourites().take(2).toList() - } - testDispatcher.advanceUntilIdle() - - sut.markAsFavourite(ContentId("a")) - - Assert.assertEquals(expected, actual.await()) + val actual = async(coroutineContext) { + sut.observeFavourites().take(2).toList() } + advanceUntilIdle() + + sut.markAsFavourite(ContentId("a")) + advanceUntilIdle() + + Assert.assertEquals(expected, actual.getCompleted()) + } /** GIVEN non empty database WHILE observing content WHEN favourite removed THEN change is emitted */ @Test - fun removingFavouriteUpdatesExistingObservers() = - runBlocking { + fun removingFavouriteUpdatesExistingObservers() = runTest(testDispatcher) { val expected = listOf(listOf(ContentId("a")), listOf()) sut.markAsFavourite(ContentId("a")) - val testDispatcher = TestCoroutineDispatcher() - val actual = async(testDispatcher) { + val actual = async(coroutineContext) { sut.observeFavourites().take(2).toList() } - testDispatcher.advanceUntilIdle() + advanceUntilIdle() sut.deleteAsFavourite(ContentId("a")) + advanceUntilIdle() - Assert.assertEquals(expected, actual.await()) + Assert.assertEquals(expected, actual.getCompleted()) } } From 46d92637425cd923e2440d0bf2c829e4103be7bd Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Sun, 23 Jan 2022 21:07:46 +0200 Subject: [PATCH 05/10] Issue#11 Adjust TestMainDispatcher extension using tests after coroutine update --- .../showcase/testutils/TestMainDispatcher.kt | 16 ++-- .../showcase/ui/auth/AuthViewModelTest.kt | 22 +++--- .../showcase/ui/home/MainViewModelTest.kt | 73 +++++++++---------- .../showcase/ui/splash/SplashViewModelTest.kt | 21 ++++-- .../org/fnives/test/showcase/di/DITest.kt | 6 -- 5 files changed, 70 insertions(+), 68 deletions(-) diff --git a/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt b/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt index e4f725c..09f8e8c 100644 --- a/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt +++ b/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt @@ -1,7 +1,10 @@ package org.fnives.test.showcase.testutils import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.fnives.test.showcase.storage.database.DatabaseInitialization @@ -15,12 +18,12 @@ import org.junit.jupiter.api.extension.ExtensionContext * * One can access the test dispatcher via [testDispatcher] static getter. */ +@OptIn(ExperimentalCoroutinesApi::class) class TestMainDispatcher : BeforeEachCallback, AfterEachCallback { override fun beforeEach(context: ExtensionContext?) { - val testDispatcher = TestCoroutineDispatcher() + val testDispatcher = StandardTestDispatcher(TestCoroutineScheduler()) privateTestDispatcher = testDispatcher - testDispatcher.pauseDispatcher() DatabaseInitialization.dispatcher = testDispatcher Dispatchers.setMain(testDispatcher) } @@ -31,8 +34,9 @@ class TestMainDispatcher : BeforeEachCallback, AfterEachCallback { } companion object { - private var privateTestDispatcher: TestCoroutineDispatcher? = null - val testDispatcher: TestCoroutineDispatcher - get() = privateTestDispatcher ?: throw IllegalStateException("TestMainDispatcher is in afterEach State") + private var privateTestDispatcher: TestDispatcher? = null + val testDispatcher: TestDispatcher + get() = privateTestDispatcher + ?: throw IllegalStateException("TestMainDispatcher is in afterEach State") } } diff --git a/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt index aa690d6..4af66dd 100644 --- a/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt +++ b/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt @@ -1,6 +1,7 @@ package org.fnives.test.showcase.ui.auth import com.jraska.livedata.test +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import org.fnives.test.showcase.core.login.LoginUseCase import org.fnives.test.showcase.model.auth.LoginCredentials @@ -27,11 +28,12 @@ import java.util.stream.Stream @Suppress("TestFunctionName") @ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) +@OptIn(ExperimentalCoroutinesApi::class) internal class AuthViewModelTest { private lateinit var sut: AuthViewModel private lateinit var mockLoginUseCase: LoginUseCase - private val testDispatcher get() = TestMainDispatcher.testDispatcher + private val testScheduler get() = TestMainDispatcher.testDispatcher.scheduler @BeforeEach fun setUp() { @@ -42,7 +44,7 @@ internal class AuthViewModelTest { @DisplayName("GIVEN initialized viewModel WHEN observed THEN loading false other fields are empty") @Test fun initialSetup() { - testDispatcher.resumeDispatcher() + testScheduler.advanceUntilIdle() sut.username.test().assertNoValue() sut.password.test().assertNoValue() @@ -54,11 +56,11 @@ internal class AuthViewModelTest { @DisplayName("GIVEN password text WHEN onPasswordChanged is called THEN password livedata is updated") @Test fun whenPasswordChangedLiveDataIsUpdated() { - testDispatcher.resumeDispatcher() val passwordTestObserver = sut.password.test() sut.onPasswordChanged("a") sut.onPasswordChanged("al") + testScheduler.advanceUntilIdle() passwordTestObserver.assertValueHistory("a", "al") sut.username.test().assertNoValue() @@ -70,11 +72,11 @@ internal class AuthViewModelTest { @DisplayName("GIVEN username text WHEN onUsernameChanged is called THEN username livedata is updated") @Test fun whenUsernameChangedLiveDataIsUpdated() { - testDispatcher.resumeDispatcher() val usernameTestObserver = sut.username.test() sut.onUsernameChanged("a") sut.onUsernameChanged("al") + testScheduler.advanceUntilIdle() usernameTestObserver.assertValueHistory("a", "al") sut.password.test().assertNoValue() @@ -92,7 +94,7 @@ internal class AuthViewModelTest { } sut.onLogin() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() loadingTestObserver.assertValueHistory(false, true, false) runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("", "")) } @@ -106,7 +108,7 @@ internal class AuthViewModelTest { sut.onLogin() sut.onLogin() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("", "")) } verifyNoMoreInteractions(mockLoginUseCase) @@ -122,7 +124,7 @@ internal class AuthViewModelTest { sut.onUsernameChanged("usr") sut.onLogin() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("usr", "pass")) @@ -141,7 +143,7 @@ internal class AuthViewModelTest { val navigateToHomeObserver = sut.navigateToHome.test() sut.onLogin() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() loadingObserver.assertValueHistory(false, true, false) errorObserver.assertValueHistory(Event(AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR)) @@ -162,7 +164,7 @@ internal class AuthViewModelTest { val navigateToHomeObserver = sut.navigateToHome.test() sut.onLogin() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() loadingObserver.assertValueHistory(false, true, false) errorObserver.assertValueHistory(Event(errorType)) @@ -180,7 +182,7 @@ internal class AuthViewModelTest { val navigateToHomeObserver = sut.navigateToHome.test() sut.onLogin() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() loadingObserver.assertValueHistory(false, true, false) errorObserver.assertNoValue() diff --git a/app/src/test/java/org/fnives/test/showcase/ui/home/MainViewModelTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/home/MainViewModelTest.kt index bb87c92..244f142 100644 --- a/app/src/test/java/org/fnives/test/showcase/ui/home/MainViewModelTest.kt +++ b/app/src/test/java/org/fnives/test/showcase/ui/home/MainViewModelTest.kt @@ -1,6 +1,7 @@ package org.fnives.test.showcase.ui.home import com.jraska.livedata.test +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase @@ -29,6 +30,7 @@ import org.mockito.kotlin.whenever @Suppress("TestFunctionName") @ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) +@OptIn(ExperimentalCoroutinesApi::class) internal class MainViewModelTest { private lateinit var sut: MainViewModel @@ -37,7 +39,7 @@ internal class MainViewModelTest { private lateinit var mockFetchContentUseCase: FetchContentUseCase private lateinit var mockAddContentToFavouriteUseCase: AddContentToFavouriteUseCase private lateinit var mockRemoveContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase - private val testDispatcher get() = TestMainDispatcher.testDispatcher + private val testScheduler get() = TestMainDispatcher.testDispatcher.scheduler @BeforeEach fun setUp() { @@ -46,7 +48,6 @@ internal class MainViewModelTest { mockFetchContentUseCase = mock() mockAddContentToFavouriteUseCase = mock() mockRemoveContentFromFavouritesUseCase = mock() - testDispatcher.pauseDispatcher() sut = MainViewModel( getAllContentUseCase = mockGetAllContentUseCase, logoutUseCase = mockLogoutUseCase, @@ -69,13 +70,16 @@ internal class MainViewModelTest { @Test fun loadingDataShowsInLoadingUIState() { whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) - testDispatcher.resumeDispatcher() - testDispatcher.advanceUntilIdle() + val errorMessageTestObserver = sut.errorMessage.test() + val contentTestObserver = sut.content.test() + val loadingTestObserver = sut.loading.test() + val navigateToAuthTestObserver = sut.navigateToAuth.test() + testScheduler.advanceUntilIdle() - sut.errorMessage.test().assertValue(false) - sut.content.test().assertNoValue() - sut.loading.test().assertValue(true) - sut.navigateToAuth.test().assertNoValue() + errorMessageTestObserver.assertValue(false) + contentTestObserver.assertNoValue() + loadingTestObserver.assertValue(true) + navigateToAuthTestObserver.assertNoValue() } @DisplayName("GIVEN loading then data WHEN observing content THEN proper states are shown") @@ -85,13 +89,13 @@ internal class MainViewModelTest { val errorMessageTestObserver = sut.errorMessage.test() val contentTestObserver = sut.content.test() val loadingTestObserver = sut.loading.test() - testDispatcher.resumeDispatcher() - testDispatcher.advanceUntilIdle() + val navigateToAuthTestObserver = sut.navigateToAuth.test() + testScheduler.advanceUntilIdle() errorMessageTestObserver.assertValueHistory(false) contentTestObserver.assertValueHistory(listOf()) loadingTestObserver.assertValueHistory(true, false) - sut.navigateToAuth.test().assertNoValue() + navigateToAuthTestObserver.assertNoValue() } @DisplayName("GIVEN loading then error WHEN observing content THEN proper states are shown") @@ -101,13 +105,13 @@ internal class MainViewModelTest { val errorMessageTestObserver = sut.errorMessage.test() val contentTestObserver = sut.content.test() val loadingTestObserver = sut.loading.test() - testDispatcher.resumeDispatcher() - testDispatcher.advanceUntilIdle() + val navigateToAuthTestObserver = sut.navigateToAuth.test() + testScheduler.advanceUntilIdle() errorMessageTestObserver.assertValueHistory(false, true) contentTestObserver.assertValueHistory(emptyList()) loadingTestObserver.assertValueHistory(true, false) - sut.navigateToAuth.test().assertNoValue() + navigateToAuthTestObserver.assertNoValue() } @DisplayName("GIVEN loading then error then loading then data WHEN observing content THEN proper states are shown") @@ -127,13 +131,13 @@ internal class MainViewModelTest { val errorMessageTestObserver = sut.errorMessage.test() val contentTestObserver = sut.content.test() val loadingTestObserver = sut.loading.test() - testDispatcher.resumeDispatcher() - testDispatcher.advanceUntilIdle() + val navigateToAuthTestObserver = sut.navigateToAuth.test() + testScheduler.advanceUntilIdle() errorMessageTestObserver.assertValueHistory(false, true, false) contentTestObserver.assertValueHistory(emptyList(), content) loadingTestObserver.assertValueHistory(true, false, true, false) - sut.navigateToAuth.test().assertNoValue() + navigateToAuthTestObserver.assertNoValue() } @DisplayName("GIVEN loading viewModel WHEN refreshing THEN usecase is not called") @@ -141,11 +145,10 @@ internal class MainViewModelTest { fun fetchIsIgnoredIfViewModelIsStillLoading() { whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) sut.content.test() - testDispatcher.resumeDispatcher() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() sut.onRefresh() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() verifyZeroInteractions(mockFetchContentUseCase) } @@ -155,11 +158,10 @@ internal class MainViewModelTest { fun fetchIsCalledIfViewModelIsLoaded() { whenever(mockGetAllContentUseCase.get()).doReturn(flowOf()) sut.content.test() - testDispatcher.resumeDispatcher() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() sut.onRefresh() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() verify(mockFetchContentUseCase, times(1)).invoke() verifyNoMoreInteractions(mockFetchContentUseCase) @@ -170,11 +172,10 @@ internal class MainViewModelTest { fun loadingViewModelStillCalsLogout() { whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) sut.content.test() - testDispatcher.resumeDispatcher() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() sut.onLogout() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() runBlocking { verify(mockLogoutUseCase, times(1)).invoke() } verifyNoMoreInteractions(mockLogoutUseCase) @@ -185,11 +186,10 @@ internal class MainViewModelTest { fun nonLoadingViewModelStillCalsLogout() { whenever(mockGetAllContentUseCase.get()).doReturn(flowOf()) sut.content.test() - testDispatcher.resumeDispatcher() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() sut.onLogout() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() runBlocking { verify(mockLogoutUseCase, times(1)).invoke() } verifyNoMoreInteractions(mockLogoutUseCase) @@ -204,11 +204,10 @@ internal class MainViewModelTest { ) whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Success(contents))) sut.content.test() - testDispatcher.resumeDispatcher() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() sut.onFavouriteToggleClicked(ContentId("c")) - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() verifyZeroInteractions(mockRemoveContentFromFavouritesUseCase) verifyZeroInteractions(mockAddContentToFavouriteUseCase) @@ -223,11 +222,10 @@ internal class MainViewModelTest { ) whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Success(contents))) sut.content.test() - testDispatcher.resumeDispatcher() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() sut.onFavouriteToggleClicked(ContentId("b")) - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() runBlocking { verify(mockRemoveContentFromFavouritesUseCase, times(1)).invoke(ContentId("b")) } verifyNoMoreInteractions(mockRemoveContentFromFavouritesUseCase) @@ -243,11 +241,10 @@ internal class MainViewModelTest { ) whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Success(contents))) sut.content.test() - testDispatcher.resumeDispatcher() - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() sut.onFavouriteToggleClicked(ContentId("a")) - testDispatcher.advanceUntilIdle() + testScheduler.advanceUntilIdle() verifyZeroInteractions(mockRemoveContentFromFavouritesUseCase) runBlocking { verify(mockAddContentToFavouriteUseCase, times(1)).invoke(ContentId("a")) } diff --git a/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt index 5ca371e..bd366e1 100644 --- a/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt +++ b/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt @@ -1,6 +1,7 @@ package org.fnives.test.showcase.ui.splash import com.jraska.livedata.test +import kotlinx.coroutines.ExperimentalCoroutinesApi import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase import org.fnives.test.showcase.testutils.InstantExecutorExtension import org.fnives.test.showcase.testutils.TestMainDispatcher @@ -14,11 +15,12 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) +@OptIn(ExperimentalCoroutinesApi::class) internal class SplashViewModelTest { private lateinit var mockIsUserLoggedInUseCase: IsUserLoggedInUseCase private lateinit var sut: SplashViewModel - private val testCoroutineDispatcher get() = TestMainDispatcher.testDispatcher + private val testScheduler get() = TestMainDispatcher.testDispatcher.scheduler @BeforeEach fun setUp() { @@ -30,29 +32,32 @@ internal class SplashViewModelTest { @Test fun loggedOutUserGoesToAuthentication() { whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) + val navigateToTestObserver = sut.navigateTo.test() - testCoroutineDispatcher.advanceTimeBy(500) + testScheduler.advanceTimeBy(501) - sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.AUTHENTICATION)) + navigateToTestObserver.assertValueHistory(Event(SplashViewModel.NavigateTo.AUTHENTICATION)) } @DisplayName("GIVEN logged in user WHEN splash started THEN after half a second navigated to home") @Test - fun loggedInUserGoestoHome() { + fun loggedInUserGoesToHome() { whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(true) + val navigateToTestObserver = sut.navigateTo.test() - testCoroutineDispatcher.advanceTimeBy(500) + testScheduler.advanceTimeBy(501) - sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.HOME)) + navigateToTestObserver.assertValueHistory(Event(SplashViewModel.NavigateTo.HOME)) } @DisplayName("GIVEN not logged in user WHEN splash started THEN before half a second no event is sent") @Test fun withoutEnoughTimeNoNavigationHappens() { whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) + val navigateToTestObserver = sut.navigateTo.test() - testCoroutineDispatcher.advanceTimeBy(100) + testScheduler.advanceTimeBy(500) - sut.navigateTo.test().assertNoValue() + navigateToTestObserver.assertNoValue() } } diff --git a/app/src/testKoin/java/org/fnives/test/showcase/di/DITest.kt b/app/src/testKoin/java/org/fnives/test/showcase/di/DITest.kt index 1a88d3d..cf595ba 100644 --- a/app/src/testKoin/java/org/fnives/test/showcase/di/DITest.kt +++ b/app/src/testKoin/java/org/fnives/test/showcase/di/DITest.kt @@ -7,7 +7,6 @@ import org.fnives.test.showcase.ui.auth.AuthViewModel import org.fnives.test.showcase.ui.home.MainViewModel import org.fnives.test.showcase.ui.splash.SplashViewModel import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.koin.android.ext.koin.androidContext @@ -28,11 +27,6 @@ class DITest : KoinTest { private val mainViewModel by inject() private val splashViewModel by inject() - @BeforeEach - fun setUp() { - TestMainDispatcher.testDispatcher.pauseDispatcher() - } - @AfterEach fun tearDown() { stopKoin() From 8ae94cfe92c6a5aa36b9209a8638774043c8ac6d Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Sun, 23 Jan 2022 21:18:30 +0200 Subject: [PATCH 06/10] Issue#11 Adjust SharedTests by using new TestDispatcher instead of deprecated TestDispatchers --- .../TestCoroutineMainDispatcherTestRule.kt | 15 ++++--- .../testutils/idling/awaitIdlingResources.kt | 31 +++++++------ .../showcase/ui/splash/SplashActivityTest.kt | 31 ++++++++++++- .../showcase/ui/splash/SplashActivityTest.kt | 45 +++++++++++++++---- 4 files changed, 89 insertions(+), 33 deletions(-) diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/TestCoroutineMainDispatcherTestRule.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/TestCoroutineMainDispatcherTestRule.kt index 4706794..5460a89 100644 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/TestCoroutineMainDispatcherTestRule.kt +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/TestCoroutineMainDispatcherTestRule.kt @@ -1,7 +1,10 @@ package org.fnives.test.showcase.testutils.configuration import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.fnives.test.showcase.storage.database.DatabaseInitialization @@ -9,16 +12,16 @@ import org.fnives.test.showcase.testutils.idling.advanceUntilIdleWithIdlingResou import org.junit.runner.Description import org.junit.runners.model.Statement +@OptIn(ExperimentalCoroutinesApi::class) class TestCoroutineMainDispatcherTestRule : MainDispatcherTestRule { - private lateinit var testDispatcher: TestCoroutineDispatcher + private lateinit var testDispatcher: TestDispatcher override fun apply(base: Statement, description: Description): Statement = object : Statement() { @Throws(Throwable::class) override fun evaluate() { - val dispatcher = TestCoroutineDispatcher() - dispatcher.pauseDispatcher() + val dispatcher = StandardTestDispatcher(TestCoroutineScheduler()) Dispatchers.setMain(dispatcher) testDispatcher = dispatcher DatabaseInitialization.dispatcher = dispatcher @@ -39,10 +42,10 @@ class TestCoroutineMainDispatcherTestRule : MainDispatcherTestRule { } override fun advanceUntilIdle() { - testDispatcher.advanceUntilIdle() + testDispatcher.scheduler.advanceUntilIdle() } override fun advanceTimeBy(delayInMillis: Long) { - testDispatcher.advanceTimeBy(delayInMillis) + testDispatcher.scheduler.advanceTimeBy(delayInMillis) } } diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/awaitIdlingResources.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/awaitIdlingResources.kt index 896255a..80a7d15 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/awaitIdlingResources.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/awaitIdlingResources.kt @@ -4,15 +4,11 @@ import androidx.test.espresso.Espresso import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.IdlingResource import androidx.test.espresso.matcher.ViewMatchers -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher import org.fnives.test.showcase.testutils.viewactions.LoopMainThreadFor import org.fnives.test.showcase.testutils.viewactions.LoopMainThreadUntilIdle - -private val idleScope = CoroutineScope(Dispatchers.IO) +import java.util.concurrent.Executors // workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807 fun anyResourceIdling(): Boolean = !IdlingRegistry.getInstance().resources.all(IdlingResource::isIdleNow) @@ -21,13 +17,14 @@ fun awaitIdlingResources() { val idlingRegistry = IdlingRegistry.getInstance() if (idlingRegistry.resources.all(IdlingResource::isIdleNow)) return + val executor = Executors.newSingleThreadExecutor() var isIdle = false - idleScope.launch { + executor.submit { do { idlingRegistry.resources .filterNot(IdlingResource::isIdleNow) - .forEach { idlingRegistry -> - idlingRegistry.awaitUntilIdle() + .forEach { idlingResource -> + idlingResource.awaitUntilIdle() } } while (!idlingRegistry.resources.all(IdlingResource::isIdleNow)) isIdle = true @@ -35,23 +32,25 @@ fun awaitIdlingResources() { while (!isIdle) { loopMainThreadFor(200L) } + executor.shutdown() } -private suspend fun IdlingResource.awaitUntilIdle() { +private fun IdlingResource.awaitUntilIdle() { // using loop because some times, registerIdleTransitionCallback wasn't called while (true) { if (isIdleNow) return - delay(100) + Thread.sleep(100L) } } -fun TestCoroutineDispatcher.advanceUntilIdleWithIdlingResources() { - advanceUntilIdle() // advance until a request is sent +@OptIn(ExperimentalCoroutinesApi::class) +fun TestDispatcher.advanceUntilIdleWithIdlingResources() { + scheduler.advanceUntilIdle() // advance until a request is sent while (anyResourceIdling()) { // check if any request is in progress awaitIdlingResources() // complete all requests and other idling resources - advanceUntilIdle() // run coroutines after request is finished + scheduler.advanceUntilIdle() // run coroutines after request is finished } - advanceUntilIdle() + scheduler.advanceUntilIdle() } fun loopMainThreadUntilIdleWithIdlingResources() { diff --git a/app/src/sharedTestHilt/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt b/app/src/sharedTestHilt/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt index 1caf222..33c3be2 100644 --- a/app/src/sharedTestHilt/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt +++ b/app/src/sharedTestHilt/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt @@ -78,7 +78,7 @@ class SplashActivityTest : KoinTest { activityScenario = ActivityScenario.launch(HiltSplashActivity::class.java) - mainDispatcherTestRule.advanceTimeBy(500) + mainDispatcherTestRule.advanceTimeBy(501) splashRobot.assertHomeIsStarted() .assertAuthIsNotStarted() @@ -93,9 +93,36 @@ class SplashActivityTest : KoinTest { activityScenario = ActivityScenario.launch(HiltSplashActivity::class.java) - mainDispatcherTestRule.advanceTimeBy(500) + mainDispatcherTestRule.advanceTimeBy(501) splashRobot.assertAuthIsStarted() .assertHomeIsNotStarted() } + + @Test + fun loggedOutStatesNotEnoughTime() { + setupLoggedInState.setupLogout() + + activityScenario = ActivityScenario.launch(HiltSplashActivity::class.java) + + mainDispatcherTestRule.advanceTimeBy(10) + + splashRobot.assertAuthIsNotStarted() + .assertHomeIsNotStarted() + } + + /** GIVEN loggedInState and not enough time WHEN opened THEN no activity is started */ + @Test + fun loggedInStatesNotEnoughTime() { + setupLoggedInState.setupLogin(mockServerScenarioSetupTestRule.mockServerScenarioSetup) + + activityScenario = ActivityScenario.launch(HiltSplashActivity::class.java) + + mainDispatcherTestRule.advanceTimeBy(10) + + splashRobot.assertHomeIsNotStarted() + .assertAuthIsNotStarted() + + setupLoggedInState.setupLogout() + } } diff --git a/app/src/sharedTestKoin/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt b/app/src/sharedTestKoin/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt index c7b1a1a..74e202f 100644 --- a/app/src/sharedTestKoin/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt +++ b/app/src/sharedTestKoin/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt @@ -1,6 +1,5 @@ package org.fnives.test.showcase.ui.splash -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -26,10 +25,6 @@ class SplashActivityTest : KoinTest { private val splashRobot: SplashRobot get() = robotTestRule.robot - @Rule - @JvmField - val instantTaskExecutorRule = InstantTaskExecutorRule() - @Rule @JvmField val robotTestRule = RobotTestRule(SplashRobot()) @@ -61,14 +56,15 @@ class SplashActivityTest : KoinTest { disposable.dispose() } - /** GIVEN loggedInState WHEN opened THEN MainActivity is started */ + /** GIVEN loggedInState WHEN opened after some time THEN MainActivity is started */ @Test fun loggedInStateNavigatesToHome() { SetupLoggedInState.setupLogin(mockServerScenarioSetupTestRule.mockServerScenarioSetup) activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) - mainDispatcherTestRule.advanceTimeBy(500) + mainDispatcherTestRule.advanceTimeBy(501) splashRobot.assertHomeIsStarted() .assertAuthIsNotStarted() @@ -76,16 +72,47 @@ class SplashActivityTest : KoinTest { SetupLoggedInState.setupLogout() } - /** GIVEN loggedOffState WHEN opened THEN AuthActivity is started */ + /** GIVEN loggedOffState WHEN opened after some time THEN AuthActivity is started */ @Test fun loggedOutStatesNavigatesToAuthentication() { SetupLoggedInState.setupLogout() activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) - mainDispatcherTestRule.advanceTimeBy(500) + mainDispatcherTestRule.advanceTimeBy(501) splashRobot.assertAuthIsStarted() .assertHomeIsNotStarted() } + + /** GIVEN loggedOffState and not enough time WHEN opened THEN no activity is started */ + @Test + fun loggedOutStatesNotEnoughTime() { + SetupLoggedInState.setupLogout() + + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + mainDispatcherTestRule.advanceTimeBy(10) + + splashRobot.assertAuthIsNotStarted() + .assertHomeIsNotStarted() + } + + /** GIVEN loggedInState and not enough time WHEN opened THEN no activity is started */ + @Test + fun loggedInStatesNotEnoughTime() { + SetupLoggedInState.setupLogin(mockServerScenarioSetupTestRule.mockServerScenarioSetup) + + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + mainDispatcherTestRule.advanceTimeBy(10) + + splashRobot.assertHomeIsNotStarted() + .assertAuthIsNotStarted() + + SetupLoggedInState.setupLogout() + } } From b52652ed676080687725656a08d8d8d479e714e1 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Sun, 23 Jan 2022 22:45:25 +0200 Subject: [PATCH 07/10] Issue#11 Fix codeAnalysis errors --- .../FavouriteContentLocalStorageImplTest.kt | 36 +++++++++---------- .../AddContentToFavouriteUseCaseTest.kt | 1 - .../core/content/GetAllContentUseCaseTest.kt | 2 -- .../RemoveContentFromFavouritesUseCaseTest.kt | 1 - .../core/login/koin/LogoutUseCaseTest.kt | 1 - gradlescripts/versions.gradle | 8 ++--- 6 files changed, 21 insertions(+), 28 deletions(-) diff --git a/app/src/robolectricTestKoin/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt b/app/src/robolectricTestKoin/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt index c05948f..2c77b78 100644 --- a/app/src/robolectricTestKoin/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt +++ b/app/src/robolectricTestKoin/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt @@ -56,23 +56,21 @@ internal class FavouriteContentLocalStorageImplTest : KoinTest { /** GIVEN content_id added WHEN removed to Favourite THEN it no longer can be read out */ @Test fun contentIdAddedThenRemovedCanNoLongerBeReadOut() = runTest(testDispatcher) { - val expected = listOf() - sut.markAsFavourite(ContentId("b")) + val expected = listOf() + sut.markAsFavourite(ContentId("b")) - sut.deleteAsFavourite(ContentId("b")) - val actual = sut.observeFavourites().first() + sut.deleteAsFavourite(ContentId("b")) + val actual = sut.observeFavourites().first() - Assert.assertEquals(expected, actual) - } + Assert.assertEquals(expected, actual) + } /** GIVEN empty database WHILE observing content WHEN favourite added THEN change is emitted */ @Test fun addingFavouriteUpdatesExistingObservers() = runTest(testDispatcher) { val expected = listOf(listOf(), listOf(ContentId("a"))) - val actual = async(coroutineContext) { - sut.observeFavourites().take(2).toList() - } + val actual = async(coroutineContext) { sut.observeFavourites().take(2).toList() } advanceUntilIdle() sut.markAsFavourite(ContentId("a")) @@ -84,17 +82,17 @@ internal class FavouriteContentLocalStorageImplTest : KoinTest { /** GIVEN non empty database WHILE observing content WHEN favourite removed THEN change is emitted */ @Test fun removingFavouriteUpdatesExistingObservers() = runTest(testDispatcher) { - val expected = listOf(listOf(ContentId("a")), listOf()) - sut.markAsFavourite(ContentId("a")) + val expected = listOf(listOf(ContentId("a")), listOf()) + sut.markAsFavourite(ContentId("a")) - val actual = async(coroutineContext) { - sut.observeFavourites().take(2).toList() - } - advanceUntilIdle() - - sut.deleteAsFavourite(ContentId("a")) + val actual = async(coroutineContext) { + sut.observeFavourites().take(2).toList() + } advanceUntilIdle() - Assert.assertEquals(expected, actual.getCompleted()) - } + sut.deleteAsFavourite(ContentId("a")) + advanceUntilIdle() + + Assert.assertEquals(expected, actual.getCompleted()) + } } 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 98815f0..652a326 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 @@ -4,7 +4,6 @@ import kotlinx.coroutines.runBlocking 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 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 4f51d4d..c352b21 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,11 +1,9 @@ package org.fnives.test.showcase.core.content 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.runBlockingTest import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage import org.fnives.test.showcase.model.content.Content 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 d020b03..31fa1da 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 @@ -1,7 +1,6 @@ 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 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 7432626..6fc404f 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,5 @@ 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 diff --git a/gradlescripts/versions.gradle b/gradlescripts/versions.gradle index b7ac346..fecdcec 100644 --- a/gradlescripts/versions.gradle +++ b/gradlescripts/versions.gradle @@ -1,11 +1,11 @@ project.ext { androidx_core_version = "1.7.0" - androidx_appcompat_version = "1.4.0" - androidx_material_version = "1.4.0" - androidx_constraintlayout_version = "2.1.2" + androidx_appcompat_version = "1.4.1" + androidx_material_version = "1.5.0" + androidx_constraintlayout_version = "2.1.3" androidx_livedata_version = "2.4.0" androidx_swiperefreshlayout_version = "1.1.0" - androidx_room_version = "2.4.0" + androidx_room_version = "2.4.1" activity_ktx_version = "1.4.0" coroutines_version = "1.6.0" From a71fa67de2974365a9442daf07e11cdae06d9e7b Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Sun, 23 Jan 2022 22:59:07 +0200 Subject: [PATCH 08/10] Issue#11 Create Turbine variant of Flow Tests --- .../core/content/ContentRepositoryTest.kt | 25 -- .../core/content/GetAllContentUseCaseTest.kt | 60 +++-- .../content/TurbineContentRepositoryTest.kt | 165 +++++++++++++ .../TurbineGetAllContentUseCaseTest.kt | 228 ++++++++++++++++++ 4 files changed, 428 insertions(+), 50 deletions(-) create mode 100644 core/src/test/java/org/fnives/test/showcase/core/content/TurbineContentRepositoryTest.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/content/TurbineGetAllContentUseCaseTest.kt 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 89d2c27..eb24e76 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,6 +1,5 @@ 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 @@ -147,28 +146,4 @@ internal class ContentRepositoryTest { Assertions.assertFalse(actual.isCompleted) actual.cancel() } - - @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/GetAllContentUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/GetAllContentUseCaseTest.kt index c352b21..cca4a93 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,9 +1,10 @@ package org.fnives.test.showcase.core.content -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.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage import org.fnives.test.showcase.model.content.Content @@ -151,15 +152,18 @@ internal class GetAllContentUseCaseTest { Resource.Success(listOf(FavouriteContent(content, true))) ) - sut.get().test { - contentFlow.value = Resource.Success(listOf(content)) - favouriteContentIdFlow.value = listOf(ContentId("a")) - - expected.forEach { expectedItem -> - Assertions.assertEquals(expectedItem, awaitItem()) - } - Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + val actual = async(coroutineContext) { + sut.get().take(3).toList() } + advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + advanceUntilIdle() + + favouriteContentIdFlow.value = listOf(ContentId("a")) + advanceUntilIdle() + + Assertions.assertEquals(expected, actual.getCompleted()) } @DisplayName("GIVEN loading then data then removed favourite WHEN observed THEN loading then correct favourites are returned") @@ -174,15 +178,18 @@ internal class GetAllContentUseCaseTest { Resource.Success(listOf(FavouriteContent(content, false))) ) - sut.get().test { - contentFlow.value = Resource.Success(listOf(content)) - favouriteContentIdFlow.value = emptyList() - - expected.forEach { expectedItem -> - Assertions.assertEquals(expectedItem, awaitItem()) - } - Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + val actual = async(coroutineContext) { + sut.get().take(3).toList() } + advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + advanceUntilIdle() + + favouriteContentIdFlow.value = emptyList() + advanceUntilIdle() + + Assertions.assertEquals(expected, actual.getCompleted()) } @DisplayName("GIVEN loading then data then loading WHEN observed THEN loading then correct favourites then loading are returned") @@ -197,14 +204,17 @@ internal class GetAllContentUseCaseTest { Resource.Loading() ) - sut.get().test { - contentFlow.value = Resource.Success(listOf(content)) - contentFlow.value = Resource.Loading() - - expected.forEach { expectedItem -> - Assertions.assertEquals(expectedItem, awaitItem()) - } - Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + val actual = async(coroutineContext) { + sut.get().take(3).toList() } + advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + advanceUntilIdle() + + contentFlow.value = Resource.Loading() + advanceUntilIdle() + + Assertions.assertEquals(expected, actual.getCompleted()) } } diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/TurbineContentRepositoryTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/TurbineContentRepositoryTest.kt new file mode 100644 index 0000000..1e7e120 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/content/TurbineContentRepositoryTest.kt @@ -0,0 +1,165 @@ +package org.fnives.test.showcase.core.content + +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +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 +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.fnives.test.showcase.network.content.ContentRemoteSource +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.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +class TurbineContentRepositoryTest { + + private lateinit var sut: ContentRepository + private lateinit var mockContentRemoteSource: ContentRemoteSource + + @BeforeEach + fun setUp() { + mockContentRemoteSource = mock() + sut = ContentRepository(mockContentRemoteSource) + } + + @DisplayName("GIVEN no interaction THEN remote source is not called") + @Test + fun fetchingIsLazy() { + verifyNoMoreInteractions(mockContentRemoteSource) + } + + @DisplayName("GIVEN content response WHEN content observed THEN loading AND data is returned") + @Test + fun happyFlow() = runTest { + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(Content(ContentId("a"), "", "", ImageUrl("")))) + ) + whenever(mockContentRemoteSource.get()).doReturn( + listOf(Content(ContentId("a"), "", "", ImageUrl(""))) + ) + + sut.contents.test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN content error WHEN content observed THEN loading AND data is returned") + @Test + fun errorFlow() = runTest { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + whenever(mockContentRemoteSource.get()).doThrow(exception) + + sut.contents.test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN saved cache WHEN collected THEN cache is returned") + @Test + fun verifyCaching() = runTest { + val content = Content(ContentId("1"), "", "", ImageUrl("")) + val expected = listOf(Resource.Success(listOf(content))) + whenever(mockContentRemoteSource.get()).doReturn(listOf(content)) + sut.contents.take(2).toList() + + sut.contents.test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + verify(mockContentRemoteSource, times(1)).get() + } + + @DisplayName("GIVEN no response from remote source WHEN content observed THEN loading is returned") + @Test + fun loadingIsShownBeforeTheRequestIsReturned() = runTest { + val expected = listOf(Resource.Loading>()) + val suspendedRequest = CompletableDeferred() + whenever(mockContentRemoteSource.get()).doSuspendableAnswer { + suspendedRequest.await() + emptyList() + } + + sut.contents.test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + suspendedRequest.complete(Unit) + } + + @DisplayName("GIVEN content response THEN error WHEN fetched THEN returned states are loading data loading error") + @Test + 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 + } + + sut.contents.test { + sut.fetch() + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + } + } + + @DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted") + @Test + fun noAdditionalItemsEmitted() = 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/TurbineGetAllContentUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/TurbineGetAllContentUseCaseTest.kt new file mode 100644 index 0000000..463be19 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/content/TurbineGetAllContentUseCaseTest.kt @@ -0,0 +1,228 @@ +package org.fnives.test.showcase.core.content + +import app.cash.turbine.test +import kotlinx.coroutines.flow.MutableStateFlow +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 +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +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.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class TurbineGetAllContentUseCaseTest { + + private lateinit var sut: GetAllContentUseCase + private lateinit var mockContentRepository: ContentRepository + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + private lateinit var contentFlow: MutableStateFlow>> + private lateinit var favouriteContentIdFlow: MutableStateFlow> + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + mockContentRepository = mock() + favouriteContentIdFlow = MutableStateFlow(emptyList()) + contentFlow = MutableStateFlow(Resource.Loading()) + whenever(mockFavouriteContentLocalStorage.observeFavourites()).doReturn( + favouriteContentIdFlow + ) + whenever(mockContentRepository.contents).doReturn(contentFlow) + sut = GetAllContentUseCase(mockContentRepository, mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN loading AND empty favourite WHEN observed THEN loading is shown") + @Test + fun loadingResourceWithNoFavouritesResultsInLoadingResource() = runTest { + favouriteContentIdFlow.value = emptyList() + contentFlow.value = Resource.Loading() + val expected = listOf(Resource.Loading>()) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN loading AND listOfFavourite WHEN observed THEN loading is shown") + @Test + fun loadingResourceWithFavouritesResultsInLoadingResource() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + contentFlow.value = Resource.Loading() + val expected = listOf(Resource.Loading>()) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN error AND empty favourite WHEN observed THEN error is shown") + @Test + fun errorResourceWithNoFavouritesResultsInErrorResource() = runTest { + favouriteContentIdFlow.value = emptyList() + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = listOf(Resource.Error>(exception)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN error AND listOfFavourite WHEN observed THEN error is shown") + @Test + fun errorResourceWithFavouritesResultsInErrorResource() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("b")) + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = listOf(Resource.Error>(exception)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN listOfContent AND empty favourite WHEN observed THEN favourites are returned") + @Test + 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 = listOf(Resource.Success(items)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN listOfContent AND other favourite id WHEN observed THEN favourites are returned") + @Test + 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 = listOf(Resource.Success(items)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN listOfContent AND same favourite id WHEN observed THEN favourites are returned") + @Test + 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 = listOf(Resource.Success(items)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN loading then data then added favourite WHEN observed THEN loading then correct favourites are returned") + @Test + 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)) + favouriteContentIdFlow.value = listOf(ContentId("a")) + + 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() = 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)) + favouriteContentIdFlow.value = emptyList() + + 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() = 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)) + contentFlow.value = Resource.Loading() + + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } +} From e9a22dd60a5fe17ccf1e1a349a825b40318e6742 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Sun, 23 Jan 2022 23:21:16 +0200 Subject: [PATCH 09/10] Issue#11 Add annotation @OptIn(ExperimentalCoroutinesApi::class) where its needed --- .../org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt | 2 +- .../org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt | 2 ++ .../showcase/core/content/AddContentToFavouriteUseCaseTest.kt | 2 ++ .../test/showcase/core/content/CodeKataContentRepositoryTest.kt | 2 ++ .../fnives/test/showcase/core/content/ContentRepositoryTest.kt | 2 ++ .../test/showcase/core/content/FetchContentUseCaseTest.kt | 2 ++ .../test/showcase/core/content/GetAllContentUseCaseTest.kt | 2 ++ .../core/content/RemoveContentFromFavouritesUseCaseTest.kt | 2 ++ .../test/showcase/core/content/TurbineContentRepositoryTest.kt | 2 ++ .../showcase/core/content/TurbineGetAllContentUseCaseTest.kt | 2 ++ .../org/fnives/test/showcase/core/login/LoginUseCaseTest.kt | 2 ++ .../fnives/test/showcase/core/login/hilt/LogoutUseCaseTest.kt | 2 ++ .../fnives/test/showcase/core/login/koin/LogoutUseCaseTest.kt | 2 ++ .../org/fnives/test/showcase/core/shared/AnswerUtilsKtTest.kt | 2 ++ 14 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt index f5d8193..2d4fc4d 100644 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt @@ -29,7 +29,7 @@ class ShadowSnackbar { @Implementation @JvmStatic fun make(view: View, text: CharSequence, duration: Int): Snackbar? { - var snackbar: Snackbar? = null + val snackbar: Snackbar? try { val constructor = Snackbar::class.java.getDeclaredConstructor( Context::class.java, diff --git a/app/src/test/java/org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt b/app/src/test/java/org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt index eb996f2..74d65d2 100644 --- a/app/src/test/java/org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt +++ b/app/src/test/java/org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt @@ -1,5 +1,6 @@ package org.fnives.test.showcase.ui.auth +import kotlinx.coroutines.ExperimentalCoroutinesApi import org.fnives.test.showcase.core.login.LoginUseCase import org.fnives.test.showcase.testutils.InstantExecutorExtension import org.fnives.test.showcase.testutils.TestMainDispatcher @@ -12,6 +13,7 @@ import org.mockito.kotlin.mock @Disabled("CodeKata") @ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) +@OptIn(ExperimentalCoroutinesApi::class) class CodeKataAuthViewModel { private lateinit var sut: AuthViewModel 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 652a326..9b85438 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,5 +1,6 @@ package org.fnives.test.showcase.core.content +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage @@ -17,6 +18,7 @@ import org.mockito.kotlin.verifyZeroInteractions import org.mockito.kotlin.whenever @Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) internal class AddContentToFavouriteUseCaseTest { private lateinit var sut: AddContentToFavouriteUseCase 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 ee79ad0..3d78816 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,5 +1,6 @@ package org.fnives.test.showcase.core.content +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Disabled @@ -7,6 +8,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @Disabled("CodeKata") +@OptIn(ExperimentalCoroutinesApi::class) class CodeKataContentRepositoryTest { @BeforeEach 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 eb24e76..8b1b7d7 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,6 +1,7 @@ package org.fnives.test.showcase.core.content import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList @@ -28,6 +29,7 @@ import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever @Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) internal class ContentRepositoryTest { private lateinit var sut: ContentRepository 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 46adab1..52722ce 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,5 +1,6 @@ package org.fnives.test.showcase.core.content +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertThrows @@ -15,6 +16,7 @@ import org.mockito.kotlin.verifyZeroInteractions import org.mockito.kotlin.whenever @Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) internal class FetchContentUseCaseTest { private lateinit var sut: FetchContentUseCase 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 cca4a93..26cbeb6 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,5 +1,6 @@ package org.fnives.test.showcase.core.content +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.take @@ -21,6 +22,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) internal class GetAllContentUseCaseTest { private lateinit var sut: GetAllContentUseCase 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 31fa1da..2ae0064 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 @@ -1,5 +1,6 @@ package org.fnives.test.showcase.core.content +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage @@ -17,6 +18,7 @@ import org.mockito.kotlin.verifyZeroInteractions import org.mockito.kotlin.whenever @Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) internal class RemoveContentFromFavouritesUseCaseTest { private lateinit var sut: RemoveContentFromFavouritesUseCase diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/TurbineContentRepositoryTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/TurbineContentRepositoryTest.kt index 1e7e120..136de71 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/content/TurbineContentRepositoryTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/content/TurbineContentRepositoryTest.kt @@ -2,6 +2,7 @@ package org.fnives.test.showcase.core.content import app.cash.turbine.test import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -26,6 +27,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever +@OptIn(ExperimentalCoroutinesApi::class) class TurbineContentRepositoryTest { private lateinit var sut: ContentRepository diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/TurbineGetAllContentUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/TurbineGetAllContentUseCaseTest.kt index 463be19..172a180 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/content/TurbineGetAllContentUseCaseTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/content/TurbineGetAllContentUseCaseTest.kt @@ -1,6 +1,7 @@ package org.fnives.test.showcase.core.content import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage @@ -17,6 +18,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +@OptIn(ExperimentalCoroutinesApi::class) class TurbineGetAllContentUseCaseTest { private lateinit var sut: GetAllContentUseCase 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 b06d399..43cfb72 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,5 +1,6 @@ package org.fnives.test.showcase.core.login +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.shared.UnexpectedException import org.fnives.test.showcase.core.storage.UserDataLocalStorage @@ -23,6 +24,7 @@ import org.mockito.kotlin.verifyZeroInteractions import org.mockito.kotlin.whenever @Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) internal class LoginUseCaseTest { private lateinit var sut: LoginUseCase 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 b9d817e..a3fa397 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,5 +1,6 @@ package org.fnives.test.showcase.core.login.hilt +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.content.ContentRepository import org.fnives.test.showcase.core.login.LogoutUseCase @@ -16,6 +17,7 @@ import org.mockito.kotlin.verifyZeroInteractions import javax.inject.Inject @Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) internal class LogoutUseCaseTest { @Inject 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 6fc404f..1c125e7 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,5 +1,6 @@ package org.fnives.test.showcase.core.login.koin +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.content.ContentRepository import org.fnives.test.showcase.core.di.koin.createCoreModule @@ -21,6 +22,7 @@ import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.verifyZeroInteractions @Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) internal class LogoutUseCaseTest : KoinTest { private lateinit var sut: LogoutUseCase 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 0ce5312..ea9fd2f 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 @@ -1,6 +1,7 @@ package org.fnives.test.showcase.core.shared import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.model.shared.Answer @@ -12,6 +13,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) internal class AnswerUtilsKtTest { @DisplayName("GIVEN network exception thrown WHEN wrapped into answer THEN answer error is returned") From 27c708fdfc336235ff4cbadec46a5b4d13b3f8de Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Mon, 24 Jan 2022 00:43:06 +0200 Subject: [PATCH 10/10] Issue#11 Review and Update instruction set, additionally update the CodeKata class --- README.md | 2 +- ....instructionset => core.instructionset.md} | 179 +++++++++++------- .../content/CodeKataContentRepositoryTest.kt | 7 +- .../core/content/ContentRepositoryTest.kt | 5 +- 4 files changed, 120 insertions(+), 73 deletions(-) rename codekata/{core.instructionset => core.instructionset.md} (74%) diff --git a/README.md b/README.md index ecc93a8..3f97af3 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ The Code Kata is structured into 5 different section, each section in different Since our layering is "app", "core" and "networking", of course we will jump right into the middle and start with core. #### Core -Open the [core instruction set](./codekata/core.instructionset). +Open the [core instruction set](./codekata/core.instructionset.md). The core tests are the simplest, we will look into how to use mockito to mock class dependencies and write our first simple tests. diff --git a/codekata/core.instructionset b/codekata/core.instructionset.md similarity index 74% rename from codekata/core.instructionset rename to codekata/core.instructionset.md index 8dfeb6d..c5e0e34 100644 --- a/codekata/core.instructionset +++ b/codekata/core.instructionset.md @@ -48,9 +48,9 @@ that mock object to return what we want and verify its method calls. ### 2. First simple test -So now you need to write your first test. When testing, first you should start with the simplest test, so let's just do that. +So now you need to write your first test. When testing, first you should start with the simplest case, so let's just do that. -When the class is created, the delegate should not yet be touched, so create a test for that: +When the class is created, the delegate should not yet be touched, so we start there: ```kotlin @DisplayName("WHEN nothing is changed THEN delegate is not touched") // this will show up when running our tests and is a great way to document what we are testing @@ -61,6 +61,7 @@ fun verifyNoInteractionsIfNoInvocations() { ``` Now let's run out Test, to do this: + - Remove the `@Disabled` annotation if any - on project overview right click on FirstSessionExpirationAdapterTest - click run - => At this point we should see Tests passed: 1 of 1 test. @@ -81,10 +82,11 @@ fun verifyOnSessionExpirationIsDelegated() { } ``` -Now let's run our tests with coverage: to do this: -- right click on the file -- click "Run with coverage". -- => We can see the SessionExpirationAdapter is fully covered. +Now let's run our tests with coverage, to do this: + - right click on the file + - click "Run with coverage". + - navigate in the result to it's package + - => We can see the SessionExpirationAdapter is fully covered. If we did everything right, our test should be identical to SessionExpirationAdapterTest. @@ -94,10 +96,10 @@ Our System Under Test will be `org.fnives.test.showcase.core.login.LoginUseCase` What it does is: - verifies parameters, -- if they are invalid it returns an Error Answer with the error -- if valid then calls the remote source -- if that's successful it saves the received data and returns Success Answer -- if the request fails Error Answer is returned +- if they are invalid then it returns an Error Answer with the error +- if valid then it calls the remote source + - if that's successful it saves the received data and returns Success Answer + - if the request fails Error Answer is returned Now this is a bit more complicated, let's open our test file: @@ -105,6 +107,8 @@ Now this is a bit more complicated, let's open our test file: org.fnives.test.showcase.core.login.CodeKataSecondLoginUseCaseTest ``` +### 0. Setup + - declare the `sut` variable and its dependencies, you should be familiar how to do this by now. ### 1. `emptyUserNameReturnsLoginStatusError` @@ -133,7 +137,7 @@ 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 `runTest`, 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 test coroutine scope 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") @@ -151,11 +155,13 @@ fun emptyUserNameReturnsLoginStatusError() = runTest { `Assertions.assertEquals` throws an exception if the `expected` is not equal to the `actual` value. The first parameter is the expected in all assertion methods. +Before running the test don't forget to remove the `@Disabled` annotation. + ### 2. `emptyPasswordNameReturnsLoginStatusError` Next do the same thing for `emptyPasswordNameReturnsLoginStatusError` -This is really similar, so try to write it on your own, but for progress the code is here: +This is really similar, so try to write it on your own, but if you get stuck, the code is here: ```kotlin @DisplayName("GIVEN empty password WHEN trying to login THEN invalid password is returned") @@ -172,7 +178,7 @@ fun emptyPasswordNameReturnsLoginStatusError() = runTest { ``` You may think that's bad to duplicate code in such a way, but you need to remember in testing it's not as important to not duplicate code. -Also we have the possibility to reduce this duplication, we will touch this in the app module test. +Also we have the possibility to reduce this duplication, we will touch on this later in the app module tests. ### 3. `invalidLoginResponseReturnInvalidCredentials` @@ -224,17 +230,17 @@ fun invalidLoginResponseReturnInvalidCredentials() = runTest { } ``` -Now we see how we can mock responses. +With that we saw how we can mock responses. ### 4. `validResponseResultsInSavingSessionAndSuccessReturned`, Now continue with `validResponseResultsInSavingSessionAndSuccessReturned`. You should have almost every tool to do this test: - declare the expected value -- do the mock response -- call the system under test +- define the mock response +- call the System Under Test - verify the actual result to the expected - verify the localStorage's session was saved once, and only once: `verify(mockUserDataLocalStorage, times(1)).session = Session("c", "d")` -- verify the localStorage was not touched anymore. +- verify the localStorage was not touched anymore: `verifyNoMoreInteractions(mockUserDataLocalStorage)` The full code: ```kotlin @@ -286,10 +292,6 @@ Verification: ```kotlin Assertions.assertEquals(expected, actual) verifyZeroInteractions(mockUserDataLocalStorage) - -- Now we saw how to mock invocations on our mock objects -- How to test suspend functions -- and the pattern of GIVEN-WHEN-THEN description. ``` Together: @@ -307,12 +309,20 @@ fun invalidResponseResultsInErrorReturned() = runTest { Assertions.assertEquals(expected, actual) verifyZeroInteractions(mockUserDataLocalStorage) - } +} ``` +#### Lessons learned +- Now we saw how to mock invocations on our mock objects +- How to run our tests +- How to test suspend functions +- and the pattern of GIVEN-WHEN-THEN description. ## Our third Class Test with flows -Our system under test will be org.fnives.test.showcase.core.content.ContentRepository +Our system under test will be +```kotlin +org.fnives.test.showcase.core.content.ContentRepository +``` It has two methods: - getContents: that returns a Flow, which emits loading, error and content data @@ -321,9 +331,14 @@ It has two methods: The content data come from a RemoteSource class. Additionally the Content is cached. So observing again should not yield loading. -The inner workings of the class shouldn't matter, just the public apis, since that's what we want to test. +The inner workings of the class shouldn't matter, just the public apis, since that's what we want to test, always. -For setup we declare the system under test and its mock argument. +Our Test class will be +```kotlin +org.fnives.test.showcase.core.content.CodeKataContentRepositoryTest +``` + +For setup we declare the system under test and its mock argument as usual. ```kotlin private lateinit var sut: ContentRepository @@ -374,7 +389,7 @@ Now just the verifications: Assertions.assertEquals(expected, actual) ```` -Note we don't verify the request has been called, since it's implied. It returns the same data we returned from the request, so it must have been called. +Notice we don't verify the request has been called, since it's implied. It returns the same data we returned from the request, so it must have been called. ### 3. ```errorFlow``` @@ -421,15 +436,14 @@ Assertions.assertEquals(expected, actual) ### 5. `loadingIsShownBeforeTheRequestIsReturned` -So far we just expected the first element is "loading", but it could easily happen that the flow is set up in such a way that the loading is not emitted -before the request already finished. +So far we just expected the first element is "loading", but it could easily happen that the flow is set up in such a way that the loading is not emitted before the request already finished. This can be an easy mistake with such flows, but would be really bad UX, so let's see how we can verify something like that: -We need to suspend the request calling and verify that before that is finished the Loading is already emitted. +We need to suspend the request calling. Verify that before the request call is finished the Loading is already emitted. So the issue becomes how can we suspend the mock until a signal is given. -Generally we could still use mockito mocks OR we could create our own Mock. +Generally we could still use mockito mocks OR we could create our own Mock (Fake). #### Creating our own mock. @@ -453,9 +467,9 @@ class SuspendingContentRemoteSource { } ``` -In this case we should recreate our sut in the test and feed it our own remote source. +In this case we should recreate our sut in the test and feed it our own remote source for this test. -#### Still using mockito +#### Still using mockito. To mock such behaviour with mockito with our current tool set is not as straight forward as creating our own. That's because how we used mockito so far it is not aware of the nature of suspend functions, like our code is in the custom mock. @@ -470,7 +484,7 @@ Luckily this has already been done in "org.mockito.kotlin" and it's called `doSu The point here is that we can get arguments while mocking with mockito, and we are able to extend it in a way that helps us in common patterns. -This `doSuspendableAnswer` wasn't available for a while, but we could still create it, if needed. +This `doSuspendableAnswer` wasn't available for a while, but we could still create it on our own before, if it was needed. #### Back to the actual test @@ -500,7 +514,7 @@ suspendedRequest.complete(Unit) ### 6. `whenFetchingRequestIsCalledAgain` -We still didn't even touch the fetch method so let's test the behaviour next: +We still didn't even touch the fetch method so let's test that behaviour next: We want to get the first result triggered by the subscription to the flow, and then again another loading and result after a call to `fetch`, so the setup would be: ```kotlin @@ -517,7 +531,7 @@ whenever(mockContentRemoteSource.get()).doAnswer { } ``` -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. +However the main issue here is, when to call fetch? If we call after `take()` we will never reach it since we are suspended by take. But if we call it before then it doesn't test the right behaviour. We need to do it async: ```kotlin @@ -540,7 +554,17 @@ 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. +If we run this test, now it will pass. Let's break down exactly what happens now: + - The test creates the exception, expected, mocking and create the async but doesn't start it + - advanceUntilIdle will run the async until it's suspended, aka it receives two elements + - Now we get back to advanceUntilIdle and call sut.fetch() + - Note: at this point the async is still suspended + - Then actual.await() will suspend so the async continues until it finishes + - async received all the elements, by continuing the flow + - async finishes so we compare values + - => This shows us that we have full control over the execution order which makes `runTest` a great utility for us. + +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()) { @@ -552,8 +576,17 @@ fun whenFetchingRequestIsCalledAgain() = runTest(UnconfinedTestDispatcher()) { Assertions.assertEquals(expected, actual.await()) } ``` +Let's break down what changed with `UnconfinedTestDispatcher` + - The test still creates the exception, expected, mocking and create the async but doesn't start it + - The test creates the async and starts to execute it + - async suspends after the 2nd element received + - at this point the next execution is `sut.fetch()` since async got suspended + - Then actual.await() will suspend so the async continues until it finishes + - async received all the elements, by continuing the flow + - async finishes so we compare values + - => This shows us `UnconfinedTestDispatcher` basically gave us the same execution order except the manual declaration of `advanceUntilIdle` -Now we can test even complicated interactions between methods and classes with test dispatchers. +##### Now we can test even complicated interactions between methods and classes with test dispatchers. ### 7. `noAdditionalItemsEmitted` @@ -562,47 +595,56 @@ 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. +Luckily `async.isCompleted` is helpful here: We can check if the async actually finished, aka if it still suspended or complete. +Alternatively when checking with values, we may use `async.getCompleted()` as well, since if a coroutine didn't finish properly it will throw an `IllegalStateException("This job has not completed yet")`. -So all we need to do is to request more than elements it should send out and expect an IllegalStateException from runBlocking. +So all we need to do is verify that the actual deferred is completed at the end. +With this we no longer need the expected values. -So our method looks just like `whenFetchingRequestIsCalledAgain` except wrapped into an IllegalStateException expectation, and requesting 5 elements instead of 4. +So our method looks similar to `whenFetchingRequestIsCalledAgain` except: +- We no longer have expected values +- We check if the async is completed +- We need an additional `advanceUntilIdle` after fetch so the async has a possibility to actually complete +- And requesting 5 elements instead of 4. +- And cancel the async since we no longer need it + +Note: if it confuses you why we need the additional `advanceUntilIdle` refer to the execution order descried above. The async got their 3rd and 4th values because we were using await. ```kotlin -Assertions.assertThrows(IllegalStateException::class.java) { - 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(5).toList() } - testDispatcher.advanceUntilIdle() - 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 noAdditionalItemsEmitted() = runTest { + val exception = RuntimeException() + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception // notice first time we return success next we return error } + + val actual = async { + sut.contents.take(5).toList() + } + advanceUntilIdle() + sut.fetch() + advanceUntilIdle() + + Assertions.assertFalse(actual.isCompleted) + actual.cancel() } ``` -### 8. `noAdditionalItemsEmittedWithTurbine` +###### Now just to verify our test tests what we want, switch the 5 to a 4 and run the test again. If our test setup is correct, now it should fail, since we expect that the async doesn't complete. -Turbine is library that provides some testing utilities for Flow. +### 8. Turbine `noAdditionalItemsEmittedWithTurbine` + +Until now we were testing with async and taking values, this can be tidious for some, so here is an alternative: + +[Turbine](https://github.com/cashapp/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: +Keeping the same setup as in `whenFetchingRequestIsCalledAgain` we can use turbine to test `contents` as follows: ```kotlin sut.contents.test { Assertions.assertEquals(expected[0], awaitItem()) @@ -614,9 +656,8 @@ sut.contents.test { } ``` -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: +The code seems pretty recognizable, the execution order follows what we have been doing before. +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() @@ -634,8 +675,8 @@ Here we went over most common cases when you need to test simple java / kotlin f - how to setup and structure your test - how to run your tests - a convention to naming your tests -- how to use mockito to mock dependencies of your system under test +- how to use mockito to mock dependencies of your System Under Test objects - how to test suspend functions - how to test flows - how to verify your mock usage -- how to verify success and error states +- how to assert responses 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 3d78816..0e10faa 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 @@ -47,6 +47,11 @@ class CodeKataContentRepositoryTest { @DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted") @Test - fun noAdditionalItemsEmitted() { + fun noAdditionalItemsEmitted() = runTest { + } + + @DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted") + @Test + fun noAdditionalItemsEmittedWithTurbine() = runTest { } } 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 8b1b7d7..1497067 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 @@ -5,7 +5,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.fnives.test.showcase.core.shared.UnexpectedException @@ -110,7 +109,7 @@ internal class ContentRepositoryTest { @DisplayName("GIVEN content response THEN error WHEN fetched THEN returned states are loading data loading error") @Test - fun whenFetchingRequestIsCalledAgain() = runTest(UnconfinedTestDispatcher()) { + fun whenFetchingRequestIsCalledAgain() = runTest() { val exception = RuntimeException() val expected = listOf( Resource.Loading(), @@ -126,7 +125,9 @@ internal class ContentRepositoryTest { val actual = async { sut.contents.take(4).toList() } + advanceUntilIdle() sut.fetch() + advanceUntilIdle() Assertions.assertEquals(expected, actual.getCompleted()) }