[Ktor] Ktor로 API 서버 개발(4)

Daemon·2025년 8월 10일

Ktor

목록 보기
4/4
post-thumbnail

Ktor 테스트 환경 설정

Ktor는 자체적으로 훌륭한 테스트 프레임워크를 제공한다. Spring Boot의 @SpringBootTest나 @WebMvcTest와 비슷한 개념이지만, 더 가볍고 직관적이다.

먼저 build.gradle.kts에 테스트 관련 의존성이 제대로 추가되어 있는지 확인해보자.

dependencies {
    // ... 기존 의존성들
    testImplementation("io.ktor:ktor-server-test-host")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}

ktor-server-test-host는 Ktor의 테스트 환경을 제공하고, kotlin-test-junit는 JUnit과 Kotlin test assertion을 사용할 수 있게 해준다.

전체 테스트 클래스 구조

이제 본격적으로 테스트 코드를 살펴보자.

ApplicationTest.kt

package com.example

import com.example.models.ApiResponse
import com.example.repository.HeroRepositoryImpl
import com.example.repository.NEXT_PAGE_KEY
import com.example.repository.PREVIOUS_PAGE_KEY
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.koin.core.context.stopKoin
import kotlin.test.assertEquals

class ApplicationTest {

    @AfterEach
    fun tearDown() {
        stopKoin()
    }
    
    // 테스트 메서드들...
}

여기서 중요한 부분이 @AfterEach로 선언된 tearDown() 메서드다.

Koin 충돌 문제 해결

/*
테스트를 실행할 때마다 module() 함수가 호출되는데
이전 테스트에서 이미 Koin이 초기화되어있어서 충돌이 발생하므로 Koin을 정리해준다.
*/
@AfterEach
fun tearDown() {
    stopKoin()
}

처음에 테스트를 돌렸을 때 두 번째 테스트부터 계속 실패했는데, 알고 보니 Koin이 이미 초기화되어 있다는 에러가 발생하고 있었다.

각 테스트마다 application { module() }을 호출하면서 Koin을 초기화하는데, 이전 테스트에서 초기화한 Koin 컨텍스트가 그대로 남아있어서 충돌이 발생한 것이다.

Root 엔드포인트 테스트

가장 간단한 테스트부터 시작해보자.

@Test
fun `access root endpoint, assert correct information`() =
    testApplication {
        application { module() }
        val response = client.get("/")
        assertEquals(
            expected = HttpStatusCode.OK,
            actual = response.status
        )
        assertEquals(
            expected = "Welcome to Boruto API!",
            actual = response.bodyAsText()
        )
    }

testApplication DSL을 사용해서 테스트 환경을 구성한다. Spring Boot의 MockMvc와 비슷한 역할을 한다고 보면 된다.

페이지네이션 통합 테스트

이제 핵심 기능인 페이지네이션을 테스트해보자.

@ExperimentalSerializationApi
@Test
fun `access all heroes endpoint, query all pages, assert correct information`() =
    testApplication {
        application { module() }
        val heroRepository = HeroRepositoryImpl()
        val pages = 1..5
        val heroes = listOf(
            heroRepository.page1,
            heroRepository.page2,
            heroRepository.page3,
            heroRepository.page4,
            heroRepository.page5
        )
        pages.forEach { page ->
            val response = client.get("/boruto/heroes?page=$page")
            assertEquals(
                expected = HttpStatusCode.OK,
                actual = response.status
            )
            val actual = Json.decodeFromString<ApiResponse>(response.bodyAsText())
            val expected = ApiResponse(
                success = true,
                message = "ok",
                prevPage = calculatePage(page = page)["prevPage"],
                nextPage = calculatePage(page = page)["nextPage"],
                heroes = heroes[page - 1]
            )
            assertEquals(
                expected = expected,
                actual = actual
            )
        }
    }

여기서 흥미로운 점은 테스트 내에서 HeroRepositoryImpl의 인스턴스를 직접 생성해서 예상 결과를 만들어내는 것이다.

실제 API가 반환하는 데이터와 Repository가 가지고 있는 데이터가 일치하는지 검증한다. 1부터 5까지 모든 페이지를 순회하면서 테스트하므로 페이지네이션 로직이 제대로 동작하는지 확실하게 검증할 수 있다.

@ExperimentalSerializationApi 어노테이션은 kotlinx.serialization의 실험적 기능을 사용하기 위해 필요하다. 프로덕션 코드에서는 조심스럽게 사용해야 하지만, 테스트 코드에서는 큰 문제가 없다.

에러 케이스 테스트

정상 케이스만 테스트하는 것은 반쪽짜리 테스트다. 에러 상황도 꼼꼼히 테스트해보자.

@ExperimentalSerializationApi
@Test
fun `access all heroes endpoint, query non existing page number, assert error`() =
    testApplication {
        application { module() }
        val response = client.get("/boruto/heroes?page=6")
        assertEquals(
            expected = HttpStatusCode.NotFound,
            actual = response.status
        )
        assertEquals(
            expected = "Page Not Found.",
            actual = response.bodyAsText()
        )
    }

페이지 범위(1-5)를 벗어난 6페이지를 요청했을 때 404 NotFound를 반환하는지 확인한다. 2편에서 개선했던 HTTP 상태 코드가 제대로 동작하는지 검증하는 테스트다.

잘못된 파라미터 테스트

@ExperimentalSerializationApi
@Test
fun `access all heroes endpoint, query invalid page number, assert error`() =
    testApplication {
        application { module() }
        val response = client.get("/boruto/heroes?page=invalid")
        assertEquals(
            expected = HttpStatusCode.BadRequest,
            actual = response.status
        )
        val expected = ApiResponse(
            success = false,
            message = "Only Numbers Allowed"
        )
        val actual = Json.decodeFromString<ApiResponse>(response.bodyAsText())
        assertEquals(
            expected = expected,
            actual = actual
        )
    }

숫자가 아닌 "invalid" 문자열을 페이지 파라미터로 전달했을 때의 처리를 테스트한다. NumberFormatException이 제대로 처리되는지 확인할 수 있다.

검색 기능 테스트

검색 기능도 다양한 케이스로 테스트해보자.

@ExperimentalSerializationApi
@Test
fun `access search heroes endpoint, query hero name, assert single hero result`() =
    testApplication {
        application { module() }
        val response = client.get("/boruto/heroes/search?name=sas")
        assertEquals(expected = HttpStatusCode.OK, actual = response.status)
        val actual = Json.decodeFromString<ApiResponse>(response.bodyAsText())
            .heroes.size
        assertEquals(expected = 1, actual = actual)
    }

"sas"로 검색했을 때 Sasuke만 검색되는지 확인한다. 부분 문자열 매칭이 제대로 동작하는지 검증하는 테스트다.

복수 결과 테스트

@ExperimentalSerializationApi
@Test
fun `access search heroes endpoint, query hero name, assert multiple heroes result`() =
    testApplication {
        application { module() }
        val response = client.get("/boruto/heroes/search?name=sa")
        assertEquals(expected = HttpStatusCode.OK, actual = response.status)
        val actual = Json.decodeFromString<ApiResponse>(response.bodyAsText())
            .heroes.size
        assertEquals(expected = 3, actual = actual)
    }

"sa"로 검색하면 Sasuke와 Sakura가 모두 검색되어야 한다. 실제로 3명이 검색되는 것으로 보아 다른 캐릭터도 포함되는 것 같다.

빈 검색어 테스트

@ExperimentalSerializationApi
@Test
fun `access search heroes endpoint, query an empty text, assert empty list as a result`() =
    testApplication {
        application { module() }
        val response = client.get("/boruto/heroes/search?name=")
        assertEquals(expected = HttpStatusCode.OK, actual = response.status)
        val actual = Json.decodeFromString<ApiResponse>(response.bodyAsText())
            .heroes
        assertEquals(expected = emptyList(), actual = actual)
    }

빈 문자열로 검색했을 때 빈 리스트를 반환하는지 확인한다. 이런 엣지 케이스를 놓치면 프로덕션에서 예상치 못한 버그가 발생할 수 있다.

존재하지 않는 히어로 검색

@ExperimentalSerializationApi
@Test
fun `access search heroes endpoint, query non existing hero, assert empty list as a result`() =
    testApplication {
        application { module() }
        val response = client.get("/boruto/heroes/search?name=unknown")
        assertEquals(expected = HttpStatusCode.OK, actual = response.status)
        val actual = Json.decodeFromString<ApiResponse>(response.bodyAsText())
            .heroes
        assertEquals(expected = emptyList(), actual = actual)
    }

존재하지 않는 히어로를 검색했을 때도 에러가 아닌 빈 리스트를 반환하는 것이 RESTful한 설계다.

404 처리 테스트

@ExperimentalSerializationApi
@Test
fun `access non existing endpoint,assert not found`() =
    testApplication {
        application { module() }
        val response = client.get("/unknown")
        assertEquals(expected = HttpStatusCode.NotFound, actual = response.status)
        assertEquals(expected = "Page Not Found.", actual = response.bodyAsText())
    }

존재하지 않는 엔드포인트에 접근했을 때 404를 반환하는지 확인한다. Ktor의 기본 404 처리가 제대로 동작하는지 검증하는 테스트다.

헬퍼 함수 활용

private fun calculatePage(page: Int) =
    mapOf(
        PREVIOUS_PAGE_KEY to if (page in 2..5) page.minus(1) else null,
        NEXT_PAGE_KEY to if (page in 1..4) page.plus(1) else null
    )

Repository의 calculatePage() 로직을 테스트에서도 동일하게 구현해서 사용한다.

DRY(Don't Repeat Yourself) 원칙에 어긋나는 것처럼 보일 수 있지만, 테스트 코드에서는 오히려 이렇게 독립적으로 구현하는 것이 좋다. Repository의 로직이 잘못되었을 때 테스트가 그것을 잡아낼 수 있기 때문이다.

테스트 실행 결과

모든 테스트를 실행하면 다음과 같은 결과를 볼 수 있다:

✅ access root endpoint, assert correct information
✅ access all heroes endpoint, query all pages, assert correct information
✅ access all heroes endpoint, query non existing page number, assert error
✅ access all heroes endpoint, query invalid page number, assert error
✅ access search heroes endpoint, query hero name, assert single hero result
✅ access search heroes endpoint, query hero name, assert multiple heroes result
✅ access search heroes endpoint, query an empty text, assert empty list as a result
✅ access search heroes endpoint, query non existing hero, assert empty list as a result
✅ access non existing endpoint,assert not found

Test Results: 9 passed

모든 테스트가 통과했다! 이제 API가 예상대로 동작한다는 확신을 가질 수 있다.

테스트 커버리지 측정 - JaCoCo 설정

테스트를 작성했으니 이제 얼마나 커버하고 있는지 측정해보자. Kotlin에서는 Kover도 있지만, 나는 더 익숙한 JaCoCo를 선택했다.

val kotlin_version: String by project
val logback_version: String by project
val koinVersion: String by project

plugins {
    kotlin("jvm") version "2.1.21"
    id("io.ktor.plugin") version "3.2.2"
    id("org.jetbrains.kotlin.plugin.serialization") version "2.1.21"
    id("jacoco")  // JaCoCo 플러그인 추가
}

jacoco {
    toolVersion = "0.8.10"
}

tasks.jacocoTestReport {
    dependsOn(tasks.test)
    reports {
        xml.required.set(true)  // Codecov는 XML 리포트 필요
        html.required.set(true) // 로컬 확인용
        csv.required.set(false)
    }

    // 제외할 파일들
    classDirectories.setFrom(
        files(classDirectories.files.map {
            fileTree(it) {
                exclude(
                    "**/Application*",
                    "**/ApplicationKt*",
                    "**/plugins/**",
                    "**/models/**", // 데이터 클래스 제외
                    "**/*\$WhenMappings.*" // Kotlin when 매핑 제외
                )
            }
        })
    )

    executionData.setFrom(fileTree(layout.buildDirectory.dir("jacoco")).include("**/*.exec"))
}

tasks.test {
    useJUnitPlatform()
    systemProperty("file.encoding", "UTF-8")
    jvmArgs = listOf("-Dfile.encoding=UTF-8")
    finalizedBy(tasks.jacocoTestReport)  // 테스트 후 자동으로 리포트 생성
}

여기서 중요한 부분을 짚어보자.

커버리지 제외 설정: 데이터 클래스나 설정 파일들은 테스트할 필요가 없으므로 제외했다. Spring Boot 프로젝트에서도 @Configuration 클래스나 Entity들을 제외하는 것과 같은 맥락이다.

인코딩 설정: 한글이 포함된 테스트 이름을 사용하거나 데이터에 한글이 있을 경우를 대비해 UTF-8로 명시적으로 설정했다. 이거 하나 빠뜨려서 CI에서만 테스트가 실패하는 경험을 해본 적이 있다.

JUnit 5 의존성 명시

// 명시적으로 JUnit 의존성 추가 (Gradle 9.0 호환)
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.9.2")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
    
    // 테스트 의존성 - JUnit 버전 통일
    testImplementation("io.ktor:ktor-server-test-host")
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.1")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5:$kotlin_version")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

Gradle 9.0부터는 JUnit Platform Launcher를 명시적으로 추가해야 한다. 이걸 몰라서 CI에서 "No tests found" 에러로 한참 고생했던 기억이 있다.

Codecov 통합

로컬에서만 커버리지를 확인하는 것은 의미가 없다. PR마다 자동으로 커버리지를 체크하고 리포트를 생성하도록 Codecov를 설정해보자.

codecov:
  require_ci_to_pass: true

coverage:
  precision: 2
  round: down

  status:
    project:
      default:
        target: 70%          # 전체 프로젝트 커버리지 목표
        threshold: 5%        # 허용 가능한 커버리지 감소폭
        base: auto
    patch:
      default:
        target: 80%          # 새로운 코드의 커버리지 목표
        threshold: 5%
        base: auto

PR 코멘트 설정

comment:
  layout: "reach,diff,flags,files"
  behavior: default
  require_changes: false

설정의 핵심 포인트를 살펴보자.

프로젝트 vs 패치 커버리지: 전체 프로젝트는 70%, 새로 추가되는 코드는 80%를 목표로 설정했다. 새 코드에 더 엄격한 기준을 적용하는 것이 점진적으로 커버리지를 개선하는 좋은 방법이다.

threshold 설정: 5%의 감소는 허용한다. 때로는 리팩토링이나 큰 기능 추가로 일시적으로 커버리지가 떨어질 수 있기 때문이다. 너무 엄격하면 개발 속도가 떨어진다.
ignore 패턴: JaCoCo 설정과 동일하게 맞춰서 일관성을 유지했다. 양쪽에서 다르게 설정하면 혼란스러울 수 있다.

GitHub Actions 연동

CI/CD 파이프라인에서 Codecov를 사용하려면 GitHub Actions 워크플로우도 설정해야 한다.

name: CI with Codecov

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

jobs:
  test-and-coverage:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        fetch-depth: 0  # Codecov가 git history를 필요로 함
    
    - name: Set up JDK 21
      uses: actions/setup-java@v4
      with:
        java-version: '21'
        distribution: 'temurin'
    
    - name: Cache Gradle packages
      uses: actions/cache@v3
      with:
        path: |
          ~/.gradle/caches
          ~/.gradle/wrapper
        key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
        restore-keys: |
          ${{ runner.os }}-gradle-
    
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    
    - name: Run tests and generate coverage
      run: |
        echo "JAVA_HOME is: $JAVA_HOME"
        java -version
        ./gradlew test jacocoTestReport --stacktrace --info -Dorg.gradle.java.home="$JAVA_HOME"
      env:
        GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8"
    
    - name: Verify coverage file exists
      run: |
        ls -la build/reports/jacoco/test/
        if [ -f "build/reports/jacoco/test/jacocoTestReport.xml" ]; then
          echo "Coverage file found"
          head -20 build/reports/jacoco/test/jacocoTestReport.xml
        else
          echo "Coverage file not found!"
          exit 1
        fi
    
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v4
      with:
        token: ${{ secrets.CODECOV_TOKEN }}
        files: ./build/reports/jacoco/test/jacocoTestReport.xml
        flags: unittests
        name: codecov-umbrella
        fail_ci_if_error: true  # 에러 시 실패하도록 변경
        verbose: true
        override_branch: main
        override_commit: ${{ github.sha }}
    
    - name: Upload coverage via curl (fallback)
      if: failure()
      run: |
        curl -Os https://uploader.codecov.io/latest/linux/codecov
        chmod +x codecov
        ./codecov -f build/reports/jacoco/test/jacocoTestReport.xml -t ${{ secrets.CODECOV_TOKEN }}
    
    - name: Archive test results
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: test-results
        path: |
          build/reports/tests/
          build/reports/jacoco/

실제 커버리지 결과 분석

테스트를 실행하고 나면 build/reports/jacoco/test/html/index.html을 열어서 로컬에서 커버리지를 확인할 수 있다.

./gradlew test jacocoTestReport

현재 우리 프로젝트의 커버리지는 대략 이런 상태일 것이다:

Repository 레이어: 85-90% (대부분의 로직이 테스트됨)
Route 레이어: 70-75% (통합 테스트로 커버)
전체: 75% 정도

이 정도면 충분히 안정적인 수준이다. 100% 커버리지는 이상적이지만, 실용적인 관점에서는 핵심 비즈니스 로직이 잘 테스트되고 있다면 충분하다.

끝맺음

SpringBoot보다 안드로이드 개발자한테는 Ktor가 러닝커브가 더 낮다고 판단해서 빠르게 구성해보았는데 한글 레퍼런스가 부족해서 재밌었던 것 같다. 최근에 인프런에서 Jetbrains 컨퍼런스를 번역 및 더빙을 한 컨텐츠가 있는데, 거기서도 안드로이드 외에 Ktor 파트도 들으면서 동기부여도 되는 것 같다. 실제 서비스를 출시하면서 클라우드 인프라도 해보고 싶다.

깃허브 링크

0개의 댓글