Merge pull request #45 from fknives/issue#12-viewmode-tests

Issue#12 Finish ViewModel instruction set
This commit is contained in:
Gergely Hegedis 2022-01-25 21:25:37 +02:00 committed by GitHub
commit e2bf4b3bce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 472 additions and 243 deletions

View file

@ -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. 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 #### 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. This section we will see how to replace the dispatcher to testDispatcher to control the ViewModel's coroutines.

View file

@ -14,7 +14,7 @@ import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext 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. * One can access the test dispatcher via [testDispatcher] static getter.
*/ */

View file

@ -44,45 +44,59 @@ internal class AuthViewModelTest {
@DisplayName("GIVEN initialized viewModel WHEN observed THEN loading false other fields are empty") @DisplayName("GIVEN initialized viewModel WHEN observed THEN loading false other fields are empty")
@Test @Test
fun initialSetup() { 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() testScheduler.advanceUntilIdle()
sut.username.test().assertNoValue() usernameTestObserver.assertNoValue()
sut.password.test().assertNoValue() passwordTestObserver.assertNoValue()
sut.loading.test().assertValue(false) loadingTestObserver.assertValue(false)
sut.error.test().assertNoValue() errorTestObserver.assertNoValue()
sut.navigateToHome.test().assertNoValue() navigateToHomeTestObserver.assertNoValue()
} }
@DisplayName("GIVEN password text WHEN onPasswordChanged is called THEN password livedata is updated") @DisplayName("GIVEN password text WHEN onPasswordChanged is called THEN password livedata is updated")
@Test @Test
fun whenPasswordChangedLiveDataIsUpdated() { fun whenPasswordChangedLiveDataIsUpdated() {
val usernameTestObserver = sut.username.test()
val passwordTestObserver = sut.password.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("a")
sut.onPasswordChanged("al") sut.onPasswordChanged("al")
testScheduler.advanceUntilIdle() testScheduler.advanceUntilIdle()
usernameTestObserver.assertNoValue()
passwordTestObserver.assertValueHistory("a", "al") passwordTestObserver.assertValueHistory("a", "al")
sut.username.test().assertNoValue() loadingTestObserver.assertValue(false)
sut.loading.test().assertValue(false) errorTestObserver.assertNoValue()
sut.error.test().assertNoValue() navigateToHomeTestObserver.assertNoValue()
sut.navigateToHome.test().assertNoValue()
} }
@DisplayName("GIVEN username text WHEN onUsernameChanged is called THEN username livedata is updated") @DisplayName("GIVEN username text WHEN onUsernameChanged is called THEN username livedata is updated")
@Test @Test
fun whenUsernameChangedLiveDataIsUpdated() { fun whenUsernameChangedLiveDataIsUpdated() {
val usernameTestObserver = sut.username.test() 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("bla")
sut.onUsernameChanged("al") sut.onUsernameChanged("blabla")
testScheduler.advanceUntilIdle() testScheduler.advanceUntilIdle()
usernameTestObserver.assertValueHistory("a", "al") usernameTestObserver.assertValueHistory("bla", "blabla")
sut.password.test().assertNoValue() passwordTestObserver.assertNoValue()
sut.loading.test().assertValue(false) loadingTestObserver.assertValue(false)
sut.error.test().assertNoValue() errorTestObserver.assertNoValue()
sut.navigateToHome.test().assertNoValue() navigateToHomeTestObserver.assertNoValue()
} }
@DisplayName("GIVEN no password or username WHEN login is Called THEN empty credentials are used in usecase") @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.onPasswordChanged("pass")
sut.onUsernameChanged("usr") sut.onUsernameChanged("usr")
testScheduler.advanceUntilIdle()
sut.onLogin() sut.onLogin()
testScheduler.advanceUntilIdle() testScheduler.advanceUntilIdle()
@ -134,20 +149,20 @@ internal class AuthViewModelTest {
@DisplayName("GIVEN AnswerError WHEN login called THEN error is shown") @DisplayName("GIVEN AnswerError WHEN login called THEN error is shown")
@Test @Test
fun loginErrorResultsInErrorState() { fun loginUnexpectedErrorResultsInErrorState() {
runBlocking { runBlocking {
whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable()))
} }
val loadingObserver = sut.loading.test() val loadingTestObserver = sut.loading.test()
val errorObserver = sut.error.test() val errorTestObserver = sut.error.test()
val navigateToHomeObserver = sut.navigateToHome.test() val navigateToHomeTestObserver = sut.navigateToHome.test()
sut.onLogin() sut.onLogin()
testScheduler.advanceUntilIdle() testScheduler.advanceUntilIdle()
loadingObserver.assertValueHistory(false, true, false) loadingTestObserver.assertValueHistory(false, true, false)
errorObserver.assertValueHistory(Event(AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR)) errorTestObserver.assertValueHistory(Event(AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR))
navigateToHomeObserver.assertNoValue() navigateToHomeTestObserver.assertNoValue()
} }
@MethodSource("loginErrorStatusesArguments") @MethodSource("loginErrorStatusesArguments")
@ -159,16 +174,16 @@ internal class AuthViewModelTest {
runBlocking { runBlocking {
whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(loginStatus)) whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(loginStatus))
} }
val loadingObserver = sut.loading.test() val loadingTestObserver = sut.loading.test()
val errorObserver = sut.error.test() val errorTestObserver = sut.error.test()
val navigateToHomeObserver = sut.navigateToHome.test() val navigateToHomeTestObserver = sut.navigateToHome.test()
sut.onLogin() sut.onLogin()
testScheduler.advanceUntilIdle() testScheduler.advanceUntilIdle()
loadingObserver.assertValueHistory(false, true, false) loadingTestObserver.assertValueHistory(false, true, false)
errorObserver.assertValueHistory(Event(errorType)) errorTestObserver.assertValueHistory(Event(errorType))
navigateToHomeObserver.assertNoValue() navigateToHomeTestObserver.assertNoValue()
} }
@DisplayName("GIVEN answer success and login status success WHEN login called THEN navigation event is sent") @DisplayName("GIVEN answer success and login status success WHEN login called THEN navigation event is sent")
@ -177,16 +192,16 @@ internal class AuthViewModelTest {
runBlocking { runBlocking {
whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(LoginStatus.SUCCESS)) whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(LoginStatus.SUCCESS))
} }
val loadingObserver = sut.loading.test() val loadingTestObserver = sut.loading.test()
val errorObserver = sut.error.test() val errorTestObserver = sut.error.test()
val navigateToHomeObserver = sut.navigateToHome.test() val navigateToHomeTestObserver = sut.navigateToHome.test()
sut.onLogin() sut.onLogin()
testScheduler.advanceUntilIdle() testScheduler.advanceUntilIdle()
loadingObserver.assertValueHistory(false, true, false) loadingTestObserver.assertValueHistory(false, true, false)
errorObserver.assertNoValue() errorTestObserver.assertNoValue()
navigateToHomeObserver.assertValueHistory(Event(Unit)) navigateToHomeTestObserver.assertValueHistory(Event(Unit))
} }
companion object { companion object {

View file

@ -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.InstantExecutorExtension
import org.fnives.test.showcase.testutils.TestMainDispatcher import org.fnives.test.showcase.testutils.TestMainDispatcher
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
@Disabled("CodeKata")
@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) @ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class CodeKataAuthViewModel { class CodeKataAuthViewModel {
private lateinit var sut: AuthViewModel private lateinit var sut: AuthViewModel
private lateinit var mockLoginUseCase: LoginUseCase private lateinit var mockLoginUseCase: LoginUseCase
private val testDispatcher get() = TestMainDispatcher.testDispatcher private val testScheduler get() = TestMainDispatcher.testDispatcher.scheduler
@BeforeEach @BeforeEach
fun setUp() { fun setUp() {
@ -45,4 +43,29 @@ class CodeKataAuthViewModel {
@Test @Test
fun noPasswordUsesEmptyStringInLoginUseCase() { 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() {
}
} }

View file

@ -1,11 +1,13 @@
package org.fnives.test.showcase.ui.splash package org.fnives.test.showcase.ui.splash
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@Disabled("CodeKata") @Disabled("CodeKata")
@OptIn(ExperimentalCoroutinesApi::class)
internal class CodeKataSplashViewModelTest { internal class CodeKataSplashViewModelTest {
@BeforeEach @BeforeEach
@ -19,7 +21,7 @@ internal class CodeKataSplashViewModelTest {
@DisplayName("GIVEN logged in user WHEN splash started THEN after half a second navigated to home") @DisplayName("GIVEN logged in user WHEN splash started THEN after half a second navigated to home")
@Test @Test
fun loggedInUserGoestoHome() { fun loggedInUserGoesToHome() {
} }
@DisplayName("GIVEN not logged in user WHEN splash started THEN before half a second no event is sent") @DisplayName("GIVEN not logged in user WHEN splash started THEN before half a second no event is sent")

View file

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

View file

@ -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<Arguments?> = 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