Issue#49 Add Integration Test instruction set for Core
This commit is contained in:
parent
9a63cdba38
commit
24cffc5057
7 changed files with 495 additions and 24 deletions
334
codekata/core.again.instructionset.md
Normal file
334
codekata/core.again.instructionset.md
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
# 4. Starting of integration testing
|
||||
|
||||
You probably got bored of Unit Testing if you got to this point, so let's switch it up a little.
|
||||
|
||||
In this testing instruction set you will learn how to write simple Integration tests for your Java module:
|
||||
|
||||
- How to write integration tests
|
||||
- How to use Fakes
|
||||
- How to depend on test modules
|
||||
- Exercise parametrized tests
|
||||
- Exercise Junit Extensions
|
||||
|
||||
## AuthIntegrationTest test
|
||||
|
||||
Our System Under Test will be all Authentication related public classes of Core module, so namely:
|
||||
- `org.fnives.test.showcase.core.login.IsUserLoggedInUseCase`
|
||||
- `org.fnives.test.showcase.core.login.LoginUseCase`
|
||||
- `org.fnives.test.showcase.core.login.LogoutUseCase`
|
||||
|
||||
What we want to test here, is that all components hidden behind these classes together let the user login, store their session and logout.
|
||||
|
||||
### Setup
|
||||
|
||||
So let's open up our test class: `org.fnives.test.showcase.core.integration.CodeKataAuthIntegrationTest`
|
||||
|
||||
First, we want to take advantage of our DI module, so let's inject our actual classes:
|
||||
```kotlin
|
||||
private val isUserLoggedInUseCase by inject<IsUserLoggedInUseCase>()
|
||||
private val loginUseCase by inject<LoginUseCase>()
|
||||
private val logoutUseCase by inject<LogoutUseCase>()
|
||||
```
|
||||
|
||||
Now let's startKoin in our setup method:
|
||||
```kotlin
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
startKoin {
|
||||
modules(
|
||||
createCoreModule(
|
||||
baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url),
|
||||
enableNetworkLogging = true,
|
||||
favouriteContentLocalStorageProvider = { fakeFavouriteContentLocalStorage },
|
||||
sessionExpirationListenerProvider = { mockSessionExpirationListener },
|
||||
userDataLocalStorageProvider = { fakeUserDataLocalStorage }
|
||||
).toList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
stopKoin()
|
||||
}
|
||||
```
|
||||
|
||||
Okay, a couple of things are missing. First of what are those fakes? Let's start with them
|
||||
|
||||
#### Fakes
|
||||
|
||||
So the `FavouriteContentLocalStorage` and `UserDataLocalStorage` will be injected into our modules.
|
||||
However, we expect a specific behaviour from them.
|
||||
|
||||
So instead of mocking them, let's create simple fakes, that we can use in our tests, as they were the real class.
|
||||
|
||||
Let's start with `FakeUserDataLocalStorage`.
|
||||
|
||||
###### Let's open `CodeKataUserDataLocalStorage`.
|
||||
|
||||
This has to extend the `UserDataLocalStorage` interface, so add that. And the only required implementation is a modifiable field. So add it as a constructor argument and that's it.
|
||||
|
||||
```kotlin
|
||||
class CodeKataUserDataLocalStorage(override var session: Session? = null) : UserDataLocalStorage
|
||||
```
|
||||
|
||||
###### Now let's open `CodeKataFavouriteContentLocalStorage`.
|
||||
|
||||
This is a bit more tricky, there are multiple methods.
|
||||
|
||||
First of all we need a flow, so let's just use a SharedFlow and initialize it:
|
||||
```kotlin
|
||||
private val dataFlow = MutableSharedFlow<List<ContentId>>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
init {
|
||||
dataFlow.tryEmit(emptyList())
|
||||
}
|
||||
```
|
||||
|
||||
With that we can return our flow from `observeFavourites`:
|
||||
|
||||
```kotlin
|
||||
override fun observeFavourites(): Flow<List<ContentId>> = dataFlow.asSharedFlow()
|
||||
```
|
||||
|
||||
And our methods just need to update the flow as it would be expected:
|
||||
```kotlin
|
||||
override suspend fun markAsFavourite(contentId: ContentId) {
|
||||
dataFlow.emit(dataFlow.replayCache.first().plus(contentId))
|
||||
}
|
||||
|
||||
override suspend fun deleteAsFavourite(contentId: ContentId) {
|
||||
dataFlow.emit(dataFlow.replayCache.first().minus(contentId))
|
||||
}
|
||||
```
|
||||
|
||||
Okay, we have our fakes. Let's navigate back to `CodeKataAuthIntegrationTest`
|
||||
|
||||
#### Continue Setup
|
||||
|
||||
Let's just declare our fakes and initialize them in the setup:
|
||||
```kotlin
|
||||
private lateinit var fakeFavouriteContentLocalStorage: FavouriteContentLocalStorage
|
||||
private lateinit var mockSessionExpirationListener: SessionExpirationListener
|
||||
private lateinit var fakeUserDataLocalStorage: UserDataLocalStorage
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
mockSessionExpirationListener = mock() // we are using mock, since it only has 1 function so we just want to verify if it's called
|
||||
fakeFavouriteContentLocalStorage = FakeFavouriteContentLocalStorage()
|
||||
fakeUserDataLocalStorage = FakeUserDataLocalStorage(null)
|
||||
startKoin {
|
||||
///...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
We are still missing `mockServerScenarioSetupExtensions` this will be our TestExtension, to initialize MockWebServer.
|
||||
`MockServerScenarioSetupExtensions` is declared in the `:network` test module.
|
||||
However we are still able to import it.
|
||||
|
||||
That's because of [java-test-fixtures](https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures) plugin. It is can be used to depend on a specific test module "textFixtures".
|
||||
Check out the build.gradle's to see how that's done.
|
||||
This can be useful to share some static Test Data, or extensions in our case.
|
||||
|
||||
So let's add this extension:
|
||||
```kotlin
|
||||
@RegisterExtension
|
||||
@JvmField
|
||||
val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions()
|
||||
private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup
|
||||
```
|
||||
|
||||
This extension is a wrapper around MockWebServer containing setups of requests, request verifications and ContentData.
|
||||
It is useful to mock our requests with this extension from now on so we don't repeat ourselves.
|
||||
|
||||
With that let's start testing:
|
||||
|
||||
### 1. `withoutSessionTheUserIsNotLoggedIn`
|
||||
|
||||
As usual, we start with the simplest test. Let's verify that if the session object is null, we are indeed logged out:
|
||||
|
||||
```kotlin
|
||||
@DisplayName("GIVEN no session saved WHEN checking if user is logged in THEN they are not")
|
||||
@Test
|
||||
fun withoutSessionTheUserIsNotLoggedIn() = runTest {
|
||||
fakeUserDataLocalStorage.session = null
|
||||
val actual = isUserLoggedInUseCase.invoke()
|
||||
|
||||
Assertions.assertFalse(actual, "User is expected to be not logged in")
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
```
|
||||
|
||||
### 2. `loginSuccess`
|
||||
|
||||
Let's test that given good credentials and success response, our user can login in.
|
||||
|
||||
First we setup our mock server and the expected session:
|
||||
```kotlin
|
||||
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "usr", password = "sEc"), validateArguments = true) // validate arguments just verifies the request path, body, headers etc.
|
||||
val expectedSession = ContentData.loginSuccessResponse
|
||||
```
|
||||
|
||||
Now we login, and then check if we are actually logged in:
|
||||
```kotlin
|
||||
val answer = loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc"))
|
||||
val actual = isUserLoggedInUseCase.invoke()
|
||||
```
|
||||
|
||||
And just verify:
|
||||
```kotlin
|
||||
Assertions.assertEquals(Answer.Success(LoginStatus.SUCCESS), answer)
|
||||
Assertions.assertTrue(actual, "User is expected to be logged in")
|
||||
Assertions.assertEquals(expectedSession, fakeUserDataLocalStorage.session)
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
```
|
||||
|
||||
With this, looks like our Integration works correctly. Requests are called, proper response is received, login state is changed.
|
||||
|
||||
### 3. `localInputError`
|
||||
We have to expected errors, that are returned even before running requests, if the username or password is empty.
|
||||
This two tests would be really similar, so let's do Parametrized tests.
|
||||
|
||||
First we modify our method signature:
|
||||
```kotlin
|
||||
@MethodSource("localInputErrorArguments")
|
||||
@ParameterizedTest(name = "GIVEN {0} credentials WHEN login called THEN error {1} is shown")
|
||||
fun localInputError(credentials: LoginCredentials, loginError: LoginStatus)
|
||||
```
|
||||
|
||||
Now let's declare our action:
|
||||
```kotlin
|
||||
val answer = loginUseCase.invoke(credentials)
|
||||
val actual = isUserLoggedInUseCase.invoke()
|
||||
```
|
||||
|
||||
And do our verifications, aka not logged in, not session expired and the correct error:
|
||||
```kotlin
|
||||
Assertions.assertEquals(Answer.Success(loginError), answer)
|
||||
Assertions.assertFalse(actual, "User is expected to be not logged in")
|
||||
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
```
|
||||
|
||||
Now we just need to declare our parameters for our test:
|
||||
```kotlin
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun localInputErrorArguments() = Stream.of(
|
||||
Arguments.of(LoginCredentials("", "password"), LoginStatus.INVALID_USERNAME),
|
||||
Arguments.of(LoginCredentials("username", ""), LoginStatus.INVALID_PASSWORD)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
With that we covered both of these errors.
|
||||
|
||||
### 4. `networkInputError`
|
||||
|
||||
Now let's do the same with network inputs. This will be really similar, only difference is we will initialize our mockserver with the AuthScenario.
|
||||
Try to do it yourself, however for completeness sake, as usual, here is the code:
|
||||
```kotlin
|
||||
@MethodSource("networkErrorArguments")
|
||||
@ParameterizedTest(name = "GIVEN {0} network response WHEN login called THEN error is shown")
|
||||
fun networkInputError(authScenario: AuthScenario) = runTest {
|
||||
mockServerScenarioSetup.setScenario(authScenario, validateArguments = true)
|
||||
val credentials = LoginCredentials(username = authScenario.username, password = authScenario.password)
|
||||
val answer = loginUseCase.invoke(credentials)
|
||||
val actual = isUserLoggedInUseCase.invoke()
|
||||
|
||||
Assertions.assertTrue(answer is Answer.Error, "Answer is expected to be an Error")
|
||||
Assertions.assertFalse(actual, "User is expected to be not logged in")
|
||||
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. `loginInvalidCredentials`
|
||||
|
||||
We have one more expected error type, but this comes from the NetworkResponse. We could add it as parametrized test, but for the sake of readability, let's just keep it separate.
|
||||
|
||||
Thi is really similar to the `networkInputError`, the differences are that this is not parametrized, we use `AuthScenario.InvalidCredentials` response and we expect `Answer.Success(LoginStatus.INVALID_CREDENTIALS)`
|
||||
|
||||
So together:
|
||||
```kotlin
|
||||
@DisplayName("GIVEN no session WHEN user is logging in THEN they get session")
|
||||
@Test
|
||||
fun loginInvalidCredentials() = runTest {
|
||||
mockServerScenarioSetup.setScenario(AuthScenario.InvalidCredentials(username = "usr", password = "sEc"), validateArguments = true)
|
||||
|
||||
val answer = loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc"))
|
||||
val actual = isUserLoggedInUseCase.invoke()
|
||||
|
||||
Assertions.assertEquals(Answer.Success(LoginStatus.INVALID_CREDENTIALS), answer)
|
||||
Assertions.assertFalse(actual, "User is expected to be not logged in")
|
||||
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
}
|
||||
```
|
||||
|
||||
### 6. `logout`
|
||||
Now let's verify if the user can logout properly.
|
||||
|
||||
For this we first need to have the user in a logged in state:
|
||||
```kotlin
|
||||
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "usr", password = "sEc"), validateArguments = true)
|
||||
loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc"))
|
||||
```
|
||||
|
||||
The user needs to logout:
|
||||
```kotlin
|
||||
logoutUseCase.invoke()
|
||||
val actual = isUserLoggedInUseCase.invoke()
|
||||
```
|
||||
|
||||
And we verify the user is indeed logged out now:
|
||||
```kotlin
|
||||
Assertions.assertFalse(actual, "User is expected to be logged out")
|
||||
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
|
||||
verifyZeroInteractions(mockSessionExpirationListener)
|
||||
```
|
||||
|
||||
### 7. `logoutReleasesContent`
|
||||
At last, let's verify that when the user logs out, their cache is released and the request is no longer authenticated.
|
||||
|
||||
To do this, first we setup our MockServer and login the user:
|
||||
```kotlin
|
||||
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "usr", password = "sEc"), validateArguments = true)
|
||||
.setScenario(ContentScenario.Success(usingRefreshedToken = false), validateArguments = true)
|
||||
loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc"))
|
||||
```
|
||||
|
||||
Now we get the content values before and after logout:
|
||||
```kotlin
|
||||
val valuesBeforeLogout = get<GetAllContentUseCase>().get().take(2).last()
|
||||
logoutUseCase.invoke()
|
||||
val valuesAfterLogout = get<GetAllContentUseCase>().get().take(2).last()
|
||||
```
|
||||
> Note: we are using get() from koin, since we don't want to depend on how the data is cleared and this way we get the UseCase a new user would get.
|
||||
|
||||
Now there is a bit of explaining to do. How `mockServerScenarioSetup` is setup is that if `validateArguments` is set, it will verify the path, the body and the authentication token. If it doesn't match, it will return a BAD Request.
|
||||
We could do the same with MockWebServer and recorded request as well, it's just now hidden behind our TestHelper MockServer.
|
||||
|
||||
So what we want to verify, is that `valuesBeforeLogout` is a success, and the `valuesAfterLogout` is a failure.
|
||||
|
||||
```kotlin
|
||||
Assertions.assertTrue(valuesBeforeLogout is Resource.Success, "Before we expect a cached Success")
|
||||
Assertions.assertTrue(valuesAfterLogout is Resource.Error, "After we expect an error, since our request no longer is authenticated")
|
||||
```
|
||||
If it would be cached, the test would be stuck, cause Loading wouldn't be emitted, or if the request would be authenticated success would be returned as we setup Success response.
|
||||
|
||||
## Conclusions
|
||||
With that we wrote our Integration tests.
|
||||
There is no point of going over other integration test's in the core module, since the idea is captured, and nothing new could be shown.
|
||||
If you want to give it a go, feel free, however consider using turbine for flow tests, cause it can be a bit tricky.
|
||||
|
||||
What we have learned:
|
||||
- In integration tests, we mock the least amount of classes
|
||||
- In integration tests we verify multiple classes and how they work together
|
||||
- We learned we can share test classes between modules
|
||||
- We learned how to write fakes
|
||||
- We exercised the Parametrized tests
|
||||
Loading…
Add table
Add a link
Reference in a new issue