1. Introduction

In software development, testing is essential to ensuring our applications’ quality and reliability. When testing applications that interact with external services through APIs, using real endpoints during testing can be impractical and unreliable. This is where MockServer comes to the rescue! MockServer is a powerful tool that allows us to simulate API endpoints and responses, making it an invaluable asset for testing our applications in isolation.

In this tutorial, we’ll explore leveraging MockServer with Kotest, a popular testing framework for Kotlin, to write robust and comprehensive tests. By the end of the tutorial, we’ll be able to create MockServer instances, define expectations for incoming requests, and verify interactions using Kotest’s expressive testing capabilities.

2. Adding the MockServer Extension

Let’s add the MockServer Kotest extension to our project. This extension for Kotest simplifies the integration process and makes it even easier to work with MockServer. Let’s start by adding it to our pom.xml:

<dependency>
    <groupId>io.kotest.extensions</groupId>
    <artifactId>kotest-extensions-mockserver</artifactId>
    <version>1.2.1</version>
    <scope>test</scope>
</dependency>

Now that we’ve added the MockServer Kotest extension to our project, it’s time to set up MockServer for testing. Let’s start by including MockServerListener() in our test by invoking listener():

class MockServerTest : FunSpec({
    val mockServerListener = MockServerListener(1080)
    listener(mockServerListener)
    // ...
})

By adding this listener, Kotest will control MockServer’s lifecycle. When we run our tests, a MockServer will be available at port 1080 for our tests to use. The listener() function is from Kotest core, and MockServerListener() is a listener implementation that comes from the extension.

3. Preparing a Request

Let’s use a beforeTest() lifecycle hook to prepare a simple mocked /login endpoint:

class MockServerTest : FunSpec({
    val mockServerListener = MockServerListener(1080)
    listener(mockServerListener)

    beforeTest {
        MockServerClient("localhost", 1080).´when´(
            HttpRequest.request()
                  .withMethod("POST")
                  .withPath("/login")
                  .withHeader("Content-Type", "application/json")
                  .withBody("""{"username": "foo", "password": "bar"}""")
            ).respond(
                HttpResponse.response().withStatusCode(202)
            )
    }

    afterTest {
        mockServerListener.close()
    }
})

With this code inside beforeTest(), we’ve created our MockServer and prepared an expected request, to which we’ll respond 202 Accepted. We also need to ensure that the MockServerListener is properly cleaned up after all the tests in this class have executed, by calling the close method on the afterTest function. By setting this up, we’re now ready to perform requests and assertions with the mocked API.

4. Writing a Test Case

After setting MockServer up, let’s demonstrate a call and our expectations by using Apache HttpClient:

class MockServerTest : FunSpec({
    val mockServerListener = MockServerListener(1080)
    listener(mockServerListener)

    beforeTest {
        MockServerClient("localhost", 1080).`when`(
            HttpRequest.request()
                .withMethod("POST")
                .withPath("/login")
                .withHeader("Content-Type", "application/json")
                .withBody("""{"username": "foo", "password": "bar"}""")
        ).respond(HttpResponse.response().withStatusCode(202))
    }

    test("Should return 202 status code") {
        val httpPost = HttpPost("http://localhost:1080/login").apply {
            entity = StringEntity("""{"username": "foo", "password": "bar"}""")
            setHeader("Content-Type", "application/json")
        }

        val response = HttpClients.createDefault().use { it.execute(httpPost) }
        val statusCode = response.statusLine.statusCode
        statusCode shouldBe 202
    }

    afterTest {
        mockServerListener.close()
    }
})

In this test case, we’ve created an HttpPost to the MockServer that we configured to listen at http://localhost:1080. After creating a new request with HttpClients.createDefault(), we can send the request with execute(). Because these requests must be closed to free up resources, we enclose the request in a try-with-resources provided by use().

5. Verifying the HttpRequest

In our test case, we asserted what was returned as the MockServer response. However, there are situations where we may want to verify the request sent by our code. This can be particularly useful when we set up MockServer to respond differently based on various requests. This helps us to ensure that the correct request was made.

Let’s create a test where we do not specify the exact kind of request we expect beforehand, and then, we’ll perform assertions on it:

class UnspecifiedHttpRequestTest : FunSpec({
    val mockServerListener = MockServerListener(1080)
    listener(mockServerListener)

    val client = MockServerClient("localhost", 1080)

    beforeTest {
        client.`when`(
            HttpRequest.request()
        ).respond(
            HttpResponse.response().withStatusCode(202)
        )
    }

    test("Should make a post with correct content") {
        val httpPost = HttpPost("http://localhost:1080/login").apply {
            entity = StringEntity("""{"username": "foo", "password": "bar"}""")
            setHeader("Content-Type", "application/json")
        }

        HttpClients.createDefault().use { it.execute(httpPost) }

        val request = client.retrieveRecordedRequests(null).first()

        request.getHeader("Content-Type") shouldContain "application/json"
        request.bodyAsJsonOrXmlString.replace("\r\n", "\n") shouldBe """{
            |  "username" : "foo",
            |  "password" : "bar"
            |}""".trimMargin()
        request.path.value shouldBe "/login"
    }

    afterTest {
        mockServerListener.close()
    }
})

In this test case, we’ve created an HttpPost request similar to the previous example. However, the difference lies in how we’ve configured our MockServerClient to handle requests. Instead of specifying the exact type of request MockServer should expect, we’ve set it up to accept any HttpRequest.

To validate the request information, we utilize the retrieveRecordedRequests() function of our client. Passing null as an argument to retrieveRecordedRequests() allows us to retrieve all requests made during the test execution. This approach enables us to query the requests and perform assertions using Kotest’s shouldContain() and shouldBe() functions.

By employing this technique, we can ensure that the correct request was made without specifying all the details beforehand. This grants us flexibility and ease of validation while testing, as we can dynamically verify the recorded requests for our HttpPost operation.

6. Conclusion

When used in conjunction with Kotest, MockServer proves to be a powerful and efficient tool for testing applications that interact with external services through APIs. By simulating API endpoints and responses, MockServer allows developers to create isolated and reliable test environments. The integration of the MockServer Kotest extension makes setting up and controlling MockServer within our tests seamless.

Throughout this article, we’ve demonstrated how to leverage MockServer to prepare and define expectations for incoming requests. By employing Kotest’s expressive testing capabilities, we can perform requests and assertions with the mocked API effortlessly. This approach ensures our applications’ robustness and facilitates a smoother testing process by eliminating the need for real endpoints during testing.

By mastering MockServer and Kotest, developers can confidently write comprehensive tests that guarantee the quality and reliability of their software applications, ultimately leading to a more efficient and successful software development process.

As always, the code used in this article is available on GitHub.