Integration tests are easier when you use a mock server. There’s a lot of mock servers available out there. The most popular one that I know of and I’ve worked with is WireMock. It does a lot of things correctly and I ‘d be comfortable in recommending it every time you need a mock server. An added bonus is that it has an integration with Spring Boot.
If you’re looking for something else to consider, I’ve stumbled upon MockWebServer recently. Its simplicity is very surprising.
Using MockWebServer
I had to implement a mock server to replace the server stubs used in the integration tests of one of our services. Someone suggested MockWebServer
based on its usage in Spring codebase. I went ahead and used it.
We can use MockWebServer
as is or we can use a library from fabric8 that wraps MockWebServer
and gives us a nice DSL. Or do as I’ve done and create our own simple wrapper around MockWebServer
.
We will be using Spring Boot and Spock for this example. We’ll be using RestTemplate
to call external services. Although, you should start looking at WebClient
instead.
Simple DSL
If you haven’t read the documentation of MockWebServer,
please go ahead and read it now. It’s very simple and straightforward.
We’ll try to emulate a smaller scope of what fabric8 created. The server will respond as long as there is a request. And the DSL will be server.expect().withPath().andReturn()
.
We will be using Spock as our test framework. Thus, the following (test) code will all be using Groovy.
server
Of course, we will always start with the mock server itself. As such we’ll create a class that wraps MockWebserver
:
class DefaultMockServer {
private final MockWebServer server
}
It does not look like it will be useful to us at this point so let’s get on with the next part.
expect()
Our main goal for using a mock server is to get responses from mocked endpoints. For our purpose we will call these endpoints as expectations. We can model these as MockServerExpectation
.
Since our server will host this expectations we can create expect in DefaultMockServer
. It will return an expectation that we can manipulate later on:
class DefaultMockServer {
...
private final List<MockServerExpectation> expectations = []
MockServerExpectation expect() {
def expectation = new MockServerExpectation()
this.expectations.add(expectation)
return expectation
}
}
class MockServerExpectation {}
withPath()
Now that we are able to create expectations we can now configure them. Let’s start with the path of the endpoint:
class MockServerExpectation {
String path
MockServerExpectation withPath(String path) {
this.path = path
return this
}
}
Nice! But this isn’t still useful if we are not returning anything. Let’s take care of that.
andReturn()
With MockWebServer
, we will return a MockResponse
that a Dispatcher
will send back.
That’s a lot of concepts to take in. Let’s focus on configuring the response first. All we need is a status and an object that we will transform into JSON
.
class MockServerExpectation {
...
MockResponse response
...
MockServerExpectation andReturn(int statusCode, Object response) {
this.response = new MockResponse().setResponseCode(statusCode)
.setBody(JsonOutput.toJson(response))
.addHeader("Content-Type", "application/json; charset=utf-8")
.addHeader("Cache-Control", "no-cache")
return this
}
}
We are now able to configure an endpoint’s main parts: path and response.
We need to have a way for our mock server to know which expectation will respond to an incoming request. That is the role of a Dispatcher
.
Dispatcher
We’ve setup our endpoints and now when we start our server it should start listening to any request and return an appropriate response.
With MockWebServer
, the one responsible in relaying the request to the correct response is the Dispatcher
. The Dispatcher
will take a RecordedRequest
and then return a MockResponse
based from that.
Let’s implement that and also take care of requests for endpoints that don’t exist:
class DefaultMockServer {
private final MockWebServer server = new MockWebServer()
...
void start(int port) {
server.setDispatcher(new Dispatcher() {
@Override
MockResponse dispatch(@NotNull RecordedRequest recordedRequest) throws InterruptedException {
return new MockResponse().setResponseCode(404)
}
})
server.start(port)
}
}
We can use DefaultMockServer
in its current state and it will always return the same 404 response. Since we will be calling two different external services, we need two different instances of MockWebServer
listening on different ports. Hence, the port
argument of start
.
To make it more useful we can use RecordedRequest
to dispatch the request to the correct MockServerExpectation
. To make our code more organised and simpler we will create our own request object called MockServerRequest
:
class MockServerRequest {
String path
MockServerRequest(RecordedRequest recordedRequest) {
this.path = recordedRequest.path
}
boolean matches(String path) {
return this.path.matches(path)
}
}
class DefaultMockServer {
...
private final List<MockServerExpectation> expectations = []
...
void start(int port) {
server.setDispatcher(new Dispatcher() {
@Override
MockResponse dispatch(@NotNull RecordedRequest recordedRequest) throws InterruptedException {
def mockRequest = new MockServerRequest(recordedRequest)
def expectation = expectations.find { mockRequest.matches(it.path) }
return expectation ? expectation.response : new MockResponse().setResponseCode(404)
}
})
server.start(port)
}
}
Our current implementation is very simplistic. We can extend this to have its own representation of the query parameters and request body if we like. I’ve been able to do it, but I’ll leave it for you to try.
Integration Test
Our MockWebServer
wrapper is now ready to be used. Note that the ports we are using are hardcoded in the services so make sure you are using the correct ports.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CheckoutControllerPurchaseSpec extends Specification {
@Autowired
CheckoutController controller
List<DefaultMockServer> servers = []
def setup() {
setupCartService()
setupPurchaseService()
}
def "should be successful checkout"() {
when:
def response = controller.purchase(123, 234)
then:
response.statusCode.value() == 200
response.getBody() instanceof CheckoutResponse
CheckoutResponse checkoutResponse = (CheckoutResponse) response.body
checkoutResponse.amount == 100
checkoutResponse.successful
}
void setupCartService() {
DefaultMockServer server = new DefaultMockServer()
server.expect().withPath("/cart/\\d+/total")
.andReturn(200, new CartTotal(1L, BigDecimal.valueOf(100)))
server.start(5001)
servers.add(server)
}
void setupPurchaseService() {
DefaultMockServer server = new DefaultMockServer()
server.expect().withPath("/purchase/\\d+/\\d+")
.andReturn(200, new PurchaseAttempt(1, true))
server.start(5002)
servers.add(server)
}
def cleanup() {
servers.each { it.shutdown() }
}
}
Conclusion
We’ve created a very basic DSL wrapper of MockWebServer
which we can easily extend to handle more complex use cases. This is all possible because it is very simple and lightweight.
It’s good that we can rely on tools we’re familiar with, but we should never forget to try other tools that might just be enough for our use case.