Creating testing-friendly Kotlin services with http4k

Ivan Sanchez
Google Developer Experts
3 min readJan 9, 2023

--

Re-configurable electric contraption (Photo by John Barkiple via Unplash)

Whether you’re practicing Test-Driven-Development or just creating any kind of test automation around your service APIs, you can benefit from code that makes life easier for testing.

Let’s check how http4k uses the simplicity of Kotlin to make services easy to test without compromising on the developer experience.

You can get a new http4k project setup from scratch is using the http4k Project Wizard (or skip that step completely and dowload a simple Hello World project here)

Alternatively, all just need to add the http4k-core dependency to your project:

implementation("org.http4k:http4k-core:4.35.3.0")

Now, back to creating a service. Here’s all you need to get started:

val service = { request: Request -> Response(OK).body("Hello World") }

Yes, that’s something you can already use for testing. It looks like a simple function, because it is just that: a server as a function.

What does invoking this server look like? Well — as our server is a standard Kotlin function, it just returns the constructed response:

val response = service(Request(GET, "/"))

assertThat(response.status, equalsTo(Status.OK)
assertThat(response.bodyString(), equalsTo("Hello World"))

This is the foundation of http4k:

A server is a function, and you can just use it as such.

To achieve that, http4k takes advantage of Kotlin’s type aliases (HttpHandler is an alias for (Request) -> Response). Also, both request/response are data classes, which are immutable and easy to create and modify.

By now, you’re problably asking yourself: that doesn’t look like something one could use in production, where we want real HTTP clients accessing the service, which will be running somewhere and accessible only via network on a given port. Where’s all that?

To answer that, let’s take our service and make it work over the network:

service.asServer(SunHttp(8000)).start() //starts a server on port 8000
val client = JavaHttpClient()

val response = client(Request(GET, "http://localhost:9000"))

assertThat(response.status, equalsTo(Status.OK)
assertThat(response.bodyString(), equalsTo("Hello World"))

In this case, our server is running as a real HTTP server using http4k’s .asServer extension function and SunHttp as the choice of real server implementation. It uses code available in the JDK so there’s no extra dependency, but http4k also offers other options such as Jetty, Netty, Undertow and even Ktor.

We also introduce an HTTP client, JavaHttpClient, which is also using code built into the JDK. And once again, using a single line of code we can swap that several others including OkHttp and Apache.

This is a common theme behind a lot of http4k’s 50 or so integration modules — it provides a consistent shim over these libraries, allowing the API user to mix and match which ones they are familiar with, whilst integrating seamlessly with a small set of foundational concepts.

As for testing, the biggest breakthrough in http4k is that both clients and servers use the same function definition (the type alias mentioned above):

typealias HttpHandler = (Request) -> Response

That makes clients and servers interchangeable for testing purposes. For example, you can replace the client with the server definition in test above without touching your assertion:

val service = { request: Request -> Response(OK).body("Hello World") }
val client = service

val response = client(Request(GET, "http://localhost:9000")) //uri is irrelevant

assertThat(response.status, equalsTo(Status.OK)
assertThat(response.bodyString(), equalsTo("Hello World"))

This brings some major benefits:

  1. Creating testing data is simple as request and responses are just data classes.
  2. Tests run an order of magnitude faster as they skip the network completely, and are unaffected by port clashes.
  3. There is no separate test infrastructure required for testing. Service setup in tests is identical to production code.
  4. Multiple services can be wired to each other by replacing clients with server implementations. That means tests can cover a whole system as function.
  5. You can reuse test and run the same test in-memory, against servers running on the local machine, or even against a full-blown deployed environment.

The examples above don’t go into details such as endpoint routing, middleware (filters), or handling complex, type-safe request/response bodies. All of those features are available and built on top of the same function definition and without any special reflection or annotation mechanism.

That means the same testability you see above can be achieved no matter how complex you service is.

--

--