Merge pull request #45 from fknives/issue#12-viewmode-tests
Issue#12 Finish ViewModel instruction set
This commit is contained in:
commit
e2bf4b3bce
7 changed files with 472 additions and 243 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
```
|
||||
391
codekata/viewmodel.instructionset.md
Normal file
391
codekata/viewmodel.instructionset.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue