Merge pull request #44 from fknives/issue#10-review-and-update-network-test-description
Issue#10 Add additional Test for missing field and exact copy of how the CodeKataSessionExpirationTest should look
This commit is contained in:
commit
89c741a6bb
3 changed files with 137 additions and 49 deletions
|
|
@ -138,7 +138,7 @@ The core tests are the simplest, we will look into how to use mockito to mock cl
|
|||
Next we will look how to test flows.
|
||||
|
||||
#### Networking
|
||||
Open the [networking instruction set](./codekata/networking.instructionset).
|
||||
Open the [networking instruction set](./codekata/networking.instructionset.md).
|
||||
|
||||
The networking instruction set will show you how to test network request with mockwebserver.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,57 @@
|
|||
# 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 and networking.
|
||||
|
||||
Every system under test will be injected to ensure the
|
||||
Every test will use a mocked response and verify the requests sent out.
|
||||
We will also verify how oauth token refreshing can be tested.
|
||||
Every System Under Test will be injected to ensure the setup of Retrofit is correct.
|
||||
In every test class will use a mocked response and verify the requests sent out.
|
||||
It will also be verify how OAuth token refreshing can be tested.
|
||||
|
||||
I would suggest to open this document in your browser, while working in Android Studio.
|
||||
|
||||
## Simple network test
|
||||
|
||||
Our system under test will be `org.fnives.test.showcase.network.auth.LoginRemoteSourceImpl` But we will only test the login method. The refresh method is part of sesssion handling, that's why it is internal.
|
||||
Our System Under Test will be `org.fnives.test.showcase.network.auth.LoginRemoteSourceImpl` But we will only test the login function. The refresh method is part of sesssion handling, that's why it is internal.
|
||||
|
||||
So the login method sends out a retrofit request and parses a response for us, this is what we intend to test.
|
||||
The login function sends out a retrofit request and parses a response for us, this is what we intend to test.
|
||||
|
||||
Let's setup our testClass: CodeKataLoginRemoteSourceTest
|
||||
Let's setup our testClass: `org.fnives.test.showcase.network.auth.CodeKataLoginRemoteSourceTest`
|
||||
|
||||
### Setup
|
||||
First since we are using Koin as Service Locator, we should extend KoinTest, thus giving us an easier way to access koin.
|
||||
First since we are using Koin as Service Locator, we should extend KoinTest, thus giving us an easier way to access koin functions.
|
||||
|
||||
```kotlin
|
||||
class CodeKataLoginRemoteSourceTest : KoinTest {
|
||||
|
||||
//...
|
||||
}
|
||||
```
|
||||
|
||||
Next we need to inject our system under test and setup koin. However we also need to tearDown our setup after every test:
|
||||
Next we need to inject our System Under Test, setup koin and setup or MockServer. However we also need to tearDown our setup after every test. Let's take it step by step.
|
||||
|
||||
First we declare our required fields:
|
||||
```kotlin
|
||||
private val sut by inject<LoginRemoteSource>()
|
||||
private lateinit var mockWebServer : MockWebServer
|
||||
```
|
||||
|
||||
MockWebServer is what we will use to give Mock Responses to our Retrofit calls. For this we need to start it, and at the end tear it down.
|
||||
```kotlin
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
mockWebServer.shutdown()
|
||||
}
|
||||
```
|
||||
|
||||
Now we need to setup our Koin:
|
||||
```kotlin
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
//...
|
||||
val mockNetworkSessionLocalStorage = mock<NetworkSessionLocalStorage>()
|
||||
startKoin {
|
||||
modules(
|
||||
|
|
@ -49,22 +67,48 @@ fun setUp() {
|
|||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
mockWebServer.shutdown()
|
||||
stopKoin()
|
||||
//...
|
||||
}
|
||||
```
|
||||
|
||||
We use mockwebserver's rul as baseUrl thus every retrofit request we will send out the mockwebserver will capture.
|
||||
|
||||
We also need to stop mockwebserver after every test so it doesn't have state sharing between tests.
|
||||
|
||||
We use mockwebserver's url as baseUrl thus every retrofit request we will send out the MockWebServer will capture and respond.
|
||||
Koin also needs to be stopped so our LoginRemoteSource is injected every time.
|
||||
`createNetworkModules` is the koin modules defining the network module's injections.
|
||||
For now we do not care about `networkSessionExpirationListenerProvider`, and `networkSessionLocalStorageProvider`.
|
||||
We enable logging so it's easier to see what happens in tests.
|
||||
|
||||
With this setup we are ready to start testing
|
||||
With this setup we are ready to start testing. Just for the sake of simplicity here is the full code:
|
||||
```kotlin
|
||||
private val sut by inject<LoginRemoteSource>()
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
startKoin {
|
||||
modules(
|
||||
createNetworkModules(
|
||||
baseUrl = BaseUrl(mockWebServer.url("mockserver/").toString()),
|
||||
enableLogging = true,
|
||||
networkSessionExpirationListenerProvider = { mock() },
|
||||
networkSessionLocalStorageProvider = { mock() }
|
||||
).toList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
stopKoin()
|
||||
mockWebServer.shutdown()
|
||||
}
|
||||
```
|
||||
|
||||
### 1. `successResponseParsedProperly`
|
||||
|
||||
Notice we are starting with `runBlocking` instead of `runBlockingTest`. That's because okhttp / retrofit can have still not finished coroutines which would fail our runBlockingTest.
|
||||
Notice we are starting with `runBlocking` instead of `runTest`. That's because we do not have any concurrency, and also don't care about the Threads used by OkHttp, we just want to be sure to get the responses in sync.
|
||||
|
||||
First we need to setup mockwebserver to respond to our request and the expected value:
|
||||
|
||||
|
|
@ -73,35 +117,35 @@ mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(readResourceFi
|
|||
val expected = LoginStatusResponses.Success(Session(accessToken = "login-access", refreshToken = "login-refresh"))
|
||||
```
|
||||
|
||||
As you can see we can set the responseCode of the request and a body. here we use a helper function which goes to the resources folder and reads the content of the file.
|
||||
As you can see we can set the responseCode of the request and a body. here we use a helper function which goes to the resources folder and reads the content of the given file.
|
||||
Usually you will need to create your own files out of the expected responses so please check where the file is located.
|
||||
|
||||
Next we declared the expected value, the accessToken and refreshToken come from the ResponseBody (aka the file), this is where it should be parsed from.
|
||||
Next, we declared the expected value, the accessToken and refreshToken come from the ResponseBody (aka the file), this is where it should be parsed from.
|
||||
|
||||
We then declare the action
|
||||
```kotlin
|
||||
val actual = sut.login(LoginCredentials("a", "b"))
|
||||
val actual = sut.login(LoginCredentials(username = "a", password = "b"))
|
||||
```
|
||||
|
||||
And verification
|
||||
And at the end we just verify the response is what we expected
|
||||
```kotlin
|
||||
Assertions.assertEquals(expected, actual)
|
||||
```
|
||||
|
||||
Now running this tet you should see logs by OkHttpLoggingInterceptor and see what request was sent out and received.
|
||||
Now, running this test, you will see logs by OkHttpLoggingInterceptor and see what request was sent out and received.
|
||||
|
||||
### 2. `requestProperlySetup`
|
||||
|
||||
So far we verified how to parse a response, but what about the validity of the request. This is what we will test next:
|
||||
So far we verified how to parse a response, but what about the validity of the request send out. This is what we will test next:
|
||||
|
||||
First we setup the mockwebserver:
|
||||
First we setup the mockwebserver just like before, however we no longer care about the returned value:
|
||||
```kotlin
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(readResourceFile("success_response_login.json")))
|
||||
```
|
||||
|
||||
Next we call the method, and get the requestData from mockwebserver:
|
||||
Next we call the function, and get the sent requestData from MockWebServer:
|
||||
```kotlin
|
||||
val actual = sut.login(LoginCredentials("a", "b"))
|
||||
sut.login(LoginCredentials("a", "b"))
|
||||
val request = mockWebServer.takeRequest()
|
||||
```
|
||||
|
||||
|
|
@ -109,17 +153,27 @@ Now that we have the requestData we can do verifications on it.
|
|||
In this case we need to verify the method, headers, path, requestBody:
|
||||
|
||||
```kotlin
|
||||
// we expect it's a POST HTTP request
|
||||
Assertions.assertEquals("POST", request.method)
|
||||
|
||||
// we verify the Platform header is sent correctly
|
||||
Assertions.assertEquals("Android", request.getHeader("Platform"))
|
||||
|
||||
// we verify the request doesn't contain any authorization
|
||||
Assertions.assertEquals(null, request.getHeader("Authorization"))
|
||||
|
||||
// we verify the path of the request, "/mockserver" part comes from the setup,
|
||||
// when we gave koin the url. With this we also verified the base url is kept in the request.
|
||||
Assertions.assertEquals("/mockserver/login", request.path)
|
||||
val loginRequest = """
|
||||
|
||||
val loginRequestBody = """
|
||||
{
|
||||
"username": "a",
|
||||
"password": "b"
|
||||
}
|
||||
""".trimIndent()
|
||||
JSONAssert.assertEquals(loginRequest, request.body.readUtf8(), JSONCompareMode.NON_EXTENSIBLE) // Since the responseBody is json we use a library that can compare json properly
|
||||
// Since the responseBody is json we use a library that can compare json properly
|
||||
JSONAssert.assertEquals(loginRequestBody, request.body.readUtf8(), JSONCompareMode.NON_EXTENSIBLE)
|
||||
```
|
||||
|
||||
With this we can be sure our request contains exactly what we want it to contain.
|
||||
|
|
@ -142,20 +196,26 @@ val actual = sut.login(LoginCredentials("a", "b"))
|
|||
|
||||
Assertions.assertEquals(expected, actual)
|
||||
```
|
||||
Notice we expected this error so no exception is thronw.
|
||||
Notice we expected this error so no exception is thrown.
|
||||
|
||||
### 4. `genericErrorMeansNetworkError`
|
||||
|
||||
Next let's see if we get an unexpected response code. In such case we actually except a specific exception to be thrown, so it looks like this:
|
||||
Next, let's see if we get an unexpected response code. In such case we actually except a specific exception to be thrown, so it looks like this:
|
||||
|
||||
```kotlin
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(500).setBody(""))
|
||||
|
||||
Assertions.assertThrows(NetworkException::class.java) {
|
||||
val actual = Assertions.assertThrows(NetworkException::class.java) {
|
||||
runBlocking { sut.login(LoginCredentials("a", "b")) }
|
||||
}
|
||||
```
|
||||
|
||||
Now we can verify the details of the exception as well:
|
||||
```kotlin
|
||||
Assertions.assertEquals("HTTP 500 Server Error", actual.message)
|
||||
Assertions.assertTrue(actual.cause is HttpException)
|
||||
```
|
||||
|
||||
### 5. `invalidJsonMeansParsingException`
|
||||
|
||||
We also need to verify if we get an unexpected json, we handle that case properly:
|
||||
|
|
@ -163,12 +223,29 @@ We also need to verify if we get an unexpected json, we handle that case properl
|
|||
```kotlin
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
|
||||
|
||||
Assertions.assertThrows(ParsingException::class.java) {
|
||||
runBlocking { sut.login(LoginCredentials("a", "b")) }
|
||||
val actual = Assertions.assertThrows(ParsingException::class.java) {
|
||||
runBlocking { sut.login(LoginCredentials(username = "a", password = "b")) }
|
||||
}
|
||||
|
||||
// you not necessarily care about the details of the exception, but here it's just described how to do it.
|
||||
Assertions.assertEquals("Expected BEGIN_OBJECT but was BEGIN_ARRAY at path \$", actual.message)
|
||||
Assertions.assertTrue(actual.cause is JsonDataException)
|
||||
```
|
||||
|
||||
### 6. `malformedJsonMeansParsingException`
|
||||
### 6. `missingFieldJsonMeansParsingException`
|
||||
We also need to verify if a field is missing the error is not just ignored:
|
||||
```kotlin
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
|
||||
|
||||
val actual = Assertions.assertThrows(ParsingException::class.java) {
|
||||
runBlocking { sut.login(LoginCredentials(username = "a", password = "b")) }
|
||||
}
|
||||
|
||||
Assertions.assertEquals("Required value 'accessToken' missing at \$", actual.message)
|
||||
Assertions.assertTrue(actual.cause is JsonDataException)
|
||||
```
|
||||
|
||||
### 7. `malformedJsonMeansParsingException`
|
||||
|
||||
We can also get malformed json from the Backend, we still shouldn't crash:
|
||||
```kotlin
|
||||
|
|
@ -181,22 +258,23 @@ Assertions.assertThrows(ParsingException::class.java) {
|
|||
|
||||
### Conclusion
|
||||
|
||||
With these types of responses we should be covered for the generic errors that could happen, but additional tests needs to be adde for every specific HTTP code the client should handle.
|
||||
With these types of responses we should be covered for the generic errors that could happen.
|
||||
Based on what the request is doing additional tests can be added for every specific HTTP code / response the client should handle. Now you should be equipped to deal with that.
|
||||
|
||||
## Token Refreshing test
|
||||
|
||||
In order to verify ouath token refreshing we need to have a remote source that is session dependent, for this we will use `org.fnives.test.showcase.network.content.ContentRemoteSource`
|
||||
In order to verify OAuth token refreshing we need to have a remote source that is session dependent, for this we will use `org.fnives.test.showcase.network.content.ContentRemoteSource`
|
||||
|
||||
So what happens with session expiration and token refreshing:
|
||||
So let's describe exactly what happens with session expiration and token refreshing:
|
||||
- a request is called that needs authentication
|
||||
- the server responds with 401 meaning the token is expired
|
||||
- we need to call refresh token request
|
||||
- we get a new token and call the request again
|
||||
- or we get an error and we propagate the error and send a sessionExpiration notice
|
||||
|
||||
With that in mind open `CodeKataSessionExpirationTest`
|
||||
With that in mind open `Corg.fnives.test.showcase.network.content.odeKataSessionExpirationTest`
|
||||
|
||||
The setup is alreay done since it's equivalent to our LoginRemoteSource tests.
|
||||
The setup is alreay done since it's equivalent to our LoginRemoteSource tests except the mocks for storage and expiration listener which we now will care about.
|
||||
|
||||
### 1. `successRefreshResultsInRequestRetry`
|
||||
|
||||
|
|
@ -219,16 +297,22 @@ doAnswer { sessionToReturnByMock = it.arguments[0] as Session? }
|
|||
.whenever(mockNetworkSessionLocalStorage).session = anyOrNull() // whenever set is called get the argument and save it into the sessionToReturnByMock
|
||||
```
|
||||
|
||||
The MockWebServer setup is standard, these responses will be returned one after another.
|
||||
The mocking of storage may look a bit strange, but as described in the comments, we return a variable when accessed, the lambda is executed for each access.
|
||||
For overwriting the value we overwrite the setter and save the argument into the `sessionToReturnByMock`
|
||||
Basically we mocked out a modifiable field.
|
||||
|
||||
Now we need to take the action and get the requests to verify:
|
||||
```kotlin
|
||||
sut.get()
|
||||
mockWebServer.takeRequest()
|
||||
|
||||
mockWebServer.takeRequest() // we don't really care of the first request
|
||||
val refreshRequest = mockWebServer.takeRequest()
|
||||
val retryAfterTokenRefreshRequest = mockWebServer.takeRequest()
|
||||
val contentRequestAfterRefreshed = mockWebServer.takeRequest()
|
||||
```
|
||||
|
||||
Next we need to verify
|
||||
- the refresh request was proper
|
||||
- the refresh request was properly setup
|
||||
- the new content request used the updated access token
|
||||
- no session expiration event was sent and token was saved
|
||||
|
||||
|
|
@ -241,7 +325,9 @@ Assertions.assertEquals("", refreshRequest.body.readUtf8())
|
|||
|
||||
Assertions.assertEquals("login-access", retryAfterTokenRefreshRequest.getHeader("Authorization"))
|
||||
|
||||
verify(mockNetworkSessionLocalStorage, times(1)).session = Session("login-access", "login-refresh")
|
||||
// this matches the data from the success_response_login.json
|
||||
val expectedSavedSession = Session(accessToken = "login-access", refreshToken = "login-refresh")
|
||||
verify(mockNetworkSessionLocalStorage, times(1)).session = expectedSavedSession
|
||||
verifyZeroInteractions(mockNetworkSessionExpirationListener)
|
||||
```
|
||||
|
||||
|
|
@ -267,10 +353,10 @@ Assertions.assertThrows(NetworkException::class.java) {
|
|||
Lastly verify the session was cleared and session expiration notified:
|
||||
|
||||
```kotlin
|
||||
verify(mockNetworkSessionLocalStorage, times(3)).session
|
||||
verify(mockNetworkSessionLocalStorage, times(1)).session = null
|
||||
verify(mockNetworkSessionLocalStorage, times(3)).session // storage was accessed for the requests
|
||||
verify(mockNetworkSessionLocalStorage, times(1)).session = null // storage was cleared
|
||||
verifyNoMoreInteractions(mockNetworkSessionLocalStorage)
|
||||
verify(mockNetworkSessionExpirationListener, times(1)).onSessionExpired()
|
||||
verify(mockNetworkSessionExpirationListener, times(1)).onSessionExpired() // listener was updated
|
||||
```
|
||||
|
||||
### Conclusion
|
||||
|
|
@ -286,6 +372,6 @@ Now if you wondered around the non-CodeKata Test files, you noticed it uses the
|
|||
|
||||
The basic idea with this is that it contains all the response, request jsons and make it easier to setup sepcific scenarios.
|
||||
|
||||
This wouldn't make sense if we are only testing networking, however if we want to write instrumentation / feature tests as well, there we also need to mock out the requests.
|
||||
This doesn't make sense if we are only testing networking, however if we want to write instrumentation / feature tests as well, there we also need to mock out the requests.
|
||||
|
||||
So this can be a way to do this, but that should be up to you how to handle it.
|
||||
So this can be one way to do mocking the same way for both cases, but that's up to you how to handle this case.
|
||||
|
|
@ -86,6 +86,8 @@ class PlainSessionExpirationTest : KoinTest {
|
|||
Assertions.assertEquals("", refreshRequest.body.readUtf8())
|
||||
|
||||
Assertions.assertEquals("login-access", contentRequestAfterRefreshed.getHeader("Authorization"))
|
||||
val expectedSavedSession = Session(accessToken = "login-access", refreshToken = "login-refresh")
|
||||
verify(mockNetworkSessionLocalStorage, times(1)).session = expectedSavedSession
|
||||
verifyZeroInteractions(mockNetworkSessionExpirationListener)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue