From ad2a8fee805031e390293d5dd8ffef7280b878aa Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Sat, 18 Sep 2021 11:51:02 +0300 Subject: [PATCH] core description --- .../showcase/testutils/TestMainDispatcher.kt | 1 + .../showcase/ui/auth/AuthViewModelTest.kt | 19 +- .../showcase/ui/auth/CodeKataAuthViewModel.kt | 48 +++++ .../ui/splash/CodeKataSplashViewModelTest.kt | 39 ++++ .../showcase/ui/splash/SplashViewModelTest.kt | 11 +- codekata/core.instructionset | 14 +- codekata/networking.instructionset | 2 +- codekata/viewmodel.instructionset | 203 +++++++++++++++++- 8 files changed, 318 insertions(+), 19 deletions(-) create mode 100644 app/src/test/java/org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt create mode 100644 app/src/test/java/org/fnives/test/showcase/ui/splash/CodeKataSplashViewModelTest.kt diff --git a/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt b/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt index 3915734..c00ef75 100644 --- a/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt +++ b/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt @@ -14,6 +14,7 @@ class TestMainDispatcher : BeforeEachCallback, AfterEachCallback { override fun beforeEach(context: ExtensionContext?) { val testDispatcher = TestCoroutineDispatcher() privateTestDispatcher = testDispatcher + testDispatcher.pauseDispatcher() DatabaseInitialization.dispatcher = testDispatcher Dispatchers.setMain(testDispatcher) } diff --git a/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt index 242a8e7..263a181 100644 --- a/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt +++ b/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt @@ -10,6 +10,7 @@ import org.fnives.test.showcase.testutils.InstantExecutorExtension import org.fnives.test.showcase.testutils.TestMainDispatcher import org.fnives.test.showcase.ui.shared.Event import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest @@ -35,13 +36,14 @@ internal class AuthViewModelTest { @BeforeEach fun setUp() { mockLoginUseCase = mock() - testDispatcher.pauseDispatcher() sut = AuthViewModel(mockLoginUseCase) } + @DisplayName("GIVEN_initialized_viewModel_WHEN_observed_THEN_loading_false_other_fields_are_empty") @Test - fun GIVEN_initialized_viewModel_WHEN_observed_THEN_loading_false_other_fields_are_empty() { + fun initialSetup() { testDispatcher.resumeDispatcher() + sut.username.test().assertNoValue() sut.password.test().assertNoValue() sut.loading.test().assertValue(false) @@ -49,8 +51,9 @@ internal class AuthViewModelTest { sut.navigateToHome.test().assertNoValue() } + @DisplayName("GIVEN_password_text_WHEN_onPasswordChanged_is_called_THEN_password_livedata_is_updated") @Test - fun GIVEN_password_text_WHEN_onPasswordChanged_is_called_THEN_password_livedata_is_updated() { + fun whenPasswordChangedLiveDataIsUpdated() { testDispatcher.resumeDispatcher() val passwordTestObserver = sut.password.test() @@ -64,8 +67,9 @@ internal class AuthViewModelTest { sut.navigateToHome.test().assertNoValue() } + @DisplayName("GIVEN_username_text_WHEN_onUsernameChanged_is_called_THEN_username_livedata_is_updated") @Test - fun GIVEN_username_text_WHEN_onUsernameChanged_is_called_THEN_username_livedata_is_updated() { + fun whenUsernameChangedLiveDataIsUpdated() { testDispatcher.resumeDispatcher() val usernameTestObserver = sut.username.test() @@ -79,8 +83,9 @@ internal class AuthViewModelTest { sut.navigateToHome.test().assertNoValue() } + @DisplayName("GIVEN_no_password_or_username_WHEN_login_is_Called_THEN_empty_credentials_are_used_in_usecase") @Test - fun GIVEN_no_password_or_username_WHEN_login_is_Called_THEN_empty_credentials_are_used_in_usecase() { + fun noPasswordUsesEmptyStringInLoginUseCase() { val loadingTestObserver = sut.loading.test() runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) @@ -95,7 +100,7 @@ internal class AuthViewModelTest { } @Test - fun WHEN_login_is_Called_twise_THEN_use_case_is_only_called_once() { + fun WHEN_login_is_Called_twice_THEN_use_case_is_only_called_once() { runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) } sut.onLogin() @@ -107,7 +112,7 @@ internal class AuthViewModelTest { } @Test - fun GIVEN_password_and_username_WHEN_login_is_Called_THEN_empty_credentials_are_used_in_usecase() { + fun GIVEN_password_and_username_WHEN_login_is_Called_THEN_not_empty_credentials_are_used_in_usecase() { runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) } diff --git a/app/src/test/java/org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt b/app/src/test/java/org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt new file mode 100644 index 0000000..cff5a46 --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt @@ -0,0 +1,48 @@ +package org.fnives.test.showcase.ui.auth + +import org.fnives.test.showcase.core.login.LoginUseCase +import org.fnives.test.showcase.testutils.InstantExecutorExtension +import org.fnives.test.showcase.testutils.TestMainDispatcher +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.mock + +@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) +class CodeKataAuthViewModel { + + private lateinit var sut: AuthViewModel + private lateinit var mockLoginUseCase: LoginUseCase + private val testDispatcher get() = TestMainDispatcher.testDispatcher + + @BeforeEach + fun setUp() { + mockLoginUseCase = mock() + sut = AuthViewModel(mockLoginUseCase) + } + + @DisplayName("GIVEN_initialized_viewModel_WHEN_observed_THEN_loading_false_other_fields_are_empty") + @Test + fun initialSetup() { + + } + + @DisplayName("GIVEN_password_text_WHEN_onPasswordChanged_is_called_THEN_password_livedata_is_updated") + @Test + fun whenPasswordChangedLiveDataIsUpdated() { + + } + + @DisplayName("GIVEN_username_text_WHEN_onUsernameChanged_is_called_THEN_username_livedata_is_updated") + @Test + fun whenUsernameChangedLiveDataIsUpdated() { + + } + + @DisplayName("GIVEN_no_password_or_username_WHEN_login_is_Called_THEN_empty_credentials_are_used_in_usecase") + @Test + fun noPasswordUsesEmptyStringInLoginUseCase() { + + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/test/showcase/ui/splash/CodeKataSplashViewModelTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/splash/CodeKataSplashViewModelTest.kt new file mode 100644 index 0000000..26d39b2 --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/ui/splash/CodeKataSplashViewModelTest.kt @@ -0,0 +1,39 @@ +package org.fnives.test.showcase.ui.splash + +import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.testutils.InstantExecutorExtension +import org.fnives.test.showcase.testutils.TestMainDispatcher +import org.fnives.test.showcase.ui.shared.Event +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +internal class CodeKataSplashViewModelTest { + + @BeforeEach + fun setUp() { + + } + + @DisplayName("GIVEN not logged in user WHEN splash started THEN after half a second navigated to authentication") + @Test + fun loggedOutUserGoesToAuthentication() { + + } + + @DisplayName("GIVEN logged in user WHEN splash started THEN after half a second navigated to home") + @Test + fun loggedInUserGoestoHome() { + + } + + @DisplayName("GIVEN not logged in user WHEN splash started THEN before half a second no event is sent") + @Test + fun withoutEnoughTimeNoNavigationHappens() { + + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt index dfcb5e9..5ca371e 100644 --- a/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt +++ b/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt @@ -6,13 +6,13 @@ import org.fnives.test.showcase.testutils.InstantExecutorExtension import org.fnives.test.showcase.testutils.TestMainDispatcher import org.fnives.test.showcase.ui.shared.Event import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -@Suppress("TestFunctionName") @ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) internal class SplashViewModelTest { @@ -26,8 +26,9 @@ internal class SplashViewModelTest { sut = SplashViewModel(mockIsUserLoggedInUseCase) } + @DisplayName("GIVEN not logged in user WHEN splash started THEN after half a second navigated to authentication") @Test - fun GIVEN_not_logged_in_user_WHEN_splash_started_THEN_after_half_a_second_navigated_to_authentication() { + fun loggedOutUserGoesToAuthentication() { whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) testCoroutineDispatcher.advanceTimeBy(500) @@ -35,8 +36,9 @@ internal class SplashViewModelTest { sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.AUTHENTICATION)) } + @DisplayName("GIVEN logged in user WHEN splash started THEN after half a second navigated to home") @Test - fun GIVEN_logged_in_user_WHEN_splash_started_THEN_after_half_a_second_navigated_to_home() { + fun loggedInUserGoestoHome() { whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(true) testCoroutineDispatcher.advanceTimeBy(500) @@ -44,8 +46,9 @@ internal class SplashViewModelTest { sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.HOME)) } + @DisplayName("GIVEN not logged in user WHEN splash started THEN before half a second no event is sent") @Test - fun GIVEN_not_logged_in_user_WHEN_splash_started_THEN_before_half_a_second_no_event_is_sent() { + fun withoutEnoughTimeNoNavigationHappens() { whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) testCoroutineDispatcher.advanceTimeBy(100) diff --git a/codekata/core.instructionset b/codekata/core.instructionset index e3a59fe..1f505fc 100644 --- a/codekata/core.instructionset +++ b/codekata/core.instructionset @@ -1,4 +1,4 @@ -# Starting of testing +# 1. Starting of testing In this testing instruction set you will learn how to write simple tests using mockito. @@ -457,18 +457,20 @@ In this case we should recreate our sut in the test and feed it our own remote s #### Still using mockito -To mock such behaviour with mockito is not as straight forward as creating our own. -That's because mockito is not aware of the nature of suspend functions, like our code is in the custom mock. +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. 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 wherevere without needing to create Custom Mocks for every such case. +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. -Similarly our suspendable answer is named as such `doSuspendableAnswer` +Luckily this has already be done in "org.mockito.kotlin" and it's called `doSuspendableAnswer` -The point here is not exactly how the doSuspendableAnswer is created, just that we can get arguments while mocking with mockito, and also extend it in a way that helps us in common patterns. +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. #### Back to the actual test diff --git a/codekata/networking.instructionset b/codekata/networking.instructionset index 5dba5b8..9832007 100644 --- a/codekata/networking.instructionset +++ b/codekata/networking.instructionset @@ -1,4 +1,4 @@ -# Starting of networking testing +# 2. Starting of networking testing In this testing instruction set you will learn how to write simple tests with retrofit. diff --git a/codekata/viewmodel.instructionset b/codekata/viewmodel.instructionset index 30404ce..c52d5a1 100644 --- a/codekata/viewmodel.instructionset +++ b/codekata/viewmodel.instructionset @@ -1 +1,202 @@ -TODO \ No newline at end of file +# 3. Starting of ViewModel testing + +In this testing instruction set you will learn how to write simple tests for ViewModels. + +- We will use TestCoroutineDispatcher for time manipulation +- Learn how to use TestCorotineDispatcher in ViewModels +- And how to test LiveData +- how to use extensions +- how to parametrize a test + +## SplashViewModel test + +Our system under test will be org.fnives.test.showcase.ui.splash.SplashViewModel + +What it does is: +- waits 500 milliseconds +- checks if the user logged in +- sends navigated event based on the check + +### Setup + +So let's start with the setup. +To properly test LiveData we need to make them instant. To Do this we can use a InstantExecutorExtension. + +Also to set MainDispatcher as TestCoroutineDispatcher, we can use the TestMainDispatcher Extension. + +To add this to our TestClass we need to do the following: + +```kotlin +@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) +class CodeKataSplashViewModelTest { +``` + +Note you can use @RegisterExtension to register an extension as a field and make it easier to reference. + +Next let's setup or system under test: + +```kotlin +private lateinit var mockIsUserLoggedInUseCase: IsUserLoggedInUseCase +private lateinit var sut: SplashViewModel +private val testCoroutineDispatcher get() = TestMainDispatcher.testDispatcher // just a shortcut + +@BeforeEach +fun setUp() { + mockIsUserLoggedInUseCase = mock() + sut = SplashViewModel(mockIsUserLoggedInUseCase) +} +``` + +### 1. `loggedOutUserGoesToAuthentication` + +So let's setup our mock + +```kotlin +whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) +``` + +Since the action takes place in the ViewModel constructor, instead of additional calls, we need to simulate that time has elapsed. + +Note: the Extension we are using is pausing the dispatcher, that's why we the test is linear and not shaky. + +```kotlin +testCoroutineDispatcher.advanceTimeBy(500) +``` + +Next verify that we navigated to Authentication + +```kotlin +sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.AUTHENTICATION)) +``` + +Here test() is a LiveData extension. It helps to verify value history, current value and observers the value properly. + +If a livedata is not observed, it's value may not update (like a livedata that maps) so it's important to have a proper TestObserver set. + +### 2. `loggedInUserGoestoHome` + +This is really similar to `loggedOutUserGoesToAuthentication`, so here is the complete code: + +```kotlin +whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(true) + +testCoroutineDispatcher.advanceTimeBy(500) + +sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.HOME)) +``` + +### 3. `withoutEnoughTimeNoNavigationHappens` + +Not let's verify that if the time didn't elapse then the event is not sent out: + +```kotlin +whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) + +testCoroutineDispatcher.advanceTimeBy(100) // we wait only 100ms not 500ms + +sut.navigateTo.test().assertNoValue() // this is the way to test that no value has been sent out +``` + +## AuthViewModelTest Test + +Our system under test will be org.fnives.test.showcase.ui.auth.AuthViewModel + +What it does is: +- observes input username and password +- tries to login with the given data +- processes the response and either navigates or shows an error + +Let's open CodeKataAuthViewModel. + +The setup is already done because it's almost the same as menitoned above. + +### 1. `initialSetup` + +Let's start with the basics. So first let's verify when the viewModel is setup all LiveData contain the correct data. + +First we resume the dispatcher, then verify the livedata. + +```kotlin +testDispatcher.resumeDispatcher() + +sut.username.test().assertNoValue() +sut.password.test().assertNoValue() +sut.loading.test().assertValue(false) +sut.error.test().assertNoValue() +sut.navigateToHome.test().assertNoValue() +``` + +### 2. `whenPasswordChangedLiveDataIsUpdated` + +Here we need to test the livedata updates as we change the password. + +So first let's add a subscribed to the ViewModel which we plan to verify: + +```kotlin +testDispatcher.resumeDispatcher() +val passwordTestObserver = sut.password.test() +``` + +Next we do the action and update the password: + +```kotlin +sut.onPasswordChanged("a") +sut.onPasswordChanged("al") +``` + +And at the end we verify the passwordTestObserver was updated: + +```kotlin +passwordTestObserver.assertValueHistory("a", "al") +sut.username.test().assertNoValue() +sut.loading.test().assertValue(false) +sut.error.test().assertNoValue() +sut.navigateToHome.test().assertNoValue() +``` + +### 3. `whenUsernameChangedLiveDataIsUpdated` + +This is esentially the same as whenPasswordChangedLiveDataIsUpdated, just for the username: + +```kotlin +testDispatcher.resumeDispatcher() +val usernameTestObserver = sut.username.test() + +sut.onUsernameChanged("a") +sut.onUsernameChanged("al") + +usernameTestObserver.assertValueHistory("a", "al") +sut.password.test().assertNoValue() +sut.loading.test().assertValue(false) +sut.error.test().assertNoValue() +sut.navigateToHome.test().assertNoValue() +``` + +### 4. `noPasswordUsesEmptyStringInLoginUseCase` + +Now let's test some actual logic: +If we didn't give username and password to the ViewModel when login is clicked we should see loading, empty string passed to the UseCase + +Let's setup to login + +```kotlin +val loadingTestObserver = sut.loading.test() +runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) +} +``` + +Let's do the action + +```kotlin +sut.onLogin() +testDispatcher.advanceUntilIdle() // ensure the coroutine has run +``` + +verify the loading and the useCase call + +```kotlin +loadingTestObserver.assertValueHistory(false, true, false) +runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("", "")) } +verifyNoMoreInteractions(mockLoginUseCase) +``` \ No newline at end of file