1. Overview

In this tutorial, we’ll learn a way to write tests for Ktor controllers. We’ll create a Ktor API to be tested, and we won’t use any database to focus on the tests.

2. Application Setup

Let’s start importing the ktor-server-core, ktor-server-netty, and ktor-serialization-jackson dependencies for our Ktor application in the build.gradle file:

implementation("io.ktor", "ktor-server-core", "2.3.5")
implementation("io.ktor", "ktor-server-netty", "2.3.5")
implementation("io.ktor", "ktor-serialization-jackson", "2.3.5")

Then we can add the ktor-server-tests and kotlin-test-junit testing dependencies:

testImplementation("io.ktor", "ktor-server-tests", "2.3.5")
testImplementation("org.jetbrains.kotlin", "kotlin-test-junit", "1.9.10")

2.1. Content Negotiation

We’ll use Jackson to handle our content serialization. Let’s create an Application extension method:

fun Application.configureContentNegotiation() {
    install(ContentNegotiation) {
        jackson()
    }
}

2.2. Route Configuration

To route the requests for our application, let’s create another extension method:

fun Application.configureRouting() {
    routing {
        route("cars") { } 
    }
}

2.3. Embedded Server

Now, we can use these extensions for our server:

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        configureRouting()
        configureContentNegotiation()
    }.start(wait = true)
}

3. Ktor Controller

Before starting to configure our routes, let’s create a Car class that represents our domain:

data class Car(
    val id: String,
    var brand: String,
    var price: Double
)

As we won’t use any database. Instead, we’ll create a storage mock class:

object CarStorageMock {
    val carStorage = ArrayList<Car>()
}

Now, we can use these classes to code the routes inside the “cars” route:

get {
    call.respond(CarStorageMock.carStorage)
}
get("{id?}") {
    val id = call.parameters["id"]
    val car = CarStorageMock.carStorage.find { it.id == id } ?: return@get call.respondText(
        text = "car.not.found",
        status = HttpStatusCode.NotFound
    )
    call.respond(car)
}
post {
    val car = call.receive<Car>()
    CarStorageMock.carStorage.add(car)
    call.respond(status = HttpStatusCode.Created, message = car)
}
put("{id?}") {
    val id = call.parameters["id"]
    val car = CarStorageMock.carStorage.find { it.id == id } ?: return@put call.respondText(
        text = "car.not.found",
        status = HttpStatusCode.NotFound
    )
    val carUpdate = call.receive<Car>()
    car.brand = carUpdate.brand
    car.price = carUpdate.price
    call.respond(car)
}
delete("{id?}") {
    val id = call.parameters["id"]
    if (CarStorageMock.carStorage.removeIf { it.id == id }) {
        call.respondText(text = "car.deleted", status = HttpStatusCode.OK)
    } else {
        call.respondText(text = "car.not.found", status = HttpStatusCode.NotFound)
    }
}

Even though we’re using a mock for storage, this is a functional server, and it’ll help us create real tests.

4. Test Setup

We’ll use the testApplication() function to create the tests. Since we’re using embeddedServer, we have to add the modules manually. So, let’s start with the “get all” endpoint:

@Test
fun `when get cars then should return a list with cars`() = testApplication {
    application {
        configureRouting()
        configureContentNegotiation()
    }
}

To make the requests for this server, we can create a client and configure it with Jackson as its content negotiator:

val client = createClient {
    install(ContentNegotiation) {
        jackson()
    }
}

Since we’ll be using this pattern  in all tests, let’s create a method to avoid duplicates:

private fun ApplicationTestBuilder.configureServerAndGetClient(): HttpClient {
    application {
        configureRouting()
        configureContentNegotiation()
    }
    val client = createClient {
        install(ContentNegotiation) {
            jackson()
        }
    }
    return client
}

It’s important to clear our mocks before each test:

@Before
fun before() {
    CarStorageMock.carStorage.clear()
}

4.1. Get Cars

To retrieve a list of cars, let’s add a couple of cars to our mock class:

CarStorageMock.carStorage.addAll(
    listOf(
        Car(id = "1", brand = "BMW", price = 10_000.0),
        Car(id = "2", brand = "Audi", price = 11_000.0)
    )
)

Now, we can use the client to create a GET request:

val response = client.get("/cars")

To parse the response body to a List, we need to declare a type for the variable:

val responseBody: List<Car> = response.body()

Finally, we’re able to assert the results. For REST APIs, we should always assert the response status and the response body:

assertEquals(HttpStatusCode.OK, response.status)
assertEquals(2, responseBody.size)

val bmwCar = responseBody.find { it.id == "1" }
assertEquals("BMW", bmwCar?.brand)
assertEquals(10_000.0, bmwCar?.price)

val audiCar = responseBody.find { it.id == "2" }
assertEquals("Audi", audiCar?.brand)
assertEquals(11_000.0, audiCar?.price)

We can use pretty much the same approach to test the “get by id” endpoint:

val response = client.get("/cars/1")
val responseBody: Car = response.body()

assertEquals(HttpStatusCode.OK, response.status)
assertEquals("1", responseBody.id)
assertEquals("BMW", responseBody.brand)

For the “get by id”, we also have to test a request with an id not present in our server:

val response = client.get("/cars/3")
val responseText = response.bodyAsText()

assertEquals(HttpStatusCode.NotFound, response.status)
assertEquals("car.not.found", responseText)

4.2. Create Car

To test the POST request, we have to send a body in our request:

val response = client.post("/cars") {
    contentType(ContentType.Application.Json)
    setBody(Car(id = "2", brand = "Audi", price = 11_000.0))
}

Then, assert the response:

val responseBody: Car = response.body()

assertEquals(HttpStatusCode.Created, response.status)
assertEquals("2", responseBody.id)
assertEquals("Audi", responseBody.brand)
assertEquals(1, CarStorageMock.carStorage.size)

4.3. Update Car

For the PUT method, we have to create a car first, and then we can update it:

CarStorageMock.carStorage.add(Car(id = "1", brand = "BMW", price = 10_000.0))

val response = client.put("/cars/1") {
    contentType(ContentType.Application.Json)
    setBody(Car(id = "1", brand = "Audi", price = 11_000.0))
}
val responseBody: Car = response.body()

assertEquals(HttpStatusCode.OK, response.status)
assertEquals("1", responseBody.id)
assertEquals("Audi", responseBody.brand)
assertEquals("Audi", CarStorageMock.carStorage.find { it.id == "1" }?.brand)

For this functionality, we also have the validation for an invalid id:

val response = client.put("/cars/2") {
    contentType(ContentType.Application.Json)
    setBody(Car(id = "1", brand = "Audi", price = 11_000.0))
}

val responseText = response.bodyAsText()

assertEquals(HttpStatusCode.NotFound, response.status)
assertEquals("car.not.found", responseText)

4.4. Delete Car

The DELETE method also requires an existing car to be deleted:

CarStorageMock.carStorage.add(Car(id = "1", brand = "BMW", price = 10_000.0))

val response = client.delete("/cars/1")
val responseText = response.bodyAsText()

assertEquals(HttpStatusCode.OK, response.status)
assertEquals("car.deleted", responseText)

Then, we can assert both response body and status code:

val response = client.delete("/cars/2")
val responseText = response.bodyAsText()

assertEquals(HttpStatusCode.NotFound, response.status)
assertEquals("car.not.found", responseText)

5. Conclusion

In this article, we created a functional mock and wrote tests for it. It’s important to note that if we implement any database our tests are still valid, of course, we’d need to change all the CarStorageMock access. This approach is an important part of Test-Driven Development.

All the code is available over on GitHub.

Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.