1. 概述

本文我们学习如何为 Ktor 控制器编写测试。我们会创建一个用于测试的 Ktor API,不涉及数据库,专注于测试逻辑本身。

2. 项目搭建

首先在 build.gradle 文件中添加 Ktor 的核心依赖:

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

然后添加测试所需的依赖:

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

2.1. 内容协商配置

我们使用 Jackson 实现内容序列化。创建一个 Application 的扩展方法:

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

2.2. 路由配置

为应用添加路由配置:

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

2.3. 嵌入式服务器启动

启动 Ktor 嵌入式服务器:

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

3. Ktor 控制器实现

定义 Car 类作为领域模型:

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

使用一个模拟的存储类代替数据库:

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

在路由中实现 CRUD 操作:

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)
    }
}

4. 测试环境搭建

我们使用 testApplication() 方法进行测试,并手动配置模块。

创建一个通用方法来初始化客户端:

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

每次测试前清空模拟数据:

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

4.1. GET 请求测试

获取所有车辆列表

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

val response = client.get("/cars")
val responseBody: List<Car> = 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)

根据 ID 获取车辆

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

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

获取不存在的 ID

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

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

4.2. POST 请求测试

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

val responseBody: Car = response.body()

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

4.3. PUT 请求测试

正常更新

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("Audi", responseBody.brand)

更新不存在的 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 请求测试

删除存在的车辆

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)

删除不存在的车辆

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

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

5. 总结

本文我们搭建了一个简单的 Ktor 服务并为其控制器编写了完整的测试用例。即使将来引入数据库,测试逻辑依然有效,只需替换 CarStorageMock 的实现即可。

所有代码可在 GitHub 上找到。✅


原始标题:Testing Ktor Controllers