코틀린 유닛 테스트(1)

·2022년 1월 5일
1
post-thumbnail

코드는 우리가 의도한대로 작동되지 않는다. 우리가 작성한대로 동작할 뿐이다. 코틀린 컴파일러는 엄격한 검증이 코드에서 발생 가능한 에러를 상당부분 줄여주지만 어플리케이션이 발전하면서 코드가 지속적으로 의도한대로 동작하는지 검증하는것은 우리의 책임이다.

모든 코드가 예상대로 동작되는지 수동으로 확인하는 것은 비용과 시간이 많이 들고 그 자체로 에러를 유발할 수 있다. 자동화 테스트는 시간이 걸리지만, 길게 봤을 땐 시간을 절약하게 된다. 유닛 테스트는 자동화 테스트의 한 종류이다.

📌 테스트 코드가 있는 코드


테스트를 진행할 어플리케이션 설계는 다음과 같다
설계는 그림의 우측의 방향으로 구현된다. 공항 정보는 FAA의 공항 정보 웹사이트를 사용한다. 먼저 테스트를 사용해서 Airport 클래스를 만든다. 클래스에는 공항을 정렬하는 메소드, 리모트 서비스에서 데이터를 가져오는 메소드, JSON 파스하는 메소드를 가지고 있다. 그리고 역시 테스트를 이용해서 AirportStatus.kt 파일의 함수를 구현한다. 마지막으로 AirportStatus.kt 의 main() 함수를 작성한다.
API : https://app.swaggerhub.com/apis/FAA/ASWS/1.2.0#/Status

우리는 테스트 우선 접근을 사용한다. 테스트 우선 접근이랑 짧고 유용한 테스트를 먼저 만들고, 그 테스트를 통과하는 최소한의 코드를 구현하는 짧은 사이클을 따르는 것이다.

📌 테스트 준비하기


💻 Gradle 세팅


plugins {
    kotlin("jvm") version "1.6.10"
    java
    application
    jacoco
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    implementation(kotlin("stdlib"))
    implementation(kotlin("reflect:1.3.41"))
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0")
    implementation("com.beust:klaxon:5.5")


    testImplementation("io.kotlintest:kotlintest-runner-junit5:3.4.2")
    testImplementation("io.mockk:mockk:1.12.1")

    //SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
    implementation("org.apache.logging.log4j:log4j-slf4j-impl:2.17.0")
}

tasks.getByName<Test>("test") {
    useJUnitPlatform()
    testLogging.showStandardStreams = true

}

jacoco {
    toolVersion = "0.8.5"
}

application {
    mainClassName = "...AirportAppKt"
}

defaultTasks("clean", "test", "jacocoTestReport")

코틀린과 연관된 Gradle 작업을 컴파일하고, 테스트하기 위해 kotlin 플러그인을 사용한다. application 플러그인은 main() 함수를 Gradle을 통해서 실행되도록 만들어 준다. 그리고 jacoco 플러그인은 코드 커버리지 툴이다.

useJUnitPlatform { } 함수는 kotlinTest를 JUnit 실행 플랫폼의 맨 위에서 실행시키기 위한 KotlinTest 툴에 필요한 과정이다. application { } 함수는 AirPortAppKt 클래스에서 최종적으로 작성하게 될 main() 함수를 실행시키기 위해서 사용된다.

📌 카나리 테스트로 시작하기


카나리 테스트는 true가 true인지 확인하는 테스트이다. 카나리 테스트는 프로젝트에 필요한 툴이 설치되었는지 확인하는 좋은 방법이다.

💻 Canary


class AirportTest : StringSpec() {

      val iah = Airport("IAH", "George Bush Intcntl/houston", false)
   val iad = Airport("IAD", "Washington Dulles Intl", false)
   val ord = Airport("ORD", "Chicago O'hare Intl", false)


   init {
       "canary test should pass"{
           true shouldBe true
       }

   }
}

KotlinTest 라이브러리는 몇 가지 다른 종류의 assert(코드가 기대한대로 실행하는지 확인하는 함수) 옵션이 있다.

위 코드에서는 어서트를 위해 shouldBe 함수를 사용했다. StringSpec 클래스는 스펙을 가장 잘 설명하는 방법을 제공한다.

코드를 실행하면 카나리 테스트가 통과했다는 빌드 완료 메세지를 보게 될 것이다.

📌 실증 테스트 작성


실증 테스트는 유닛 테스트에서 일반적이다. 메소드를 호출하고, 결과가 개발자가 예상한 것과 일치하는지를 검증한다.

실증테스트는 결과가 정해져 있고, 상태를 지닌 디펜던시가 없는 함수나 메소드일때 유용하다.

💻 empirical (src.test.kotlin...)


class AirportTest : StringSpec() {

   val iah = Airport("IAH", "Houston", true)
   val iad = Airport("IAD", "Dulles", false)
   val ord = Airport("ORD", "Chicago 0 'Hare'", true)


   init {
        "sort empty list should return an empty list"{
           Airport.sort(listOf<Airport>()) shouldBe listOf<Airport>()
       }


   }
}

💻 Airport.kt (src.main.kotlin...)


data class Airport(val code: String, val name: String, val delay: Boolean) {
   companion object {
       fun sort(airports: List<Airport>): List<Airport> {
           return airports
       }

    
   }
}

sort 메소드는 전달받은 리스트를 그대로 리턴한다.
빌드를 실행시켜보면 오류없이 실행되는 것을 알 수 있다.

이게 바로 테스트를 통과하기 위해 최소한의 메소드만을 작성하는 방식을 적용했을 떄의 sort() 메소드가 해야 할 일이다.

테스트는 우리가 메소드 이름, 파라미터 타입, 리턴타입을 놓치지 않도록 도와준다.

메소드로 요소 하나를 가진 테스트를가 전달되는 테스트를 만들어보자


class AirportTest : StringSpec() {

   val iah = Airport("IAH", "Houston", true)
   val iad = Airport("IAD", "Dulles", false)
   val ord = Airport("ORD", "Chicago 0 'Hare'", true)


   init {
"sort list with one Airport should return the given Airport"{
           Airport.sort(listOf<Airport>(iad)) shouldBe listOf<Airport>(iad)
       }

       "sort pre-sorted list should return the given list"{
           Airport.sort(listOf<Airport>(iad,iah)) shouldBe listOf<Airport>(iad,iah)
       }

    

   }
}

빌드를 하면 모든 테스트가 통과된걸 확인 할 수 있다.

이제 정렬된 두 개의 공항을 가진 리스트를 전달하는 테스트를 만들어보자.


... 

   init {
   
"sort airports should return airports in sorted order of name"{
                                 Airport.sort(listOf<Airport>(iah,iad,ord)) shouldBe listOf<Airport>(ord,iad,iah)

       }

   
   }
}

sort() 함수가 테스트를 통과할 수 있도록 sort() 메소드를 공항을 이름순으로 정렬하도록 수정해보자

💻 Airport.kt


data class Airport(val code: String, val name: String, val delay: Boolean) {
   companion object {
          fun sort(airports: List<Airport>): List<Airport> {
           return airports.sortedBy { airport -> airport.name }
       }

    
   }
}

빌드를 해보면 sort() 함수가 테스트를 통과 하는것을 확인 할 수 있다.

📌 데이터 드리븐 테스트 작성


좋은 테스트는 FAIR 하다. Fast, Automated, Independent, Repeatable즉, 빠르고 자동화되어있으며 독립적이고 반복이 가능하다.

테스트가 서로 독립적이지 않다면, 테스트가 실행될 때 특정한 순서로 실행되어야 한다. 그러면 테스트를 추가하거나 제거할 때 순서를 깨뜨리게 될 수 있고 그러면 테스트가 실패할 수 있다. 이런 테스트는 유지보수하는데 많은 비용이 든다.

테스트가 독립적이란 것을 보장하는 방법 중 하나는 절대 여러개의 독립적인 어서트를 같은 테스트에서 검증하지 않는 것이다. 이런 접근의 명확한 장점은 테스특 발전되면서 다른 테스트에 영향을 주지 않고, 다른 테스트로부터 영향을 받지도 않는다는 것이다. 하지만 이런 방법의 단점은 너무 많은 테스트를 만들어야 해서 장황하고, 이해하기 어렵고, 작성하기 힘들다는 점이다.

KotlinTest는 이런 문제를 해결하기 위해서 데이터 드리븐 테스트를 제공한다. 어서트가 각 열별로 실행되며 하나의 어서트가 실패한다고 해서 나머지 테스트를 멈추지 않고 실행한다.

💻 datadriven


class AirportTest : StringSpec() {

   val iah = Airport("IAH", "Houston", true)
   val iad = Airport("IAD", "Dulles", false)
   val ord = Airport("ORD", "Chicago 0 'Hare'", true)

   init {

       "sort airports by name" {
           forall(
               row(listOf(), listOf()),
               row(listOf(iad), listOf(iad)),
               row(listOf(iad, iah), listOf(iah, iad)),
               row(listOf(iah,iad,ord), listOf(ord,iad,iah))
           ) { input, result ->
               Airport.sort(input) shouldBe result
           }
       }
       
   }
}

테스에서 forall() 메소드를 호출해 아규먼트로 행으로 이루어진 테이블과 어서트로 시작되는 메소드를 포함한 람다식을 전달한다.

row()는 각자 테이블에서 데이터로 이루어진 행을 표현한다. 예를 들어 테이블은 두 개의 열을 가지고 있는데 첫 번째는 sort()에 들어갈 입력이고 두번째는 호출로 나올 결과이다.
두 번째 파라미터인 람다 표현식은 각 행마다 KotlinTest를 통해 실행되며, 테이블 행마다 반복되어 첫 번째 열과 두 번째 열에 있는 입력값과 결과값을 람다의 첫번째 , 두번째 아규먼트로 넘긴다.

빌드를 실행 하면 하나의 테스트가 실패 하더라도 열 별로 결과를 알려주는것을 볼 수 있다.

📌 의존성 모킹


디펜던시(의존성)는 테스트를 어렵게 만든다. 공항의 상태를 웹서비스에서 받아오는 코드를 만들 때 데이터의 성질 때문에 웹서비스의 응답은 매번 다를 수 있다. 웹서비스는 간헐적으로 오류가 있을 수 있고, 프로그램이 네트워크 에러에 빠질 수도 있다. 이런 모든 상황은 FAIR해야하는 유닛 테스트를 만들기 어렵게 만든다. 코드는 비 결정적이고, 본질적으로 신뢰할 수 없는 외부 디펜던시를 이용해야 하기 떄문이다. 이럴 때 mock을 사용한다.

mock은 실제 객체를 대신하는 객체이다. 코드 테스트를 위해서 코드를 실행할 때는 진짜 디펜던시를 사용한다. 하지만 자동화 테스트를 할 떄는 목이 실제 디펜던시를 대체한다 그래서 테스트가 FAIR해질 수 있다.

공항의 상태를 웹서비스에서 받아오는 코드를 생각해보자

두 개의 메소드를 사용해서 솔루션을 생각할 수 있다. 실제 데이터를 FAA 웹서비스에서 가져오는 fetchData() 함수를 작성하고 getAirportData() 함수는 fetchData() 함수를 호출한 이후에 응답으로 온 JSON 문자열을 파싱하여 데이터를 추출한다.

테스트에서 fetchData() 함수를 모킹한다. getAirportData()가 fetchData()를 호출하면 그 호출이 실제 구현된 함수를 호출하는 것이 아니라 우리가 모킹한 함수를 호출한다.

interaction 테스트 생성

getAirportData() 메소드에서는 fetchData()를 모킹해서 준비된 JSON 응답을 리턴한다. 테스트에서 getAirportData()가 fetchData() 함수를 호출했는지 검증한다. 이 인터렉션 테스트는 시증테스트와 반대되는 테스트이다.

테스트를 하기 전 fetchData() 함수의 목을 준비해야 한다. 이를 위해서 Mockk 라이브러리의 함수를 임포트해야 한다.

💻 KotlinTest

import io.kotlintest.TestCase
import io.kotlintest.TestResult
import io.mockk.*

fetchData() 함수를 Airport 클래스의 컴패니언 객체에 포함되도록 설계한다. 이 함수를 모킹하기 위해서 Airport 싱글톤 컴패니언 객체의 목을 만들어야 한다.
beforeTest() 라는 특화된 함수를 이용해서 목 객체를 만들 수 있다. beforeTest()와 afterTest() 한 ㄱ쌍의 함수는 각 테스트를 샌드위치한다.

beforeTest()의 코드는 각 테스트 실행 전에 실행되고 afterTEst()의 코드는 각 테스트 실행 이후에 실행된다.

💻 KotlinTest

class AirportTest : StringSpec() {

override fun beforeTest(testCase: TestCase) {
       mockkObject(Airport)
   }

   override fun afterTest(testCase: TestCase, result: TestResult) {
       clearAllMocks()
   }

}

beforeTest() 함수 안에서 Mockk 라이브러리의 mockkObject() 함수를 이용해서 Airport 싱글톤의 목을 생성했다. afterTest() 함수에서 각 테스트의 마지막에 우리가 생성하고 사용했던 목들을 정리한다. 그래서 각 테스트들은 고립된 상태가 되고 서로에세 독립적이게 된다.

💻 AirportTest

   init {
       "getAirportData invokes fetchData" {
           every { Airport.fetchData("IAD") } returns
                   """{"IATA":"IAD", "Name": "Washington Dulles Intl", "Delay": false}"""

           Airport.getAirportData("IAD")

           verify { Airport.fetchData("IAD") }
       }
}

getAirportData()가 fetchData()를 실행했는지 검증하는 테스트에서 Mockk의 every() 함수를 이용해서 Airport 컴패니언 객체의 fetchData()를 모킹했다. 그래서 주어진 공항 코드가 "IAD"일 경우 준비된 JSON 응답을 리턴했다.

every() 함수가 실행되면 테스트에서 Airport의 fetchData() 함수의 아규먼트가 "IAD"일 경우 호출이 직접적이든, 간접적이든 상관없이 모두 실제 fechData()의 구현체를 호출하는 것이 아니고 every() 함수의 returns 뒷부분의 준비된 응답을 리턴한다.

테스트 안에서 목 동작을 설정하기 위한 every() 호출 이후 우리는 아직 구현되지 않는 getAirportData() 함수를 호출했다. 그 후 Mockk의 verify() 함수를 이용해서 fetchData()가 호출되었는지 검증했다. verify() 함수 호출의 성공은 getAirportDaata() 함수의 호출이 fetchData(를 호출했다는 의미를 포함한다.

💻 Airport

data class Airport(val code: String, val name: String, val delay: Boolean) {
   companion object {
       fun sort(airports: List<Airport>): List<Airport> {
           return airports.sortedBy { airport -> airport.name }
       }

       fun getAirportData(code: String) = fetchData(code)

       fun fetchData(code: String): String {
           throw RuntimeException("Not Implemented Yet for $code")
       }

   }
}

데이터 파싱을 위한 테스트

fetchData()에서 응답받은 데이터를 Airport 인스턴스로 파싱하는 코드를 구현하자

💻 AirportTest

   init {
        "getAirportData extracts Airport from JSON returned by fetchData" {
           every { Airport.fetchData("IAD") } returns
                   """{"IATA":"IAD", "Name": "Washington Dulles IntlWashington Dulles Intl", "Delay": false}"""

           Airport.getAirportData("IAD") shouldBe iad

           verify { Airport.fetchData("IAD") }
       }
}

이전 테스트와 현재 테스트의 차이점은 getAirportData()의 리턴이 우리가 예측한 Airport의 인스턴스가 맞는지 검증한다는 사실뿐이다.

getAirportData()가 Klaxon parser를 이용해서 fetchData()에서 전달받은 JSON 응답을 Airport의 인스턴스를 생성할 수 있도록 파싱한다.

Airport 클래스의 속성에 @Json 어노테이션을 붙인다. 이렇게 하면 Klaxon 파서가 JSON의 값을 객체의 적절한 속성에 맵핑할 수 있다.

💻 Airport

  data class Airport(
   @Json(name = "IATA") val code: String,
   @Json(name = "Name") val name: String,
   @Json(name = "Delay") val delay: Boolean
) {
   companion object {
       fun sort(airports: List<Airport>): List<Airport> {
           return airports.sortedBy { airport -> airport.name }
       }

       fun getAirportData(code: String) =
           try {
               Klaxon().parse<Airport>(
                   fetchData(code)
               ) as Airport
           } catch (ex: Exception) {
               Airport(code, "Invalid Airport", false)
           }

       fun fetchData(code: String): String {
           throw RuntimeException("Not Implemented Yet for $code")
       }

   }
}

속성에 어노테이션이 붙었기 때문에 Klaxon JSON parser가 JSON 속성을 올바른 객체에 맵핑할 수 있다. getAirportData() 함수는 fetchData() 함수를 실행시킨다. 그리고 그 결과를 klaxon의 parse() 메소드로 전달한다. 파싱할 때 예외가 있든 fetchData()에서 발생시킨 예외를 전달받든 상관없이 우리의 getAirportData()메소드는 이런 상황을 우아하게 처리한다. 예외가 발생하면 주어진 코드와 함께 "Invalid Airport"라는 이름을 가진 Airport 객체를 리턴하도록 설계했다.

탑레벨 함수 테스트

Airport 클래스는 테스트를 이용해서 설계했다. Airport 클래스는 에어포트 하나의 정보를 리턴하는 기능과 이름으로 정렬된 리스트를 리턴하는 기능을 가지고 있다. AirportStatus.kt 파일에 공항 리스트를 받아서 정렬된 공항 정보를 리턴해주는 함수를 탑레벨 함수로 추가해보자.

getAirportStatus() 함수는 공항 코드 리스트를 받아서 각 공항의 정보로 채워진 Airport 인스턴스의 리스트를 리턴한다.

첫 번째 테스트는 getAirportStatus() 메소드의 시그니처를 나타내는 용도로 사용한다.

💻 getAirportStatus(src.main.kotlin...)

fun getAirportStatus(airportCodes: List<String>): List<Airport> =
  Airport.sort(
    airportCodes.map { code -> Airport.getAirportData(code) }
  )

map() 함수를 이용해서 입력인 airportCodes 콜렉션의 각 요소를 반복하면서 getAirportData()를 호출해서 Airport 인스턴스의 리스트를 만들었다. 테스트를 통과하게 만들기 위해서 map()의 결과를 sort() 함수로 넘겨준다.

💻 sychnornous

class AirportStatusTest : StringSpec() {

   val iah = Airport("IAH", "George Bush Intcntl/houston", false)
   val iad = Airport("IAD", "Washington Dulles Intl", false)
   val inv = Airport("inv", "Invalid Airport", false)

   override fun beforeTest(testCase: TestCase) {
       mockkObject(Airport)
       every { Airport.getAirportData("IAD") } returns iad
       every { Airport.getAirportData("IAH") } returns iah
       every { Airport.getAirportData("inv") } returns inv
   }

   override fun afterTest(testCase: TestCase, result: TestResult) {
       clearAllMocks()
   }

 init {
   "getAirportStatus returns status for airports in sorted order" {
     forall(
       row(listOf<String>(), listOf<Airport>()),
       row(listOf("IAD"), listOf(iad)),
       row(listOf("IAD", "IAH"), listOf(iad, iah)),
       row(listOf("IAH", "IAD"), listOf(iad, iah)),
       row(listOf("inv", "IAD", "IAH"), listOf(iad, iah, inv))
     ) { input, result ->
       getAirportStatus(input) shouldBe result
     }
   }

 }
}

forall() 함수에 전달된 아규면트 안에서 우리는 입력과 예상되는 출력을 row() 함수를 이용해 정의해 두었다. 그리고 Airport.getAirportData()를 모킹 해주었다.

빌드를 실핸하면 테스트가 통과되는것을 확인 할 수 있다.

profile
개발하고싶은사람

0개의 댓글