Merge pull request #37 from fknives/update-core-tests
Update core tests
This commit is contained in:
commit
959f79997c
29 changed files with 1059 additions and 533 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")) }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue