From d3395923903c71c447103aa44baeb8dc1abc34d1 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Tue, 25 Jan 2022 20:37:35 +0200 Subject: [PATCH] Issue#12 Finish ViewModel instruction set --- README.md | 2 +- .../showcase/testutils/TestMainDispatcher.kt | 2 +- .../showcase/ui/auth/AuthViewModelTest.kt | 85 ++-- .../showcase/ui/auth/CodeKataAuthViewModel.kt | 29 +- .../ui/splash/CodeKataSplashViewModelTest.kt | 4 +- codekata/viewmodel.instructionset | 202 --------- codekata/viewmodel.instructionset.md | 391 ++++++++++++++++++ 7 files changed, 472 insertions(+), 243 deletions(-) delete mode 100644 codekata/viewmodel.instructionset create mode 100644 codekata/viewmodel.instructionset.md diff --git a/README.md b/README.md index 8bac2ea..c04476c 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ The networking instruction set will show you how to test network request with mo It will also show you that you can write tests not only for one class mocking all the dependencies, but a component. #### App ViewModel Unit Tests -Open the [app viewModel unit tests instruction set](./codekata/viewmodel.instructionset). +Open the [app viewModel unit tests instruction set](./codekata/viewmodel.instructionset.md). This section we will see how to replace the dispatcher to testDispatcher to control the ViewModel's coroutines. 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 09f8e8c..3f1a454 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,7 +14,7 @@ import org.junit.jupiter.api.extension.BeforeEachCallback import org.junit.jupiter.api.extension.ExtensionContext /** - * Custom Junit5 Extension which replaces the [DatabaseInitialization]'s dispatcher and main dispatcher with a [TestCoroutineDispatcher] + * Custom Junit5 Extension which replaces the [DatabaseInitialization]'s dispatcher and main dispatcher with a [TestDispatcher] * * One can access the test dispatcher via [testDispatcher] static getter. */ 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 4af66dd..8d9a4af 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 @@ -44,45 +44,59 @@ internal class AuthViewModelTest { @DisplayName("GIVEN initialized viewModel WHEN observed THEN loading false other fields are empty") @Test fun initialSetup() { + val usernameTestObserver = sut.username.test() + val passwordTestObserver = sut.password.test() + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + testScheduler.advanceUntilIdle() - sut.username.test().assertNoValue() - sut.password.test().assertNoValue() - sut.loading.test().assertValue(false) - sut.error.test().assertNoValue() - sut.navigateToHome.test().assertNoValue() + usernameTestObserver.assertNoValue() + passwordTestObserver.assertNoValue() + loadingTestObserver.assertValue(false) + errorTestObserver.assertNoValue() + navigateToHomeTestObserver.assertNoValue() } @DisplayName("GIVEN password text WHEN onPasswordChanged is called THEN password livedata is updated") @Test fun whenPasswordChangedLiveDataIsUpdated() { + val usernameTestObserver = sut.username.test() val passwordTestObserver = sut.password.test() + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() sut.onPasswordChanged("a") sut.onPasswordChanged("al") testScheduler.advanceUntilIdle() + usernameTestObserver.assertNoValue() passwordTestObserver.assertValueHistory("a", "al") - sut.username.test().assertNoValue() - sut.loading.test().assertValue(false) - sut.error.test().assertNoValue() - sut.navigateToHome.test().assertNoValue() + loadingTestObserver.assertValue(false) + errorTestObserver.assertNoValue() + navigateToHomeTestObserver.assertNoValue() } @DisplayName("GIVEN username text WHEN onUsernameChanged is called THEN username livedata is updated") @Test fun whenUsernameChangedLiveDataIsUpdated() { val usernameTestObserver = sut.username.test() + val passwordTestObserver = sut.password.test() + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() - sut.onUsernameChanged("a") - sut.onUsernameChanged("al") + sut.onUsernameChanged("bla") + sut.onUsernameChanged("blabla") testScheduler.advanceUntilIdle() - usernameTestObserver.assertValueHistory("a", "al") - sut.password.test().assertNoValue() - sut.loading.test().assertValue(false) - sut.error.test().assertNoValue() - sut.navigateToHome.test().assertNoValue() + usernameTestObserver.assertValueHistory("bla", "blabla") + passwordTestObserver.assertNoValue() + loadingTestObserver.assertValue(false) + errorTestObserver.assertNoValue() + navigateToHomeTestObserver.assertNoValue() } @DisplayName("GIVEN no password or username WHEN login is Called THEN empty credentials are used in usecase") @@ -122,6 +136,7 @@ internal class AuthViewModelTest { } sut.onPasswordChanged("pass") sut.onUsernameChanged("usr") + testScheduler.advanceUntilIdle() sut.onLogin() testScheduler.advanceUntilIdle() @@ -134,20 +149,20 @@ internal class AuthViewModelTest { @DisplayName("GIVEN AnswerError WHEN login called THEN error is shown") @Test - fun loginErrorResultsInErrorState() { + fun loginUnexpectedErrorResultsInErrorState() { runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) } - val loadingObserver = sut.loading.test() - val errorObserver = sut.error.test() - val navigateToHomeObserver = sut.navigateToHome.test() + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() sut.onLogin() testScheduler.advanceUntilIdle() - loadingObserver.assertValueHistory(false, true, false) - errorObserver.assertValueHistory(Event(AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR)) - navigateToHomeObserver.assertNoValue() + loadingTestObserver.assertValueHistory(false, true, false) + errorTestObserver.assertValueHistory(Event(AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR)) + navigateToHomeTestObserver.assertNoValue() } @MethodSource("loginErrorStatusesArguments") @@ -159,16 +174,16 @@ internal class AuthViewModelTest { runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(loginStatus)) } - val loadingObserver = sut.loading.test() - val errorObserver = sut.error.test() - val navigateToHomeObserver = sut.navigateToHome.test() + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() sut.onLogin() testScheduler.advanceUntilIdle() - loadingObserver.assertValueHistory(false, true, false) - errorObserver.assertValueHistory(Event(errorType)) - navigateToHomeObserver.assertNoValue() + loadingTestObserver.assertValueHistory(false, true, false) + errorTestObserver.assertValueHistory(Event(errorType)) + navigateToHomeTestObserver.assertNoValue() } @DisplayName("GIVEN answer success and login status success WHEN login called THEN navigation event is sent") @@ -177,16 +192,16 @@ internal class AuthViewModelTest { runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(LoginStatus.SUCCESS)) } - val loadingObserver = sut.loading.test() - val errorObserver = sut.error.test() - val navigateToHomeObserver = sut.navigateToHome.test() + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() sut.onLogin() testScheduler.advanceUntilIdle() - loadingObserver.assertValueHistory(false, true, false) - errorObserver.assertNoValue() - navigateToHomeObserver.assertValueHistory(Event(Unit)) + loadingTestObserver.assertValueHistory(false, true, false) + errorTestObserver.assertNoValue() + navigateToHomeTestObserver.assertValueHistory(Event(Unit)) } companion object { diff --git a/app/src/test/java/org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt b/app/src/test/java/org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt index 74d65d2..7a70ed0 100644 --- a/app/src/test/java/org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt +++ b/app/src/test/java/org/fnives/test/showcase/ui/auth/CodeKataAuthViewModel.kt @@ -5,20 +5,18 @@ 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.Disabled import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.kotlin.mock -@Disabled("CodeKata") @ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) @OptIn(ExperimentalCoroutinesApi::class) class CodeKataAuthViewModel { 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() { @@ -45,4 +43,29 @@ class CodeKataAuthViewModel { @Test fun noPasswordUsesEmptyStringInLoginUseCase() { } + + @DisplayName("WHEN login is called twice before finishing THEN use case is only called once") + @Test + fun onlyOneLoginIsSentOutWhenClickingRepeatedly() { + } + + @DisplayName("GIVEN password and username WHEN login is called THEN proper credentials are used in usecase") + @Test + fun argumentsArePassedProperlyToLoginUseCase() { + } + + @DisplayName("GIVEN AnswerError WHEN login called THEN error is shown") + @Test + fun loginUnexpectedErrorResultsInErrorState() { + } + + @DisplayName("GIVEN answer success loginStatus INVALID_CREDENTIALS WHEN login called THEN error INVALID_CREDENTIALS is shown") + @Test + fun invalidStatusResultsInErrorState() { + } + + @DisplayName("GIVEN answer success and login status success WHEN login called THEN navigation event is sent") + @Test + fun successLoginResultsInNavigation() { + } } 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 index 475bb22..5cea5f9 100644 --- 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 @@ -1,11 +1,13 @@ package org.fnives.test.showcase.ui.splash +import kotlinx.coroutines.ExperimentalCoroutinesApi 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) internal class CodeKataSplashViewModelTest { @BeforeEach @@ -19,7 +21,7 @@ internal class CodeKataSplashViewModelTest { @DisplayName("GIVEN logged in user WHEN splash started THEN after half a second navigated to home") @Test - fun loggedInUserGoestoHome() { + fun loggedInUserGoesToHome() { } @DisplayName("GIVEN not logged in user WHEN splash started THEN before half a second no event is sent") diff --git a/codekata/viewmodel.instructionset b/codekata/viewmodel.instructionset deleted file mode 100644 index c52d5a1..0000000 --- a/codekata/viewmodel.instructionset +++ /dev/null @@ -1,202 +0,0 @@ -# 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 diff --git a/codekata/viewmodel.instructionset.md b/codekata/viewmodel.instructionset.md new file mode 100644 index 0000000..3b98dbf --- /dev/null +++ b/codekata/viewmodel.instructionset.md @@ -0,0 +1,391 @@ +# 3. Starting of ViewModel testing + +In this testing instruction set you will learn how to write simple tests for ViewModels. + +- We will use TestDispatcher for time manipulation +- Learn how to use TestDispatcher in ViewModels +- 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. + +Our test class is `org.fnives.test.showcase.ui.splash.CodeKataSplashViewModelTest` + +To properly test LiveData we need to make them instant, meaning as soon as the value is set the observers are updated. To Do this we can use a `InstantExecutorExtension`. + +Also We need to set MainDispatcher as TestDispatcher, for this 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 as usual: + +```kotlin +private lateinit var mockIsUserLoggedInUseCase: IsUserLoggedInUseCase +private lateinit var sut: SplashViewModel +private val testScheduler get() = TestMainDispatcher.testDispatcher.scheduler // just a shortcut + +@BeforeEach +fun setUp() { + mockIsUserLoggedInUseCase = mock() // the only dependency of the ViewModel + sut = SplashViewModel(mockIsUserLoggedInUseCase) +} +``` + +### 1. `loggedOutUserGoesToAuthentication` + +We want to thest that if the user is not logged in then we are navigated to the Authentication screen. +So we need to setup the mock's response: + +```kotlin +whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) +``` + +Next up we want to setup our TestObserver for LiveData. This enables us to verify the values sent into a LiveData. +If a livedata is not observed, it's value may not updated (like a livedata that maps) so it's important to have a proper TestObserver set. + + +```kotin +val navigateToTestObserver = sut.navigateTo.test() +``` + +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 StandardTestDispatcher, that's why our test is linear and not shaky. + +```kotlin +testScheduler.advanceTimeBy(501) +``` + +Next, we verify that we navigated to Authentication and only to Authentication: + +```kotlin +navigateToTestObserver.assertValueHistory(Event(SplashViewModel.NavigateTo.AUTHENTICATION)) +``` + +### 2. `loggedInUserGoestoHome` + +This is really similar to `loggedOutUserGoesToAuthentication`, so try to implement on your own. + +However for completness, here is the code: + +```kotlin +whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(true) +val navigateToTestObserver = sut.navigateTo.test() + +testScheduler.advanceTimeBy(501) + +navigateToTestObserver.assertValueHistory(Event(SplashViewModel.NavigateTo.HOME)) +``` + +### 3. `withoutEnoughTimeNoNavigationHappens` + +Now let's verify that if the time didn't elapse then the event is not sent out. +The setup is the same, expect less time: + +```kotlin +whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) +val navigateToTestObserver = sut.navigateTo.test() + +testScheduler.advanceTimeBy(100) // we wait only 100ms not 500ms +``` + +And the as verification we just check that no values were submitted. + +```kotlin +navigateToTestObserver.assertNoValue() // this is the way to test that no value has been sent out +``` + +With this we completed the SplashViewModel test. It is really simple, but it introduced extensions scheduling and LiveData testing. + +## 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 `org.fnives.test.showcase.ui.auth.CodeKataAuthViewModel`. + +The setup is already done because it's almost the same as menitoned in CodeKataSplashViewModelTest. + +### 1. `initialSetup` + +As always we start with the easiest test. This usually gives us motivitaion and helps us giving idea for the next tests. + +First we setup the observers: +```kotlin +val usernameTestObserver = sut.username.test() +val passwordTestObserver = sut.password.test() +val loadingTestObserver = sut.loading.test() +val errorTestObserver = sut.error.test() +val navigateToHomeTestObserver = sut.navigateToHome.test() +``` + +Next we advance the scheduler until everything is idle: +```kotlin +testScheduler.advanceUntilIdle() +``` + +And now, we verify the values: +```kotlin +usernameTestObserver.assertNoValue() +passwordTestObserver.assertNoValue() +loadingTestObserver.assertValue(false) +errorTestObserver.assertNoValue() +navigateToHomeTestObserver.assertNoValue() +``` + +### 2. `whenPasswordChangedLiveDataIsUpdated` + +Here we need to test the LiveData updates as we change the password. + +So first let's add a subscriber to the ViewModel which we plan to verify: + +```kotlin +val usernameTestObserver = sut.username.test() +val passwordTestObserver = sut.password.test() +val loadingTestObserver = sut.loading.test() +val errorTestObserver = sut.error.test() +val navigateToHomeTestObserver = sut.navigateToHome.test() +``` + +Next we do the action and update the password and advance the scheduler: + +```kotlin +sut.onPasswordChanged("a") +sut.onPasswordChanged("al") +``` + +And at the end we verify the passwordTestObserver was updated and the others weren't: + +```kotlin +usernameTestObserver.assertNoValue() +passwordTestObserver.assertValueHistory("a", "al") +loadingTestObserver.assertValue(false) +errorTestObserver.assertNoValue() +navigateToHomeTestObserver.assertNoValue() +``` + +### 3. `whenUsernameChangedLiveDataIsUpdated` + +This is esentially the same as whenPasswordChangedLiveDataIsUpdated, just for the username, so try to do it on your own. +However for completeness sake: + +```kotlin +val usernameTestObserver = sut.username.test() +val passwordTestObserver = sut.password.test() +val loadingTestObserver = sut.loading.test() +val errorTestObserver = sut.error.test() +val navigateToHomeTestObserver = sut.navigateToHome.test() + +sut.onUsernameChanged("bla") +sut.onUsernameChanged("blabla") +testScheduler.advanceUntilIdle() + +usernameTestObserver.assertValueHistory("bla", "blabla") +passwordTestObserver.assertNoValue() +loadingTestObserver.assertValue(false) +errorTestObserver.assertNoValue() +navigateToHomeTestObserver.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, and 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())) +} +``` + +`anyOrNull()` just means we do not care what is passed, any anything is accepted. + +Let's do the action: + +```kotlin +sut.onLogin() +testScheduler.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) +``` +### 5. `onlyOneLoginIsSentOutWhenClickingRepeatedly` + +Clicking the button once works as expected. But what if the user clicks the button multiple times before the request finishes? Let's make sure we only do actual actions once in such case. + +We just setup the UseCase: +```kotlin +runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) } +``` + +Next we click the button multiple times then dispatch. +```kotlin +sut.onLogin() +sut.onLogin() +testScheduler.advanceUntilIdle() +``` + +And we verify the UseCase was called only once: +```kotlin +runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("", "")) } +verifyNoMoreInteractions(mockLoginUseCase) +``` + +### 6. `argumentsArePassedProperlyToLoginUseCase` + +Okay, now let's verify the UseCase receives the proper data. +We setup the UseCase response and update the username and password: +```kotlin +runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) +} +sut.onPasswordChanged("pass") +sut.onUsernameChanged("usr") +testScheduler.advanceUntilIdle() +``` + +Next we do our action and clikc the button: +```kotlin +sut.onLogin() +testScheduler.advanceUntilIdle() +``` + +Now, we just verify the UseCase is called properly: +```kotlin +runBlocking { + verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("usr", "pass")) +} +verifyNoMoreInteractions(mockLoginUseCase) +``` + +### 7. `invalidStatusResultsInErrorState` + +Time to test Errors. +First we setup our UseCase and the TestObservers: +```kotlin +runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(LoginStatus.INVALID_CREDENTIALS)) +} +val loadingTestObserver = sut.loading.test() +val errorTestObserver = sut.error.test() +val navigateToHomeTestObserver = sut.navigateToHome.test() +``` + +As usual, next comes the action: +```kotlin +sut.onLogin() +testScheduler.advanceUntilIdle() +``` + +And verify the LiveData values: +``` +loadingTestObserver.assertValueHistory(false, true, false) +errorTestObserver.assertValueHistory(Event(AuthViewModel.ErrorType.INVALID_CREDENTIALS)) +navigateToHomeTestObserver.assertNoValue() +``` + +Probably you are already getting bored of writing almost the same tests, and we need 2 more tests just like this only for different Error types. +So let's not writing the same test again, but parametrize this one. +First we need to annotate or test, that it should be parametrized: + +```kotlin +@MethodSource("loginErrorStatusesArguments") +@ParameterizedTest(name = "GIVEN answer success loginStatus {0} WHEN login called THEN error {1} is shown") +fun invalidStatusResultsInErrorState( + loginStatus: LoginStatus, + errorType: AuthViewModel.ErrorType +) +``` + +Define the parameters for our tests, the field should be static and notice the field name: +```kotlin +companion object { + + @JvmStatic + fun loginErrorStatusesArguments(): Stream = Stream.of( + Arguments.of(LoginStatus.INVALID_CREDENTIALS, AuthViewModel.ErrorType.INVALID_CREDENTIALS), + Arguments.of(LoginStatus.INVALID_PASSWORD, AuthViewModel.ErrorType.UNSUPPORTED_PASSWORD), + Arguments.of(LoginStatus.INVALID_USERNAME, AuthViewModel.ErrorType.UNSUPPORTED_USERNAME) + ) +} +``` + +And let's just adjust the test to use the parameters: +```kotlin +runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(loginStatus)) +} +//... +errorTestObserver.assertValueHistory(Event(errorType)) +``` + +And now if we run the test we see 3 different tests, with different names based on the parameters. +Great, this is how we can reduce duplication in tests, without losing readability. + +### 8. `successLoginResultsInNavigation` + +And finally let's test the happy flow as well. + +We setup the observers and the UseCase: +```kotlin +runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(LoginStatus.SUCCESS)) +} +val loadingTestObserver = sut.loading.test() +val errorTestObserver = sut.error.test() +val navigateToHomeTestObserver = sut.navigateToHome.test() +``` + +The action: +```kotlin +sut.onLogin() +testScheduler.advanceUntilIdle() +``` + +And finally the verification: +```kotlin +loadingTestObserver.assertValueHistory(false, true, false) +errorTestObserver.assertNoValue() +navigateToHomeTestObserver.assertValueHistory(Event(Unit)) +``` + +## Conclusion +That concludes or ViewModel tests. +As you can see it's not too different from the previous tests, we just needed to add a couple of additional setup and helper classes. +With this we are able to: +- Test ViewModels +- Test LiveData +- Use TestScheduler for ViewModels +- How to use Test Extensions +- How to parametrize tests to reduce duplication