Merge pull request #37 from fknives/update-core-tests

Update core tests
This commit is contained in:
Gergely Hegedis 2022-01-24 00:55:06 +02:00 committed by GitHub
commit 959f79997c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1059 additions and 533 deletions

View file

@ -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.

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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<ContentId>()
sut.markAsFavourite(ContentId("b"))
fun contentIdAddedThenRemovedCanNoLongerBeReadOut() = runTest(testDispatcher) {
val expected = listOf<ContentId>()
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<Unit> {
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<Unit> {
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.await())
val actual = async(coroutineContext) {
sut.observeFavourites().take(2).toList()
}
advanceUntilIdle()
sut.deleteAsFavourite(ContentId("a"))
advanceUntilIdle()
Assert.assertEquals(expected, actual.getCompleted())
}
}

View file

@ -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<FavouriteContentLocalStorage>()
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,49 +55,44 @@ internal class FavouriteContentLocalStorageImplTest : KoinTest {
/** GIVEN content_id added WHEN removed to Favourite THEN it no longer can be read out */
@Test
fun contentIdAddedThenRemovedCanNoLongerBeReadOut() =
runBlocking {
val expected = listOf<ContentId>()
sut.markAsFavourite(ContentId("b"))
fun contentIdAddedThenRemovedCanNoLongerBeReadOut() = runTest(testDispatcher) {
val expected = listOf<ContentId>()
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<Unit> {
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()
val actual = async(coroutineContext) { sut.observeFavourites().take(2).toList() }
advanceUntilIdle()
sut.markAsFavourite(ContentId("a"))
sut.markAsFavourite(ContentId("a"))
advanceUntilIdle()
Assert.assertEquals(expected, actual.await())
}
Assert.assertEquals(expected, actual.getCompleted())
}
/** GIVEN non empty database WHILE observing content WHEN favourite removed THEN change is emitted */
@Test
fun removingFavouriteUpdatesExistingObservers() =
runBlocking<Unit> {
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.await())
val actual = async(coroutineContext) {
sut.observeFavourites().take(2).toList()
}
advanceUntilIdle()
sut.deleteAsFavourite(ContentId("a"))
advanceUntilIdle()
Assert.assertEquals(expected, actual.getCompleted())
}
}

View file

@ -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() {

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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")
}
}

View file

@ -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()

View file

@ -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

View file

@ -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")) }

View file

@ -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()
}
}

View file

@ -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<MainViewModel>()
private val splashViewModel by inject<SplashViewModel>()
@BeforeEach
fun setUp() {
TestMainDispatcher.testDispatcher.pauseDispatcher()
}
@AfterEach
fun tearDown() {
stopKoin()

View file

@ -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,14 +43,14 @@ 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
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,25 +107,27 @@ 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.
### 0. Setup
- 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
@ -133,12 +137,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 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")
@Test
fun emptyUserNameReturnsLoginStatusError() = runBlockingTest {
fun emptyUserNameReturnsLoginStatusError() = runTest {
val expected = Answer.Success(LoginStatus.INVALID_USERNAME)
val actual = sut.invoke(LoginCredentials("", "a"))
@ -151,16 +155,18 @@ fun emptyUserNameReturnsLoginStatusError() = runBlockingTest {
`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")
@Test
fun emptyPasswordNameReturnsLoginStatusError() = runBlockingTest {
fun emptyPasswordNameReturnsLoginStatusError() = runTest {
val expected = Answer.Success(LoginStatus.INVALID_PASSWORD)
val actual = sut.invoke(LoginCredentials("a", ""))
@ -172,7 +178,7 @@ fun emptyPasswordNameReturnsLoginStatusError() = runBlockingTest {
```
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`
@ -212,7 +218,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)
@ -224,23 +230,23 @@ fun invalidLoginResponseReturnInvalidCredentials() = runBlockingTest {
}
```
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:
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
@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")))
@ -255,15 +261,15 @@ fun validResponseResultsInSavingSessionAndSuccessReturned() = runBlockingTest {
### 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<LoginStatus>(UnexpectedException(exception))
@ -275,29 +281,25 @@ 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)
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:
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<LoginStatus>(UnexpectedException(exception))
whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b")))
@ -307,12 +309,20 @@ fun invalidResponseResultsInErrorReturned() = runBlockingTest {
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 it's 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
@ -368,13 +383,13 @@ Next the action:
val actual = sut.contents.take(2).toList()
```
Now just the verifications
Now just the verifications:
```kotlin
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```
@ -398,7 +413,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 +428,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,19 +436,18 @@ 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
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.
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:
@ -453,24 +467,24 @@ 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.
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.
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,34 +514,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 that 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 then again another loading and result after a call to `fetch`, so the setup would be:
```kotlin
val exception = RuntimeException()
val expected = listOf(
@ -542,20 +531,62 @@ 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 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
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())
```
Now we can test even complicated interactions between methods and classes with TestCoroutineDispatcher.
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 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
val actual = async { sut.contents.take(4).toList() }
advanceUntilIdle()
sut.fetch()
```
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()) {
... // setup here
val actual = async { sut.contents.take(4).toList() }
sut.fetch()
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.
### 7. `noAdditionalItemsEmitted`
@ -564,33 +595,76 @@ 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.
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<List<Content>>(UnexpectedException(exception))
)
var first = true
whenever(mockContentRemoteSource.get()).doAnswer {
if (first) emptyList<Content>().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<Content>().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()
}
```
###### 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.
### 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 in `whenFetchingRequestIsCalledAgain` 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())
}
```
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()
expected.forEach { expectedItem ->
Assertions.assertEquals(expectedItem, awaitItem())
}
Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty())
}
```
@ -601,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

View file

@ -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"
}

View file

@ -1,7 +1,8 @@
package org.fnives.test.showcase.core.content
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.assertThrows
@ -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
@ -36,7 +38,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 +47,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()
)

View file

@ -1,12 +1,14 @@
package org.fnives.test.showcase.core.content
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@Disabled("CodeKata")
@OptIn(ExperimentalCoroutinesApi::class)
class CodeKataContentRepositoryTest {
@BeforeEach
@ -20,31 +22,36 @@ 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")
@Test
fun noAdditionalItemsEmitted() {
fun noAdditionalItemsEmitted() = runTest {
}
@DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted")
@Test
fun noAdditionalItemsEmittedWithTurbine() = runTest {
}
}

View file

@ -1,11 +1,12 @@
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
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.runBlockingTest
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
import org.fnives.test.showcase.model.content.ContentId
@ -27,16 +28,14 @@ import org.mockito.kotlin.verifyNoMoreInteractions
import org.mockito.kotlin.whenever
@Suppress("TestFunctionName")
@OptIn(ExperimentalCoroutinesApi::class)
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 +48,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 +64,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 +79,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 +93,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<List<Content>>()
val suspendedRequest = CompletableDeferred<Unit>()
whenever(mockContentRemoteSource.get()).doSuspendableAnswer {
@ -117,52 +109,44 @@ 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<List<Content>>(UnexpectedException(exception))
)
var first = true
whenever(mockContentRemoteSource.get()).doAnswer {
if (first) emptyList<Content>().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() {
val exception = RuntimeException()
val expected = listOf(
Resource.Loading(),
Resource.Success(emptyList()),
Resource.Loading(),
Resource.Error<List<Content>>(UnexpectedException(exception))
)
var first = true
whenever(mockContentRemoteSource.get()).doAnswer {
if (first) emptyList<Content>().also { first = false } else throw exception
}
val actual = async {
sut.contents.take(4).toList()
}
advanceUntilIdle()
sut.fetch()
advanceUntilIdle()
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) {
runBlockingTest(testDispatcher) {
val exception = RuntimeException()
val expected = listOf(
Resource.Loading(),
Resource.Success(emptyList()),
Resource.Loading(),
Resource.Error<List<Content>>(UnexpectedException(exception))
)
var first = true
whenever(mockContentRemoteSource.get()).doAnswer {
if (first) emptyList<Content>().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())
}
fun noAdditionalItemsEmitted() = runTest {
val exception = RuntimeException()
var first = true
whenever(mockContentRemoteSource.get()).doAnswer {
if (first) emptyList<Content>().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()
}
}

View file

@ -1,7 +1,8 @@
package org.fnives.test.showcase.core.content
import kotlinx.coroutines.ExperimentalCoroutinesApi
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
@ -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
@ -34,7 +36,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 +45,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) {

View file

@ -5,8 +5,8 @@ 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.advanceUntilIdle
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
@ -30,11 +30,9 @@ internal class GetAllContentUseCaseTest {
private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage
private lateinit var contentFlow: MutableStateFlow<Resource<List<Content>>>
private lateinit var favouriteContentIdFlow: MutableStateFlow<List<ContentId>>
private lateinit var testDispatcher: TestCoroutineDispatcher
@BeforeEach
fun setUp() {
testDispatcher = TestCoroutineDispatcher()
mockFavouriteContentLocalStorage = mock()
mockContentRepository = mock()
favouriteContentIdFlow = MutableStateFlow(emptyList())
@ -48,187 +46,177 @@ 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<List<FavouriteContent>>()
fun loadingResourceWithNoFavouritesResultsInLoadingResource() = runTest {
favouriteContentIdFlow.value = emptyList()
contentFlow.value = Resource.Loading()
val expected = Resource.Loading<List<FavouriteContent>>()
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<List<FavouriteContent>>()
fun loadingResourceWithFavouritesResultsInLoadingResource() = runTest {
favouriteContentIdFlow.value = listOf(ContentId("a"))
contentFlow.value = Resource.Loading()
val expected = Resource.Loading<List<FavouriteContent>>()
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<List<FavouriteContent>>(exception)
fun errorResourceWithNoFavouritesResultsInErrorResource() = runTest {
favouriteContentIdFlow.value = emptyList()
val exception = Throwable()
contentFlow.value = Resource.Error(exception)
val expected = Resource.Error<List<FavouriteContent>>(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<List<FavouriteContent>>(exception)
fun errorResourceWithFavouritesResultsInErrorResource() = runTest {
favouriteContentIdFlow.value = listOf(ContentId("b"))
val exception = Throwable()
contentFlow.value = Resource.Error(exception)
val expected = Resource.Error<List<FavouriteContent>>(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)))
)
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)))
)
val actual = async(testDispatcher) {
sut.get().take(3).toList()
}
testDispatcher.advanceUntilIdle()
contentFlow.value = Resource.Success(listOf(content))
testDispatcher.advanceUntilIdle()
favouriteContentIdFlow.value = listOf(ContentId("a"))
testDispatcher.advanceUntilIdle()
Assertions.assertEquals(expected, actual.await())
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")
@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)))
)
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)))
)
val actual = async(testDispatcher) {
sut.get().take(3).toList()
}
testDispatcher.advanceUntilIdle()
contentFlow.value = Resource.Success(listOf(content))
testDispatcher.advanceUntilIdle()
favouriteContentIdFlow.value = emptyList()
testDispatcher.advanceUntilIdle()
Assertions.assertEquals(expected, actual.await())
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")
@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()
)
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()
)
val actual = async(testDispatcher) {
sut.get().take(3).toList()
}
testDispatcher.advanceUntilIdle()
contentFlow.value = Resource.Success(listOf(content))
testDispatcher.advanceUntilIdle()
contentFlow.value = Resource.Loading()
testDispatcher.advanceUntilIdle()
Assertions.assertEquals(expected, actual.await())
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())
}
}

View file

@ -1,7 +1,8 @@
package org.fnives.test.showcase.core.content
import kotlinx.coroutines.ExperimentalCoroutinesApi
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
@ -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
@ -36,7 +38,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 +47,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) {

View file

@ -0,0 +1,167 @@
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
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
@OptIn(ExperimentalCoroutinesApi::class)
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<List<Content>>(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<List<Content>>())
val suspendedRequest = CompletableDeferred<Unit>()
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<List<Content>>(UnexpectedException(exception))
)
var first = true
whenever(mockContentRemoteSource.get()).doAnswer {
if (first) emptyList<Content>().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<List<Content>>(UnexpectedException(exception))
)
var first = true
whenever(mockContentRemoteSource.get()).doAnswer {
if (first) emptyList<Content>().also { first = false } else throw exception
}
sut.contents.test {
sut.fetch()
expected.forEach { expectedItem ->
Assertions.assertEquals(expectedItem, awaitItem())
}
Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty())
}
}
}

View file

@ -0,0 +1,230 @@
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
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
@OptIn(ExperimentalCoroutinesApi::class)
class TurbineGetAllContentUseCaseTest {
private lateinit var sut: GetAllContentUseCase
private lateinit var mockContentRepository: ContentRepository
private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage
private lateinit var contentFlow: MutableStateFlow<Resource<List<Content>>>
private lateinit var favouriteContentIdFlow: MutableStateFlow<List<ContentId>>
@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<List<FavouriteContent>>())
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<List<FavouriteContent>>())
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<List<FavouriteContent>>(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<List<FavouriteContent>>(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())
}
}
}

View file

@ -1,6 +1,7 @@
package org.fnives.test.showcase.core.login
import kotlinx.coroutines.test.runBlockingTest
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
import org.fnives.test.showcase.model.auth.LoginCredentials
@ -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
@ -38,7 +40,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 +52,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 +64,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 +77,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 +91,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<LoginStatus>(UnexpectedException(exception))
whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b")))

View file

@ -1,6 +1,7 @@
package org.fnives.test.showcase.core.login.hilt
import kotlinx.coroutines.test.runBlockingTest
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
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
@ -16,12 +17,14 @@ import org.mockito.kotlin.verifyZeroInteractions
import javax.inject.Inject
@Suppress("TestFunctionName")
@OptIn(ExperimentalCoroutinesApi::class)
internal class LogoutUseCaseTest {
@Inject
lateinit var sut: LogoutUseCase
private lateinit var mockUserDataLocalStorage: UserDataLocalStorage
private lateinit var testCoreComponent: TestCoreComponent
@Inject
lateinit var contentRepository: ContentRepository
@ -45,7 +48,7 @@ internal class LogoutUseCaseTest {
@DisplayName("WHEN logout invoked THEN storage is cleared")
@Test
fun logoutResultsInStorageCleaning() = runBlockingTest {
fun logoutResultsInStorageCleaning() = runTest {
val repositoryBefore = contentRepository
sut.invoke()

View file

@ -1,6 +1,7 @@
package org.fnives.test.showcase.core.login.koin
import kotlinx.coroutines.test.runBlockingTest
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
import org.fnives.test.showcase.core.login.LogoutUseCase
@ -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
@ -56,7 +58,7 @@ internal class LogoutUseCaseTest : KoinTest {
@DisplayName("WHEN logout invoked THEN storage is cleared")
@Test
fun logoutResultsInStorageCleaning() = runBlockingTest {
fun logoutResultsInStorageCleaning() = runTest {
val repositoryBefore = getKoin().get<ContentRepository>()
sut.invoke()

View file

@ -1,7 +1,9 @@
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
import org.fnives.test.showcase.model.shared.Resource
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
@ -11,11 +13,12 @@ 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")
@Test
fun networkExceptionThrownResultsInError() = runBlocking {
fun networkExceptionThrownResultsInError() = runTest {
val exception = NetworkException(Throwable())
val expected = Answer.Error<Unit>(exception)
@ -26,7 +29,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<Unit>(exception)
@ -37,7 +40,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<Unit>(UnexpectedException(exception))
@ -48,7 +51,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" }

View file

@ -1,14 +1,15 @@
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.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"