core description

This commit is contained in:
Gergely Hegedus 2021-09-18 11:51:02 +03:00
parent 392ebc5115
commit ad2a8fee80
8 changed files with 318 additions and 19 deletions

View file

@ -14,6 +14,7 @@ class TestMainDispatcher : BeforeEachCallback, AfterEachCallback {
override fun beforeEach(context: ExtensionContext?) { override fun beforeEach(context: ExtensionContext?) {
val testDispatcher = TestCoroutineDispatcher() val testDispatcher = TestCoroutineDispatcher()
privateTestDispatcher = testDispatcher privateTestDispatcher = testDispatcher
testDispatcher.pauseDispatcher()
DatabaseInitialization.dispatcher = testDispatcher DatabaseInitialization.dispatcher = testDispatcher
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
} }

View file

@ -10,6 +10,7 @@ import org.fnives.test.showcase.testutils.InstantExecutorExtension
import org.fnives.test.showcase.testutils.TestMainDispatcher import org.fnives.test.showcase.testutils.TestMainDispatcher
import org.fnives.test.showcase.ui.shared.Event import org.fnives.test.showcase.ui.shared.Event
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
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.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
@ -35,13 +36,14 @@ internal class AuthViewModelTest {
@BeforeEach @BeforeEach
fun setUp() { fun setUp() {
mockLoginUseCase = mock() mockLoginUseCase = mock()
testDispatcher.pauseDispatcher()
sut = AuthViewModel(mockLoginUseCase) sut = AuthViewModel(mockLoginUseCase)
} }
@DisplayName("GIVEN_initialized_viewModel_WHEN_observed_THEN_loading_false_other_fields_are_empty")
@Test @Test
fun GIVEN_initialized_viewModel_WHEN_observed_THEN_loading_false_other_fields_are_empty() { fun initialSetup() {
testDispatcher.resumeDispatcher() testDispatcher.resumeDispatcher()
sut.username.test().assertNoValue() sut.username.test().assertNoValue()
sut.password.test().assertNoValue() sut.password.test().assertNoValue()
sut.loading.test().assertValue(false) sut.loading.test().assertValue(false)
@ -49,8 +51,9 @@ internal class AuthViewModelTest {
sut.navigateToHome.test().assertNoValue() sut.navigateToHome.test().assertNoValue()
} }
@DisplayName("GIVEN_password_text_WHEN_onPasswordChanged_is_called_THEN_password_livedata_is_updated")
@Test @Test
fun GIVEN_password_text_WHEN_onPasswordChanged_is_called_THEN_password_livedata_is_updated() { fun whenPasswordChangedLiveDataIsUpdated() {
testDispatcher.resumeDispatcher() testDispatcher.resumeDispatcher()
val passwordTestObserver = sut.password.test() val passwordTestObserver = sut.password.test()
@ -64,8 +67,9 @@ internal class AuthViewModelTest {
sut.navigateToHome.test().assertNoValue() sut.navigateToHome.test().assertNoValue()
} }
@DisplayName("GIVEN_username_text_WHEN_onUsernameChanged_is_called_THEN_username_livedata_is_updated")
@Test @Test
fun GIVEN_username_text_WHEN_onUsernameChanged_is_called_THEN_username_livedata_is_updated() { fun whenUsernameChangedLiveDataIsUpdated() {
testDispatcher.resumeDispatcher() testDispatcher.resumeDispatcher()
val usernameTestObserver = sut.username.test() val usernameTestObserver = sut.username.test()
@ -79,8 +83,9 @@ internal class AuthViewModelTest {
sut.navigateToHome.test().assertNoValue() sut.navigateToHome.test().assertNoValue()
} }
@DisplayName("GIVEN_no_password_or_username_WHEN_login_is_Called_THEN_empty_credentials_are_used_in_usecase")
@Test @Test
fun GIVEN_no_password_or_username_WHEN_login_is_Called_THEN_empty_credentials_are_used_in_usecase() { fun noPasswordUsesEmptyStringInLoginUseCase() {
val loadingTestObserver = sut.loading.test() val loadingTestObserver = sut.loading.test()
runBlocking { runBlocking {
whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable()))
@ -95,7 +100,7 @@ internal class AuthViewModelTest {
} }
@Test @Test
fun WHEN_login_is_Called_twise_THEN_use_case_is_only_called_once() { fun WHEN_login_is_Called_twice_THEN_use_case_is_only_called_once() {
runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) } runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) }
sut.onLogin() sut.onLogin()
@ -107,7 +112,7 @@ internal class AuthViewModelTest {
} }
@Test @Test
fun GIVEN_password_and_username_WHEN_login_is_Called_THEN_empty_credentials_are_used_in_usecase() { fun GIVEN_password_and_username_WHEN_login_is_Called_THEN_not_empty_credentials_are_used_in_usecase() {
runBlocking { runBlocking {
whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable()))
} }

View file

@ -0,0 +1,48 @@
package org.fnives.test.showcase.ui.auth
import org.fnives.test.showcase.core.login.LoginUseCase
import org.fnives.test.showcase.testutils.InstantExecutorExtension
import org.fnives.test.showcase.testutils.TestMainDispatcher
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.kotlin.mock
@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class)
class CodeKataAuthViewModel {
private lateinit var sut: AuthViewModel
private lateinit var mockLoginUseCase: LoginUseCase
private val testDispatcher get() = TestMainDispatcher.testDispatcher
@BeforeEach
fun setUp() {
mockLoginUseCase = mock()
sut = AuthViewModel(mockLoginUseCase)
}
@DisplayName("GIVEN_initialized_viewModel_WHEN_observed_THEN_loading_false_other_fields_are_empty")
@Test
fun initialSetup() {
}
@DisplayName("GIVEN_password_text_WHEN_onPasswordChanged_is_called_THEN_password_livedata_is_updated")
@Test
fun whenPasswordChangedLiveDataIsUpdated() {
}
@DisplayName("GIVEN_username_text_WHEN_onUsernameChanged_is_called_THEN_username_livedata_is_updated")
@Test
fun whenUsernameChangedLiveDataIsUpdated() {
}
@DisplayName("GIVEN_no_password_or_username_WHEN_login_is_Called_THEN_empty_credentials_are_used_in_usecase")
@Test
fun noPasswordUsesEmptyStringInLoginUseCase() {
}
}

View file

@ -0,0 +1,39 @@
package org.fnives.test.showcase.ui.splash
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.testutils.InstantExecutorExtension
import org.fnives.test.showcase.testutils.TestMainDispatcher
import org.fnives.test.showcase.ui.shared.Event
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
internal class CodeKataSplashViewModelTest {
@BeforeEach
fun setUp() {
}
@DisplayName("GIVEN not logged in user WHEN splash started THEN after half a second navigated to authentication")
@Test
fun loggedOutUserGoesToAuthentication() {
}
@DisplayName("GIVEN logged in user WHEN splash started THEN after half a second navigated to home")
@Test
fun loggedInUserGoestoHome() {
}
@DisplayName("GIVEN not logged in user WHEN splash started THEN before half a second no event is sent")
@Test
fun withoutEnoughTimeNoNavigationHappens() {
}
}

View file

@ -6,13 +6,13 @@ import org.fnives.test.showcase.testutils.InstantExecutorExtension
import org.fnives.test.showcase.testutils.TestMainDispatcher import org.fnives.test.showcase.testutils.TestMainDispatcher
import org.fnives.test.showcase.ui.shared.Event import org.fnives.test.showcase.ui.shared.Event
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
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.doReturn import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
@Suppress("TestFunctionName")
@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) @ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class)
internal class SplashViewModelTest { internal class SplashViewModelTest {
@ -26,8 +26,9 @@ internal class SplashViewModelTest {
sut = SplashViewModel(mockIsUserLoggedInUseCase) sut = SplashViewModel(mockIsUserLoggedInUseCase)
} }
@DisplayName("GIVEN not logged in user WHEN splash started THEN after half a second navigated to authentication")
@Test @Test
fun GIVEN_not_logged_in_user_WHEN_splash_started_THEN_after_half_a_second_navigated_to_authentication() { fun loggedOutUserGoesToAuthentication() {
whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false)
testCoroutineDispatcher.advanceTimeBy(500) testCoroutineDispatcher.advanceTimeBy(500)
@ -35,8 +36,9 @@ internal class SplashViewModelTest {
sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.AUTHENTICATION)) sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.AUTHENTICATION))
} }
@DisplayName("GIVEN logged in user WHEN splash started THEN after half a second navigated to home")
@Test @Test
fun GIVEN_logged_in_user_WHEN_splash_started_THEN_after_half_a_second_navigated_to_home() { fun loggedInUserGoestoHome() {
whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(true) whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(true)
testCoroutineDispatcher.advanceTimeBy(500) testCoroutineDispatcher.advanceTimeBy(500)
@ -44,8 +46,9 @@ internal class SplashViewModelTest {
sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.HOME)) sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.HOME))
} }
@DisplayName("GIVEN not logged in user WHEN splash started THEN before half a second no event is sent")
@Test @Test
fun GIVEN_not_logged_in_user_WHEN_splash_started_THEN_before_half_a_second_no_event_is_sent() { fun withoutEnoughTimeNoNavigationHappens() {
whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false)
testCoroutineDispatcher.advanceTimeBy(100) testCoroutineDispatcher.advanceTimeBy(100)

View file

@ -1,4 +1,4 @@
# Starting of testing # 1. Starting of testing
In this testing instruction set you will learn how to write simple tests using mockito. In this testing instruction set you will learn how to write simple tests using mockito.
@ -457,18 +457,20 @@ In this case we should recreate our sut in the test and feed it our own remote s
#### Still using mockito #### Still using mockito
To mock such behaviour with mockito is not as straight forward as creating our own. To mock such behaviour with mockito with our current tool set is not as straight forward as creating our own.
That's because mockito is not aware of the nature of suspend functions, like our code is in the custom mock. That's because how we used mockito so far it is not aware of the nature of suspend functions, like our code is in the custom mock.
However mockito give us the arguments passed into the function. However mockito give us the arguments passed into the function.
And since we know the Continuation object is passed as a last argument in suspend functions we can take advantage of that. And since we know the Continuation object is passed as a last argument in suspend functions we can take advantage of that.
This then can be abstracted away and used wherevere without needing to create Custom Mocks for every such case. This then can be abstracted away and used wherever without needing to create Custom Mocks for every such case.
To get arguments when creating a response for the mock you need to use thenAnswer { } and this lambda will receive InvocationOnMock containing the arguments. To get arguments when creating a response for the mock you need to use thenAnswer { } and this lambda will receive InvocationOnMock containing the arguments.
Similarly our suspendable answer is named as such `doSuspendableAnswer` Luckily this has already be done in "org.mockito.kotlin" and it's called `doSuspendableAnswer`
The point here is not exactly how the doSuspendableAnswer is created, just that we can get arguments while mocking with mockito, and also extend it in a way that helps us in common patterns. The point here is that we can get arguments while mocking with mockito, and we are able to extend it in a way that helps us in common patterns.
This `doSuspendableAnswer` wasn't available for a while, but we could still create it, if needed.
#### Back to the actual test #### Back to the actual test

View file

@ -1,4 +1,4 @@
# Starting of networking testing # 2. Starting of networking testing
In this testing instruction set you will learn how to write simple tests with retrofit. In this testing instruction set you will learn how to write simple tests with retrofit.

View file

@ -1 +1,202 @@
TODO # 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)
```