Ktor 서버에서 테스트하기
필수 의존성: io.ktor:ktor-server-test-host
, org.jetbrains.kotlin:kotlin-test
Ktor는 웹 서버를 생성하거나 소켓에 바인딩하거나 실제 HTTP 요청을 보내지 않는 특별한 테스트 엔진을 제공합니다. 대신, 내부 메커니즘에 직접 연결하여 애플리케이션 호출을 직접 처리합니다. 이는 테스트를 위해 완전한 웹 서버를 실행하는 것보다 더 빠른 테스트 실행을 가능하게 합니다.
의존성 추가
Ktor 서버 애플리케이션을 테스트하려면 빌드 스크립트에 다음 아티팩트를 포함해야 합니다.
ktor-server-test-host
의존성 추가:KotlinGroovyXML테스트에서 어설션(assertion)을 수행하기 위한 유틸리티 함수 세트를 제공하는
kotlin-test
의존성 추가:KotlinGroovyXML
네이티브 서버를 테스트하려면
nativeTest
소스 세트에 테스트 아티팩트를 추가하세요.
테스트 개요
테스트 엔진을 사용하려면 아래 단계를 따르세요:
- JUnit 테스트 클래스와 테스트 함수를 생성합니다.
- testApplication 함수를 사용하여 로컬에서 실행되는 구성된 테스트 애플리케이션 인스턴스를 설정합니다.
- 테스트 애플리케이션 내에서 Ktor HTTP 클라이언트 인스턴스를 사용하여 서버에 요청을 보내고, 응답을 받고, 어설션(assertion)을 수행합니다.
아래 코드는 /
경로로 전송된 GET 요청을 수락하고 일반 텍스트 응답을 반환하는 가장 간단한 Ktor 애플리케이션을 테스트하는 방법을 보여줍니다.
package com.example
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.testing.*
import kotlin.test.*
class ApplicationTest {
@Test
fun testRoot() = testApplication {
application {
module()
}
val response = client.get("/")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("Hello, world!", response.bodyAsText())
}
}
package com.example
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)
fun Application.module() {
routing {
get("/") {
call.respondText("Hello, world!")
}
}
}
실행 가능한 코드 예제는 다음에서 확인할 수 있습니다: engine-main.
애플리케이션 테스트
1단계: 테스트 애플리케이션 구성
테스트 애플리케이션 구성에는 다음 단계가 포함될 수 있습니다.
기본적으로 구성된 테스트 애플리케이션은 첫 번째 클라이언트 호출 시 시작됩니다. 선택적으로
startApplication
함수를 호출하여 애플리케이션을 수동으로 시작할 수 있습니다. 이는 애플리케이션의 라이프사이클 이벤트를 테스트해야 할 때 유용할 수 있습니다.
애플리케이션 모듈 추가
애플리케이션을 테스트하려면 해당 모듈이 testApplication
에 로드되어야 합니다. 이를 위해 명시적으로 모듈을 로드하거나, 구성 파일에서 로드하도록 환경을 구성해야 합니다.
모듈 명시적 로드
테스트 애플리케이션에 모듈을 수동으로 추가하려면 application
함수를 사용하세요:
package com.example
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.*
class ApplicationTest {
@Test
fun testModule1() = testApplication {
application {
module1()
module2()
}
val response = client.get("/module1")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("Hello from 'module1'!", response.bodyAsText())
}
}
구성 파일에서 모듈 로드
구성 파일에서 모듈을 로드하려면 environment
함수를 사용하여 테스트를 위한 구성 파일을 지정하세요:
@Test
fun testHello() = testApplication {
environment {
config = ApplicationConfig("application-custom.conf")
}
}
이 방법은 테스트 중에 다른 환경을 모방하거나 사용자 정의 구성 설정을 사용해야 할 때 유용합니다.
application
블록 내에서Application
인스턴스에 접근할 수도 있습니다.
라우트 추가
routing
함수를 사용하여 테스트 애플리케이션에 라우트를 추가할 수 있습니다. 이는 다음 사용 사례에서 편리할 수 있습니다.
테스트 애플리케이션에서만 필요한 라우트를 추가할 수 있습니다. 아래 예시는 테스트에서 사용자 세션을 초기화하는 데 사용되는
/login-test
엔드포인트를 추가하는 방법을 보여줍니다.kotlinfun testHello() = testApplication { routing { get("/login-test") { call.sessions.set(UserSession("xyzABC123","abc123")) } } }
전체 예제와 테스트는 다음에서 확인할 수 있습니다: auth-oauth-google.
환경 사용자 정의
테스트 애플리케이션을 위한 사용자 정의 환경을 구축하려면 environment
함수를 사용하세요. 예를 들어, 테스트를 위한 사용자 정의 구성을 사용하려면 test/resources
폴더에 구성 파일을 생성하고 config
속성을 사용하여 로드할 수 있습니다.
@Test
fun testHello() = testApplication {
environment {
config = ApplicationConfig("application-custom.conf")
}
}
구성 속성을 지정하는 또 다른 방법은 MapApplicationConfig를 사용하는 것입니다. 이는 애플리케이션이 시작되기 전에 애플리케이션 구성에 접근하려는 경우 유용할 수 있습니다. 아래 예시는 config
속성을 사용하여 MapApplicationConfig
를 testApplication
함수에 전달하는 방법을 보여줍니다.
@Test
fun testDevEnvironment() = testApplication {
environment {
config = MapApplicationConfig("ktor.environment" to "dev")
}
}
외부 서비스 모의(Mock)
Ktor는 externalServices
함수를 사용하여 외부 서비스를 모의(Mock)할 수 있도록 합니다. 이 함수 내에서 두 개의 파라미터를 받는 hosts
함수를 호출해야 합니다.
hosts
파라미터는 외부 서비스의 URL을 받습니다.block
파라미터는 외부 서비스의 모의 역할을 하는Application
을 구성할 수 있도록 합니다. 이Application
에 대한 라우팅을 구성하고 플러그인을 설치할 수 있습니다.
아래 샘플은 externalServices
를 사용하여 Google API에서 반환된 JSON 응답을 시뮬레이션하는 방법을 보여줍니다.
fun testHello() = testApplication {
externalServices {
hosts("https://www.googleapis.com") {
install(io.ktor.server.plugins.contentnegotiation.ContentNegotiation) {
json()
}
routing {
get("oauth2/v2/userinfo") {
call.respond(UserInfo("1", "JetBrains", "", ""))
}
}
}
}
}
전체 예제와 테스트는 다음에서 확인할 수 있습니다: auth-oauth-google.
2단계: (선택 사항) 클라이언트 구성
testApplication
은 client
속성을 사용하여 기본 구성이 적용된 HTTP 클라이언트에 대한 접근을 제공합니다. 클라이언트를 사용자 정의하고 추가 플러그인을 설치해야 하는 경우 createClient
함수를 사용할 수 있습니다. 예를 들어, 테스트 POST/PUT 요청에서 JSON 데이터를 보내려면 ContentNegotiation 플러그인을 설치할 수 있습니다.
@Test
fun testPostCustomer() = testApplication {
application {
main()
}
client = createClient {
install(ContentNegotiation) {
json()
}
}
}
3단계: 요청하기
애플리케이션을 테스트하려면 구성된 클라이언트를 사용하여 요청을 보내고 응답을 받으세요. 아래 예제는 POST
요청을 처리하는 /customer
엔드포인트를 테스트하는 방법을 보여줍니다.
@Test
fun testPostCustomer() = testApplication {
application {
main()
}
client = createClient {
install(ContentNegotiation) {
json()
}
}
val response = client.post("/customer") {
contentType(ContentType.Application.Json)
setBody(Customer(3, "Jet", "Brains"))
}
}
4단계: 결과 어설션(Assert)
응답을 받은 후, kotlin.test 라이브러리에서 제공하는 어설션(assertion)을 사용하여 결과를 확인할 수 있습니다.
@Test
fun testPostCustomer() = testApplication {
application {
main()
}
client = createClient {
install(ContentNegotiation) {
json()
}
}
val response = client.post("/customer") {
contentType(ContentType.Application.Json)
setBody(Customer(3, "Jet", "Brains"))
}
assertEquals("Customer stored correctly", response.bodyAsText())
assertEquals(HttpStatusCode.Created, response.status)
}
}
POST/PUT 요청 테스트
폼 데이터 전송
테스트 POST/PUT 요청에서 폼 데이터를 보내려면 Content-Type
헤더를 설정하고 요청 본문을 지정해야 합니다. 이를 위해 각각 header 및 setBody 함수를 사용할 수 있습니다. 아래 예시는 x-www-form-urlencoded
와 multipart/form-data
타입 모두를 사용하여 폼 데이터를 보내는 방법을 보여줍니다.
x-www-form-urlencoded
post-form-parameters 예제의 아래 테스트는 x-www-form-urlencoded
콘텐츠 타입을 사용하여 전송된 폼 파라미터로 테스트 요청을 만드는 방법을 보여줍니다. 키/값 쌍 목록에서 폼 파라미터를 인코딩하는 데 formUrlEncode 함수가 사용된다는 점에 유의하세요.
package formparameters
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.*
class ApplicationTest {
@Test
fun testPost() = testApplication {
application {
main()
}
val response = client.post("/signup") {
header(HttpHeaders.ContentType, ContentType.Application.FormUrlEncoded.toString())
setBody(listOf("username" to "JetBrains", "email" to "[email protected]", "password" to "foobar", "confirmation" to "foobar").formUrlEncode())
}
assertEquals("The 'JetBrains' account is created", response.bodyAsText())
}
}
import io.ktor.server.application.*
import io.ktor.server.html.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.html.*
fun Application.main() {
routing {
post("/signup") {
val formParameters = call.receiveParameters()
val username = formParameters["username"].toString()
call.respondText("The '$username' account is created")
}
}
}
multipart/form-data
아래 코드는 multipart/form-data
를 구축하고 파일 업로드를 테스트하는 방법을 보여줍니다. 전체 예제는 다음에서 확인할 수 있습니다: upload-file.
package uploadfile
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import org.junit.*
import java.io.*
import kotlin.test.*
import kotlin.test.Test
class ApplicationTest {
@Test
fun testUpload() = testApplication {
application {
main()
}
val boundary = "WebAppBoundary"
val response = client.post("/upload") {
setBody(
MultiPartFormDataContent(
formData {
append("description", "Ktor logo")
append("image", File("ktor_logo.png").readBytes().toString(), Headers.build {
append(HttpHeaders.ContentType, "image/png")
append(HttpHeaders.ContentDisposition, "filename=\"ktor_logo.png\"")
})
},
boundary,
ContentType.MultiPart.FormData.withParameter("boundary", boundary)
)
)
}
assertEquals("Ktor logo is uploaded to 'uploads/ktor_logo.png'", response.bodyAsText(Charsets.UTF_8))
}
@After
fun deleteUploadedFile() {
File("uploads/ktor_logo.png").delete()
}
}
package uploadfile
import io.ktor.server.application.*
import io.ktor.http.content.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.cio.*
import io.ktor.utils.io.*
import java.io.File
fun Application.main() {
routing {
post("/upload") {
var fileDescription = ""
var fileName = ""
val multipartData = call.receiveMultipart(formFieldLimit = 1024 * 1024 * 100)
multipartData.forEachPart { part ->
when (part) {
is PartData.FormItem -> {
fileDescription = part.value
}
is PartData.FileItem -> {
fileName = part.originalFileName as String
val file = File("uploads/$fileName")
part.provider().copyAndClose(file.writeChannel())
}
else -> {}
}
part.dispose()
}
call.respondText("$fileDescription is uploaded to 'uploads/$fileName'")
}
}
}
JSON 데이터 전송
테스트 POST/PUT 요청에서 JSON 데이터를 보내려면, 새로운 클라이언트를 생성하고 특정 형식으로 콘텐츠를 직렬화/역직렬화할 수 있도록 해주는 ContentNegotiation 플러그인을 설치해야 합니다. 요청 내부에서는 contentType
함수를 사용하여 Content-Type
헤더를 지정하고, setBody를 사용하여 요청 본문을 지정할 수 있습니다. 아래 예제는 POST
요청을 처리하는 /customer
엔드포인트를 테스트하는 방법을 보여줍니다.
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.testing.*
import kotlin.test.*
class CustomerTests {
@Test
fun testPostCustomer() = testApplication {
application {
main()
}
client = createClient {
install(ContentNegotiation) {
json()
}
}
val response = client.post("/customer") {
contentType(ContentType.Application.Json)
setBody(Customer(3, "Jet", "Brains"))
}
assertEquals("Customer stored correctly", response.bodyAsText())
assertEquals(HttpStatusCode.Created, response.status)
}
}
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import io.ktor.server.util.getValue
@Serializable
data class Customer(val id: Int, val firstName: String, val lastName: String)
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
})
}
post("/customer") {
val customer = call.receive<Customer>()
customerStorage.add(customer)
call.respondText("Customer stored correctly", status = HttpStatusCode.Created)
}
}
테스트 중 쿠키 유지
테스트 시 요청 간에 쿠키를 유지해야 하는 경우, 새 클라이언트를 생성하고 HttpCookies 플러그인을 설치해야 합니다. session-cookie-client 예제의 아래 테스트에서는 쿠키가 유지되므로 각 요청 후 재로드 횟수가 증가합니다.
package cookieclient
import io.ktor.client.plugins.cookies.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.server.testing.*
import kotlin.test.*
class ApplicationTest {
@Test
fun testRequests() = testApplication {
application {
main()
}
val client = createClient {
install(HttpCookies)
}
val loginResponse = client.get("/login")
val response1 = client.get("/user")
assertEquals("Session ID is 123abc. Reload count is 1.", response1.bodyAsText())
val response2 = client.get("/user")
assertEquals("Session ID is 123abc. Reload count is 2.", response2.bodyAsText())
val response3 = client.get("/user")
assertEquals("Session ID is 123abc. Reload count is 3.", response3.bodyAsText())
val logoutResponse = client.get("/logout")
assertEquals("Session doesn't exist or is expired.", logoutResponse.bodyAsText())
}
}
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import io.ktor.util.*
import kotlinx.serialization.Serializable
@Serializable
data class UserSession(val id: String, val count: Int)
fun Application.main() {
install(Sessions) {
val secretEncryptKey = hex("00112233445566778899aabbccddeeff")
val secretSignKey = hex("6819b57a326945c1968f45236589")
cookie<UserSession>("user_session") {
cookie.path = "/"
cookie.maxAgeInSeconds = 10
transform(SessionTransportTransformerEncrypt(secretEncryptKey, secretSignKey))
}
}
routing {
get("/login") {
call.sessions.set(UserSession(id = "123abc", count = 0))
call.respondRedirect("/user")
}
get("/user") {
val userSession = call.sessions.get<UserSession>()
if (userSession != null) {
call.sessions.set(userSession.copy(count = userSession.count + 1))
call.respondText("Session ID is ${userSession.id}. Reload count is ${userSession.count}.")
} else {
call.respondText("Session doesn't exist or is expired.")
}
}
HTTPS 테스트
HTTPS 엔드포인트를 테스트해야 하는 경우, URLBuilder.protocol 속성을 사용하여 요청을 만드는 데 사용되는 프로토콜을 변경하세요:
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.*
class ApplicationTest {
@Test
fun testRoot() = testApplication {
application {
module()
}
val response = client.get("/") {
url {
protocol = URLProtocol.HTTPS
}
}
assertEquals("Hello, world!", response.bodyAsText())
}
}
전체 예제는 다음에서 확인할 수 있습니다: ssl-engine-main.
WebSocket 테스트
클라이언트가 제공하는 WebSockets 플러그인을 사용하여 WebSocket 대화를 테스트할 수 있습니다.
package com.example
import io.ktor.client.plugins.websocket.*
import io.ktor.websocket.*
import io.ktor.server.testing.*
import kotlin.test.*
class ModuleTest {
@Test
fun testConversation() {
testApplication {
application {
module()
}
val client = createClient {
install(WebSockets)
}
client.webSocket("/echo") {
val greetingText = (incoming.receive() as? Frame.Text)?.readText() ?: ""
assertEquals("Please enter your name", greetingText)
send(Frame.Text("JetBrains"))
val responseText = (incoming.receive() as Frame.Text).readText()
assertEquals("Hi, JetBrains!", responseText)
}
}
}
}
HttpClient를 사용한 엔드 투 엔드 테스트
테스트 엔진 외에도 Ktor HTTP 클라이언트를 사용하여 서버 애플리케이션의 엔드 투 엔드(end-to-end) 테스트를 수행할 수 있습니다. 아래 예제에서 HTTP 클라이언트는 TestServer
에 테스트 요청을 보냅니다.
import e2e.TestServer
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
class EmbeddedServerTest: TestServer() {
@Test
fun rootRouteRespondsWithHelloWorldString(): Unit = runBlocking {
val response: String = HttpClient().get("http://localhost:8080/").body()
assertEquals("Hello, world!", response)
}
}
전체 예제는 다음 샘플을 참조하세요:
- embedded-server: 테스트할 샘플 서버입니다.
- e2e: 테스트 서버 설정을 위한 헬퍼 클래스 및 함수를 포함합니다.