Japanese: 'spreading brightness'. Derived from the words 'hiro', which means 'large or wide', and 'aki',
which means 'bright or clear'.
The intention of Hiroaki is to achieve clarity on your API integration tests in an idiomatic way by leveraging the power of Kotlin.
It uses MockWebServer
to provide a mock server as a target for your HTTP requests that you'll use to mock your backend.
That enables you to assert over how your program reacts to some predefined server & API behaviors.
Add the following code to your build.gradle
. Both dependencies are available in Maven Central.
dependencies{
testImplementation 'me.jorgecastillo:hiroaki-core:0.0.7'
androidTestImplementation 'me.jorgecastillo:hiroaki-android:0.0.7' // Android instrumentation tests
}
To work with Hiroaki you must extend MockServerSuite
on your test class, which takes care of running and shutting
down the server for you. If you can't do that, there's also a JUnit4 Rule
called MockServerRule
with the same goal.
To target the mock server with your requests, you'll need to request the URL from it and pass it to your endpoint creation system / collaborator / entity.
Here you have a plain OkHttp sample.
class GsonNewsNetworkDataSourceTest : MockServerSuite() {
private lateinit var dataSource: GsonNewsNetworkDataSource
@Before
override fun setup() {
super.setup()
val mockServerUrl = server.url("/v2/news")
dataSource = NewsDataSource(mockServerUrl)
}
/*...add tests here!...*/
}
/*Some random data source, probably on a different file*/
class NewsDataSource(var baseUrl: HttpUrl) {
fun getNews(): String? {
val client = OkHttpClient()
val request = Request.Builder()
.url(baseUrl)
.build()
val response = client.newCall(request).execute()
return response.body()?.string()
}
}
If you have an endpoint factory, or even a DI system providing injected endpoints, you'll need to have a good design on your app to pass the mock server url to it. That's on you and is different for every project.
However, Hiroaki provides syntax for waking up mock Retrofit
services in case you need one for writing some unit
tests for your api client / data source as the subject under test.
class GsonNewsNetworkDataSourceTest : MockServerSuite() {
private lateinit var dataSource: GsonNewsNetworkDataSource
@Before
override fun setup() {
super.setup()
// Use server.retrofitService() to build the service targeting the mock URL
dataSource = GsonNewsNetworkDataSource(server.retrofitService(
GsonNewsApiService::class.java,
GsonConverterFactory.create()))
}
/*...*/
}
This will use a default OkHttpClient
instance created for you with basic configuration. For more detailed
configuration, retrofitService()
function offers an optional parameter to pass a custom OkHttpClient
:
val customClient = OkHttpClient.Builder()
.connectTimeout(2, TimeUnit.SECONDS)
.readTimeout(2, TimeUnit.SECONDS)
.writeTimeout(2, TimeUnit.SECONDS)
.build()
dataSource = GsonNewsNetworkDataSource(server.retrofitService(
GsonNewsApiService::class.java,
GsonConverterFactory.create(),
okHttpClient = customClient))
As mentioned before, here you have the alternative JUnit4 rule to avoid using extension if that's your need:
@RunWith(MockitoJUnitRunner::class)
class RuleNetworkDataSourceTest {
private lateinit var dataSource: JacksonNewsNetworkDataSource
@get:Rule val rule: MockServerRule = MockServerRule()
@Before
fun setup() {
dataSource = JacksonNewsNetworkDataSource(rule.server.retrofitService(
JacksonNewsApiService::class.java,
JacksonConverterFactory.create()))
}
@Test
fun sendsGetNews() {
// you'll need to call the server through the rule
rule.server.whenever(GET, "v2/top-headlines")
.thenRespond(success(jsonBody = fileBody("GetNews.json")))
// Can also inline a body or use the json DSL
runBlocking { dataSource.getNews() }
/*...*/
}
}
With Hiroaki, you can mock request responses as if it was mockito:
@Test
fun chainResponses() {
server.whenever(Method.GET, "v2/top-headlines")
.thenRespond(success(jsonBody = fileBody("GetNews.json")))
// Can also inline a body or use the json DSL
val news = runBlocking { dataSource.getNews() }
/*...*/
}
This ensures that whenever the endpoint v2/top-headlines
is called with the given conditions the server will
respond with the mocked response we're providing.
These are all the supported params for whenever
that you can match to. All of them are optional except sentToPath
:
server.whenever(method = Method.GET,
sentToPath = "v2/top-headlines",
queryParams = params("sources" to "crypto-coins-news",
"apiKey" to "a7c816f57c004c49a21bd458e11e2807"),
jsonBody = fileBody("GetNews.json"), // (file, inline, or JsonDSL)
headers = headers("Cache-Control" to "max-age=640000"))
.thenRespond(success(jsonFileName = "GetNews.json"))
Also note in the previous snippets the success()
function when mocking the response. function success()
is a
shortcut to provide a mocked successful response. You can also use error()
and response()
. All of them are mocking
functions that allow you to pass the following optional arguments:
code
Int return http status code for the mocked response.jsonBody
JsonBody, JsonFileBody, Json or JsonArray: json for your mocked response body.headers
Is a Map<String,String> headers to attach to the mocked response.
If you don't want to use the succes()
, error()
or response()
shortcut functions, you can still pass your own
custom MockResponse
.
You can also chain a bunch of mocked responses:
server.whenever(Method.GET, "v2/top-headlines")
.thenRespond(success(jsonBody = fileBody("GetNews.json")))
.thenRespond(success(jsonBody = fileBody("GetSingleNew.json")))
.thenRespond(success(jsonBody = fileBody("GetNews.json")))
Each time the endpoint is called under the given conditions, the server will return the next mocked response from the list, following the same order.
Sometimes you want a response to depend on the request sent. For that reason, Hiroaki provides the thenDispatch
method:
server.whenever(Method.GET, "v2/top-headlines")
.thenDispatch { request -> success(jsonBody = inlineBody("{\"requestPath\" : ${request.path}}")) }
You can combine as many thenRespond()
and thenDispatch()
calls as you want.
server.whenever(Method.GET, "v2/top-headlines")
.thenRespond(success())
.thenDispatch { request -> success(jsonBody = inlineBody("{\"requestPath\" : ${request.path}}")) }
.thenRespond(error())
Mimic server response delays with delay()
, an extension function for MockResponse
to pass a delay in
millis: response.delay(millis)
:
server.whenever(Method.GET, "v2/top-headlines")
.thenRespond(success(jsonBody = fileBody("GetNews.json")).delay(250))
.thenRespond(success(jsonBody = fileBody("GetSingleNew.json")).delay(500))
.thenRespond(success(jsonBody = fileBody("GetNews.json")).delay(1000))
// Also for dispatched responses
server.whenever(Method.GET, "v2/top-headlines")
.thenDispatch { request -> success().delay(250) }
Sometimes you want to emulate bad network conditions, so you can throttle your response body like:
server.whenever(GET, "v2/top-headlines").thenRespond(error().throttle(64, 1000))
Here, you are asking the server to throttle and write chunks of 64 bytes per second (1000 millis).
Hiroaki provides a highly configurable verify()
function to perform verification over executed HTTP requests.
Its arguments are optional so you're free to configure the assertion in a way that matches your needs.
@Test
fun verifiesCall() {
server.whenever(Method.GET, "v2/top-headlines")
.thenRespond(success(jsonBody = fileBody("GetNews.json")))
.thenRespond(success(jsonBody = fileBody("GetSingleNew.json")))
.thenRespond(success(jsonBody = fileBody("GetNews.json")))
runBlocking {
dataSource.getNews()
dataSource.getSingleNew()
dataSource.getNews()
}
server.verify("v2/top-headlines").called(
times = times(2),
order = order(1, 3),
method = Method.POST,
headers = headers("Cache-Control" to "max-age=640000"),
queryParams = params(
"sources" to "crypto-coins-news",
"apiKey" to "a7c816f57c004c49a21bd458e11e2807"),
jsonBody = inlineBody("{\n" +
" \"title\": \"Any Title\",\n" +
" \"description\": \"Any description\",\n" +
" \"source\": {\n" +
" \"link\": \"http://source/123\",\n" +
" \"name\": \"Some source\"\n" +
" }\n" +
"}\n"))
}
You can use the functions never()
, once()
, twice()
, times(num)
, atLeast
, and atMost
for the times param.
After any test that requests data from network you'll probably need to assert over the parsed response to double check whether the data was received and parsed properly.
@Test
fun parsesNewsProperly() {
server.enqueueSuccessResponse("GetNews.json")
val news = runBlocking { dataSource.getNews() }
news eq expectedNews() // eq is an infix function for assertEquals()
}
eq
is just an infix
function to run assertEquals
on both objects. Here we are building the list of expected
objects with the function expectedNews()
. The objects are being compared using the equals
operator so you
better use data classes for DTOs or redefine equals
properly.
Extend AndroidMockServerSuite
or use AndroidMockServerRule
instead.
Basic sample of Android instrumentation tests:
@LargeTest
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest : AndroidMockServerSuite() {
@get:Rule val testRule: ActivityTestRule<MainActivity> = ActivityTestRule(MainActivity::class.java, true, false)
@Before
override fun setup() {
super.setup()
val mockService = server.retrofitService(
MoshiNewsApiService::class.java,
MoshiConverterFactory.create())
getApp().service = mockService
}
private fun startActivity(): MainActivity {
return testRule.launchActivity(Intent())
}
@Test
fun showsEmptyCaseIfThereAreNoSuperHeroes() {
server.whenever(GET, "v2/top-headlines")
.thenRespond(success(jsonBody = fileBody("GetNews.json")))
startActivity()
onView(withText(expectedNews()[0].title)).check(matches(isDisplayed()))
onView(withText(expectedNews()[0].description)).check(matches(isDisplayed()))
}
}
I'm being intentionally simple here on how I pass the mocked service to the application class (setup method), which is being replaced by a mock application on the androidTest environment. But you would use a dependency injector/container to replace the service most likely.
Important: Json Body files location:
For Android instrumentation tests you'll need to put your json body files into androidTest/assets/
folder. That's due
to how android loads resources.
Using call verification on Android instrumentation tests can also be a good idea, so you are able to assert that the endpoints are called as expected (including optional times / ordering) per screen.
Anywhere where Hiroaki requests a JsonBody
from you (matchers, assertions, wherever), you can use 3 options:
fileBody("Filename.json")
: To pass a json from a file resource (/test/resources
orandroidTest/assets
)inlineBody("{...}")
: To pass an inlined body.JsonDSL
: A fancy DSL to create your inlined json bodies in a very idiomatic way. Some examples:
json {
"status" / "ok"
"totalResults" / 2342
"articles" / jsonArray(json {
"source" / json {
"id" / request.path.length
"name" / "Lifehacker.com"
}
"author" / "Jacob Kleinman"
"title" / "How to Get Android P's Screenshot Editing Tool on Any Android Phone"
"description" / "Last year, Apple brought advanced screenshot editing tools to the iPhone with iOS 11, and, this week, Google fired back with a similar Android feature called Markup. The only catch is that this new tool is limited to Android P, which launches later this year …"
"url" / "https://lifehacker.com/how-to-get-android-ps-screenshot-editing-tool-on-any-an-1823646122"
"urlToImage" / "https://i.kinja-img.com/gawker-media/image/upload/s--Y-5X_NcT--/c_fill,fl_progressive,g_center,h_450,q_80,w_800/nxmwbkwzoc1z1tmak7s4.jpg"
"publishedAt" / "2018-03-09T20:30:00Z"
})
}
jsonArray("Something", "More stuff", "Something more"))
jsonArray(
json {
"status" / "ok"
"title" / "How to Get Android P's Screenshot Editing Tool on Any Android Phone"
"ids" / jsonArray(1, 2, 3)
},
json {
"status" / "ok"
"title" / "How to Get Android P's Screenshot Editing Tool on Any Android Phone"
"ids" / jsonArray(1, 2, 3)
},
json {
"status" / "ok"
"title" / "How to Get Android P's Screenshot Editing Tool on Any Android Phone"
"ids" / jsonArray(1, 2, 3)
})
You can combine jsonArray{}
and json{}
blocks arbitrarily. Hiroaki will create a properly formatted json for you.
Also feel free to use jsonArray
as the root node for your json if you need to.
server.whenever(Method.GET, "my-fake-service/1")
.thenRespond(success(jsonBody = jsonArray(1, 2, 3)))
server.whenever(Method.GET, "my-fake-service/1")
.thenRespond(success(jsonBody =
json {
"status" / "ok"
"totalResults" / 2342
"articles" / jsonArray(json {
"source" / json {
"id" / request.path.length
"name" / "Lifehacker.com"
}
"author" / "Jacob Kleinman"
"title" / "How to Get Android P's Screenshot Editing Tool on Any Android Phone"
"description" / "Last year, Apple brought advanced screenshot editing tools to the iPhone with iOS 11, and, this week, Google fired back with a similar Android feature called Markup. The only catch is that this new tool is limited to Android P, which launches later this year …"
"url" / "https://lifehacker.com/how-to-get-android-ps-screenshot-editing-tool-on-any-an-1823646122"
"urlToImage" / "https://i.kinja-img.com/gawker-media/image/upload/s--Y-5X_NcT--/c_fill,fl_progressive,g_center,h_450,q_80,w_800/nxmwbkwzoc1z1tmak7s4.jpg"
"publishedAt" / "2018-03-09T20:30:00Z"
})
}))
I would love to get contributions from anybody. So if you feel that the library is lacking any features you consider key, please open an issue asking for it or a pull request providing an implementation for it.
The library is using CircleCI 2.0 to enforce passing tests and code style quality.
Any PR's must pass CI and that includes code style. Run the following commands to check code style or
automatically format it. (You can use the graddle wrapper (gradlew
) instead)
// check code style
gradle app:ktlint
gradle hiroaki-core:ktlint
gradle hiroaki-android:ktlint
// autoformat
gradle app:ktlintFormat
gradle hiroaki-core:ktlintFormat
gradle hiroaki-android:ktlintFormat
Tests are also required to pass. You can run them like:
gradle test
Copyright 2018 Jorge Castillo Pérez
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.