개발/코틀린

(코틀린) Testing with Kotest

DinoDev 2022. 12. 18. 17:40
728x90
반응형

테스트 프레임워크는 개발 생명 주기 전반에서 품질을 유지할 수 있도록 도움을 주고, 재사용 가능한 코드 작성을 돕는 중요한 역할을 합니다.

Kotest 명세

Kotest를 설정하는 방법과 테스트 하는 방법에 대해 알아봅니다.

Kotest 시작하기

Kotest를 하려면 프로젝트 의존성에 Kotest를 추가해야 합니다.

testImplementation "io.kotest:kotest-runner-junit5:4.5.0"

Kotest와 IntelliJ의 통합해주기 위해 제공되는 플러그인이 있습니다.

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe

class NumbersTest : StringSpec({
    "2 + 2 should be 4" { (2 + 2) shouldBe 4 }
    "2 * 2 should be 4" { (2 * 2) shouldBe 4 }
}) {
}

명세 스타일

Kotest는 여러 명세 스타일을 지원합니다. 

테스트 케이스를 정의하려면 명세 클래스 중 하나를 상속해야 합니다. 그 후 클래스 생성자에 테스트를 추가하거나 상위 클래스 생성자에 전달하는 람다 안에 테스트를 추가 합니다.

 

WordSpec 클래스를 사용하면 더 복잡한 레이아웃을 만들 수 있습니다. should() 함수로 각각의 그룹으로 묶을 수 있습니다.

import io.kotest.core.spec.style.WordSpec
import io.kotest.matchers.shouldBe

class NumbersTest : WordSpec({
    "1 + 2" should {
        "be equal to 3" { (1 + 2) shouldBe 3 }
        "be equal to 2 + 1" { (1 + 2) shouldBe (2 + 1) }
    }
})

should() When()  `when`() 으로 감싸면 테스트 계층을 3단계로 구성할 수 있습니다.

import io.kotest.core.spec.style.WordSpec
import io.kotest.matchers.shouldBe

class NumbersTest : WordSpec({
    "Addition" When {
        "1 + 2" should {
            "be equal to 3" { (1 + 2) shouldBe 3 }
            "be equal to 2 + 1" { (1 + 2) shouldBe (2 + 1) }
        }
    }
})

테스트 계층을 원하는 만큼 깊게 만들고 싶다면 FunSpec 클래스를 사용합니다. 이 클래스는 test() 함수 호출로 묶습니다. 

test와 context 블럭은 어떤 깊이에서도 사용할 수 있지만 test블럭은 test블럭 안에 쓸 수 없습니다.

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe

class NumbersTest : FunSpec({
    test("0 should be equal to 0") { 0 shouldBe 0 }
    context("Arithmetic") {
        context("Addition") {
            test("2 + 2 should be 4") { (2 + 2) shouldBe 4 }
        }
        context("Multiplication") {
            test("2 * 2 should be 4") { (2 * 2) shouldBe 4 }
        }
    }
})

ExpectSpec도 기본적으로 동일합니다. 하지만 test() 대신 expect()를 사용하고, 추가로 최상위에 테스트를 위치시키지 못하게 합니다.

즉 모든 테스트는 context() 블럭 안에 들어가야 합니다.

DescribeSpec은 describe()/context() 블럭을 그룹 짓는 데 사용하고, it()은 내부에 테스트를 담기 위해 사용합니다.

import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe

class NumbersTest : DescribeSpec({
    describe("Addition") {
        context("1+2") {
            it("should give 3") { (1 + 2) shouldBe 3 }
        }
    }
})

ShouldSpec은 FunSpec과 비슷한 레이아웃을 만듭니다. 

이 명세는 문맥 블록을 그룹 짓는 데 사용하고, 말단에 테스트 블록을 위치시킵니다.

문맥 블록을 만들 때는 테스트를 설명하는 문자열에 대해 context() 호출을 사용하고, 테스트 블록 자체는 should() 함수 호출로 정의합니다.

import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe

class NumbersTest : ShouldSpec({
    should("be equal to 0") { 0 shouldBe 0 }
    context("Addition") {
        context("1 + 2") {
            should("be equal to 3") { (1 + 2) shouldBe 3 }
            should("be equal to 2 + 1") { (1 + 2) shouldBe (2 + 1) }
        }
    }
})

비슷한 유형의 명세를 FreeSpec 클래스를 통해 구성할 수 있습니다. 이 명세도 StringSpec과 마찬가지로 문자열에 대한 invoke()를 사용해 테스트를 정의하며 - 연산자를 통해 문맥을 소개합니다.

import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe

class NumbersTest : FreeSpec({
    "0 should be equal to 0" { 0 shouldBe 0 }
    "Addition" - {
        "1 + 2" - {
            "1 + 2 should be equal to 3" { (1 + 2) shouldBe 3 }
            "1 + 2 should be equal to 2 + 1" { (1 + 2) shouldBe (2 + 1) }
        }
    }
})

Kotest는 거킨(Gherkin) 언어에서 영감을 얻은 BDD(행동 주도 개발) 명세 스타일도 지원합니다.

FreatureSpec에서는 feature 블록에 의해 계층의 루트가 만들어지고, 그 안에는 구체적인 테스트를 구현하는 시나리오 블록이 들어간다. feature 안에서 여러 시나리오를 묶어 그룹으로 만들 때도 feature() 호출을 사용한다.

import io.kotest.core.spec.style.FeatureSpec
import io.kotest.matchers.shouldBe

class NumbersTest : FeatureSpec({
    feature("Arithmetic") {
        val x = 1
        scenario("x is 1 at first") { x shouldBe 1 }
        feature("increasing by") {
            scenario("1 gives 2") { (x + 1) shouldBe 2 }
            scenario("2 gives 3") { (x + 2) shouldBe 3 }
        }
    }
})

BehaviorSpec 클래스도 비슷한 스타일을 구현하는데, given()/Given(), 'when'()/When, then()/Then()이라는 함수로 구분되는 세 가지 수준을 제공합니다. and()와 And()를 통해 여러 when/then 블럭을 묶어서 그룹 수준을 추가할 수 있습니다.

import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe

class NumbersTest : BehaviorSpec({
    Given("Arithmetic") {
        When("x is 1") {
            val x = 1
            And("increased by ") {
                Then("result is 2") { (x + 1) shouldBe 2 }
            }
        }
    }
})

AnnotationSpec은 테스트 클래스 메서드에 붙인 @Test annotation에 의존합니다.

import io.kotest.core.spec.style.AnnotationSpec
import io.kotest.matchers.shouldBe

class NumbersTest : AnnotationSpec() {
    @Test
    fun `2 + 2 should be 4`() {
        (2 + 2) shouldBe 4
    }

    @Test
    fun `2 * 2 should be 4`() {
        (2 * 2) shouldBe 4
    }
}

단언문

Matcher

Matcher는 일반 함수 호출이나 중위 연산자 형태로 사용할 수 있는 확장 함수로 정의된다. 모든 Matcher 이름은 shouldBe로 시작합니다. Custom Matcher를 정의하려면 Matcher interface를 구현하고 이 interfaced의 test() 메서드를 오버라이드해야 합니다.

abstract fun test(value: T): MatcherResult

MatcherResult 객체는 매칭 결과를 표현합니다. 이 클래스는 데이터 클래스로 다음과 같은 프로퍼티가 있습니다.

  • passed: 단언문을 만족하는지(true) 만족하지 않는지(false)를 나타냄
  • failureMessage: 단언문 실패를 보여주고 단언문을 성공시키려면 어떤 일을 해야 하는지 알려주는 메시지
  • negatedFailureMessage: Matcher를 반전시킨 버전을 사용했는데 Matcher가 실패하는 경우 표시해야 하는 메시지
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.Matcher
import io.kotest.matchers.MatcherResult
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNot

fun beOdd() = object : Matcher<Int> {
    override fun test(value: Int): MatcherResult {
        return MatcherResult(
            passed = value % 2 != 0,
            failureMessage = "$value should be odd",
            negatedFailureMessage = "$value should not be odd"
        )
    }
}

class NumbersTestWithOddMatcher : StringSpec({
    "5 is odd" { 5 shouldBe beOdd() }
    "4 is not odd" { 4 shouldNot beOdd() }
})

Matcher 인터페이스의 구현은 자동으로 and/or/invert 연산을 지원한다. 이 연산들은 불 연산 규칙에 따라 Matcher를 합성해줍니다. 

"5 is positive odd" { 5 should (beOdd() and positive()) }

compose() 연산은 기존 Matcher에 타입 변환 함수를 추가함으로써 새로운 타입에 대한 Matcher를 만들어줍니다. 

fun beOddLength() = beOdd().compose<Collection<*>> { it.size }

모든 Matcher를 should()/shouldNot() 함수를 통해 호출할 수 있지만, 내장 Matcher 중 대부분은 should로 시작하는 특화된 함수를 함께 제공합니다. 예로 shouldBeLessThan()이 있습니다.

인스펙터

Kotest는 Matcher 외에도 Matcher와 관련된 인스펙터라는 개념을 지원합니다. 인스펙터는 컬렉션 함수에 대한 확장 함수로, 주어진 단언문이 컬렉션 원소 중 어떤 그룹에 대해 성립하는지 검증할 수 있습니다.

  • forAll()/forNone(): 단언문을 모든 원소가 만족하는지, 어떤 원소도 만족하지 않는지 검사합니다.
  • forExactly(n): 단언문을 정확히 n개의 원소가 만족하는지 검사한다. n = 1인 경우에 특화된 forOne() 함수도 있습니다.
  • forAtLeast(n)/forAtMost(n): 단언문을 최소 n개의 원소가 만족하는지, 최대 n개의 원소가 만족하는지 검사합니다. n = 1이라면 forAtLeastOne()/forAtMostOne()을 쓸 수 있고, forAny()도 쓸 수 있스빈다.
  • forSome(): 단언문을 만족하는 원소가 존재하되, 모든 원소가 단언문을 만족하지 않음을 검사합니다.
import io.kotest.core.spec.style.StringSpec
import io.kotest.inspectors.forAll
import io.kotest.inspectors.forAny
import io.kotest.inspectors.forAtLeast
import io.kotest.inspectors.forAtLeastOne
import io.kotest.inspectors.forAtMost
import io.kotest.inspectors.forAtMostOne
import io.kotest.inspectors.forExactly
import io.kotest.inspectors.forNone
import io.kotest.inspectors.forOne
import io.kotest.inspectors.forSome
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
import io.kotest.matchers.shouldBe

class NumbersTestWithInspectors : StringSpec({
    val numbers = Array(10) { it + 1 }

    "all are non-negative" { numbers.forAll { it shouldBeGreaterThanOrEqual 0 } }

    "none is zero" { numbers.forNone { it shouldBe 0 } }

    "a single 10" { numbers.forOne { it shouldBe 10 } }

    "at most one 0" { numbers.forAtMostOne { it shouldBe 0 } }

    "at least one odd number" { numbers.forAtLeastOne { it % 2 shouldBe 1 } }

    "at most five odd numbers" { numbers.forAtMost(5) { it % 2 shouldBe 1 } }

    "at least three even numbers" { numbers.forAtLeast(3) { it % 2 shouldBe 0 } }

    "some numbers are odd" { numbers.forAny { it % 2 shouldBe 1 } }

    "some but not all numbers are even" { numbers.forSome { it % 2 shouldBe 0 } }

    "exactly five numbers are even" { numbers.forExactly(5) { it % 2 shouldBe 0 } }
})

예외 처리

Kotest는 어떤 코드가 특정 예외에 의해 중단됐는지 검사하는 shouldThrow() 단언문을 제공한다. 이 단언문은 try/catch로 예외를 잡아내는 방식을 간편하게 대신할 수 있다. 성공적인 경우 shouldThrow()는 여러분이 활용할 수 있도록 잡아낸 예외를 반환한다.

import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.string.shouldEndWith

class ParseTest : StringSpec({
    "invalid string" {
        val e = shouldThrow<NumberFormatException> { "abc".toInt() }
        e.message shouldEndWith "\"abc\""
    }
})

Kotest에서 예외와 관련해 유용한 기능은 실패한 단언문이 던진 예외를 일시적으로 무시하는 기능으로 소프트 단언문(soft assertion)이라고 부릅니다.

일반적으로는 맨 처음 예외가 발생한 시점에 테스트가 종료되므로 모든 실패한 단언문을 볼 수 없습니다. Kotest에서는 assertSoftly 블록을 사용해 이런 기본 동작을 우회할 수 있습니다. assertSoftly는 누적시킨 모든 예외를 한 AssertionError에 넣고 호출한 쪽에 던진다.

import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.StringSpec
import io.kotest.inspectors.forAll
import io.kotest.matchers.ints.shouldBeGreaterThan
import io.kotest.matchers.ints.shouldBeLessThan

class NumberTestWithForAll : StringSpec({
    val numbers = Array(10) { it + 1 }
    "invalid numbers" {
        assertSoftly {
            numbers.forAll { it shouldBeLessThan 5 }
            numbers.forAll { it shouldBeGreaterThan 3 }
        }
    }
})

비결정적 코드 테스트하기

여러 번 시도해야 테스트를 통과하곤 하는 비결정적 코드를 다뤄야 한다는 타임아웃과 반복 실행을 편리하게 처리할 수 있는 대안으로 eventually()함수가 있습니다.

import io.kotest.assertions.timing.eventually
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.io.File
import kotlin.time.Duration
import kotlin.time.ExperimentalTime

@OptIn(ExperimentalTime::class)
class StringSpecWithEventually : StringSpec({
    "10초 안에 파일의 내용이 단 한 줄인 경우가 있어야 함" {
        eventually(Duration.seconds(10)) { // Duration.seconds(10)을 권장
            // 주어진 기간 동안 파일이 한 줄만 들어있는 순간이 올 때까지 검사(최대 10초)
            File("data.txt").readLines().size shouldBe 1
        }
    }
})

continually() 함수는 단언문이 최초 호출 시 성립하고 그 이후 지정한 기간 동안 계속 성립한 채로 남아있는지 검사합니다.

import io.kotest.assertions.timing.continually
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.io.File
import kotlin.time.ExperimentalTime
import kotlin.time.seconds

@OptIn(ExperimentalTime::class)
class StringSpecWithEventually : StringSpec({
    "10초 안에 파일의 내용이 단 한 줄인 경우가 있어야 함" {
        continually(10.seconds) {
            File("data.txt").readLines().size shouldBe 1
        }
    }
})

속성 기반 테스트

Kotest는 술어를 지정하면 Kotest가 자동으로 술어를 검증하기 위한, 임의의 테스트 데이터를 생성해주는 속성 기반 테스트(property based test)를 지원합니다. 이 기법은 수동으로 준비하고 유지하기 어려운 큰 데이터 집합에 대해 성립해야 하는 조건을 테스트 해야하는 겨우에 유용합니다.

testImplementation "io.kotest:kotest-property:4.5.0"

예를 들어 두 수의 최소값을 구하는 함수가 있고 이것이 잘 동작하는지 테스트 하는 코드를 작성해봅니다.

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.and
import io.kotest.matchers.ints.beLessThanOrEqualTo
import io.kotest.matchers.should
import io.kotest.property.checkAll

infix fun Int.min(n: Int) = if (this < n) this else n

class NumberTestWithAssertAll : StringSpec({
    "min" {
        checkAll { a: Int, b: Int ->
            (a min b).let {
                it should (beLessThanOrEqualTo(a) and beLessThanOrEqualTo(b))
            }
        }
    }
})

checkAll을 사용하면 기본적으로 1000개 테스트 데이터 집합 원소가 생성됩니다.

단언문 대신 불 값을 반환하는 식을 사용할 수도 있습니다. 이 경우 forAll 안에 불 값을 반환하는 람다를 넣으면 됩니다. forAll과 반대로 모든 테스트 집합 원소에 대해 false를 반환할 때만 성공하는 검사가 필요하면 forNone을 사용하면 됩니다.

import io.kotest.core.spec.style.StringSpec
import io.kotest.property.forAll

infix fun Int.min(n: Int) = if (this < n) this else n

class NumberTestWithAssertAll : StringSpec({
    "min (단언문 대신 식 사용)" {
        forAll<Int, Int> { a, b ->
            (a min b).let { it <= a && it <= b }
        }
    }
})

Kotest는 Int, Boolean, String 등 일반적으로 쓰이는 여러 타입에 대한 디폴트 생성기를 제공합니다. 만약 직접 커스텀해서 사용하고 싶다면 Gen 추상클래스를 상속해야 합니다. Gen을 상속한 추상클래스로 Arb와 Exhaustive 클래스가 있습니다.

  • Arb: 미리 하드코딩퇸 엣지케이스(edge case)와 난수 샘플(random sample)을 생성해주는 생성기입니다. 생성기에 따라서는 엣지케이스 테스트 데이터를 제공하지 않을 수도 있습니다. 테스트를 진행할 때는 디폴트로 2%는 엣지케이스를, 98%는 난수 데이터를 발생시킵니다.
  • Exhaustive: 주어진 공간에 속한 모든 데이터를 생성해줍니다. 해당 공간의 모든 값을 사용하는지 검사하고 싶을 때 Exhaustive 타입의 생성기가 유용합니다.

직접 Arb나 Exhaustive를 상속해 추상 메서드를 구현하면 쉽게 생성기를 만들 수 있습니다. 

import io.kotest.core.spec.style.StringSpec
import io.kotest.property.Arb
import io.kotest.property.RandomSource
import io.kotest.property.Sample
import io.kotest.property.forAll
import kotlin.math.abs

class Rational private constructor(
    val sign: Int,
    val num: Int,
    val den: Int,
) {
    operator fun unaryMinus() = Rational(-sign, num, den)

    operator fun plus(r: Rational): Rational {
        val gcd = gcd(den, r.den)
        val newDen = den / gcd * r.den
        val newNum = newDen / den * num * sign + newDen / r.den * r.num * r.sign
        val newSign = newNum.sign()

        return Rational(newSign, abs(newNum), newDen)
    }

    operator fun minus(r: Rational) = this + (-r)

    operator fun times(r: Rational): Rational {
        return of(sign * r.sign * num * r.num, den * r.den)
    }

    operator fun div(r: Rational): Rational {
        return of(sign * r.sign * num * r.den, den * r.num)
    }

    override fun toString(): String {
        return "${sign * num}" + if (den != 1) "/$den" else ""
    }

    companion object {
        private fun Int.sign() = when {
            this > 0 -> 1
            this < 0 -> -1
            else -> 0
        }

        private tailrec fun gcd(a: Int, b: Int): Int {
            return if (b == 0) a else gcd(b, a % b)
        }

        fun of(num: Int, den: Int = 1): Rational {
            if (den == 0) throw ArithmeticException("Denominator is zero")

            val sign = num.sign() * den.sign()
            val numAbs = abs(num)
            val denAbs = abs(den)
            val gcd = gcd(numAbs, denAbs)

            return Rational(sign, numAbs / gcd, denAbs / gcd)
        }
    }
}

class RationalTest : StringSpec({
    "Subtraction (Arb 사용)" {
        forAll(
            // 첫 번째 인자로 Arb<Rational> 인스턴스를 넘김
            object : Arb<Rational>() {
                override fun edgecase(rs: RandomSource): Rational? {
                    return null
                }

                override fun sample(rs: RandomSource): Sample<Rational> {
                    return Sample(Rational.of(rs.random.nextInt(), rs.random.nextInt(0, Int.MAX_VALUE)))
                }
            }
        ) { a: Rational ->
            (a - a).num == 0
        }
    }
})

 

위처럼 하는 방식 말고 Kotest가 제공하는 기본 생성기를 조합해서 사용하는 방법이 있습니다.

  • Arb.in(range), Arb.long(range), Arb.nats(range)...: 범위(range)에 속한 수를 임의로 선택합니다. 범위를 지정하지 않으면 이름을 암시하는 영역에 속하는 모든 수 중에 난수를 생성합니다. 엣지케이스를 제공하며 보통 0, -1, +1, Int.MIN_VALUE, Int.MAX_VALUE 등의 값 중 의미가 있는 것이 선택됩니다.
  • Exhaustive.ints(range), Exhaustive.longs(range): 범위에 속한 모든 수를 테스트 데이터로 생성합니다.
  • Arb.string(range), ARb.stringPattern(pattern)...: 주어진 범위에 속하는 문자열이나 주어진 패턴에 부합하는 문자열을 생성합니다.
  • arb.orNull(), arb.orNull(nullProbability): Arb가 만들어낸 값인 arb에 널 값을 섞은 데이터를 생성합니다. 널이 될 확률을 지정할 수 있습니다.

이런 제너레이터를 엮어서 다른 생성기를 만드럭나 제네레이터에서 값을 가져오는 연산을 제공합니다. 우선 gen.next()를 통해 생성기로부터 다음 값을 가져올 수 있고 filter(), map(), flatMap(), merge() 등의 컬렉션 연산도 제공합니다. bind() 메서드는 여러 제너레이터로부터 얻은 데이터를 한꺼번에 엮을 때 사용합니다. 이런 연산을 통하면 기존 생성기를 조합한 새로운 생성기를 쉽게 얻을 수 있다.

import io.kotest.core.spec.style.StringSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.bind
import io.kotest.property.arbitrary.int
import io.kotest.property.forAll
import kotlin.math.abs

class Rational private constructor(
    val sign: Int,
    val num: Int,
    val den: Int,
) {
    operator fun unaryMinus() = Rational(-sign, num, den)

    operator fun plus(r: Rational): Rational {
        val gcd = gcd(den, r.den)
        val newDen = den / gcd * r.den
        val newNum = newDen / den * num * sign + newDen / r.den * r.num * r.sign
        val newSign = newNum.sign()

        return Rational(newSign, abs(newNum), newDen)
    }

    operator fun minus(r: Rational) = this + (-r)

    operator fun times(r: Rational): Rational {
        return of(sign * r.sign * num * r.num, den * r.den)
    }

    operator fun div(r: Rational): Rational {
        return of(sign * r.sign * num * r.den, den * r.num)
    }

    override fun toString(): String {
        return "${sign * num}" + if (den != 1) "/$den" else ""
    }

    companion object {
        private fun Int.sign() = when {
            this > 0 -> 1
            this < 0 -> -1
            else -> 0
        }

        private tailrec fun gcd(a: Int, b: Int): Int {
            return if (b == 0) a else gcd(b, a % b)
        }

        fun of(num: Int, den: Int = 1): Rational {
            if (den == 0) throw ArithmeticException("Denominator is zero")

            val sign = num.sign() * den.sign()
            val numAbs = abs(num)
            val denAbs = abs(den)
            val gcd = gcd(numAbs, denAbs)

            return Rational(sign, numAbs / gcd, denAbs / gcd)
        }
    }
}

class RationalTest : StringSpec({
    val rationalArb = Arb.bind(Arb.int(), Arb.int(0, Int.MAX_VALUE)) { x, y -> Rational.of(x, y) }

    "Subtraction (Arb.int()와 Arb.bind() 사용)" {
        forAll(rationalArb) { a: Rational ->
            (a - a).num == 0
        }
    }
})

좀 더 편리하게 Arb나 Exhaustive를 구현할 수 있는 빌더 함수도 있습니다. Arb는 구현할 때는 arbitrary()를 사용하고, Exhaustive를 구현할 때는 리스트 객체에 대해 exhaustive() 확장 함수를 호출하면 됩니다. 앞의 유리수 테스트를 arbitrary()를 사용해 다시 구현해 봅니다.

import io.kotest.core.spec.style.StringSpec
import io.kotest.property.arbitrary.arbitrary
import io.kotest.property.forAll
import kotlin.math.abs

class Rational private constructor(
    val sign: Int,
    val num: Int,
    val den: Int,
) {
    operator fun unaryMinus() = Rational(-sign, num, den)

    operator fun plus(r: Rational): Rational {
        val gcd = gcd(den, r.den)
        val newDen = den / gcd * r.den
        val newNum = newDen / den * num * sign + newDen / r.den * r.num * r.sign
        val newSign = newNum.sign()

        return Rational(newSign, abs(newNum), newDen)
    }

    operator fun minus(r: Rational) = this + (-r)

    operator fun times(r: Rational): Rational {
        return of(sign * r.sign * num * r.num, den * r.den)
    }

    operator fun div(r: Rational): Rational {
        return of(sign * r.sign * num * r.den, den * r.num)
    }

    override fun toString(): String {
        return "${sign * num}" + if (den != 1) "/$den" else ""
    }

    companion object {
        private fun Int.sign() = when {
            this > 0 -> 1
            this < 0 -> -1
            else -> 0
        }

        private tailrec fun gcd(a: Int, b: Int): Int {
            return if (b == 0) a else gcd(b, a % b)
        }

        fun of(num: Int, den: Int = 1): Rational {
            if (den == 0) throw ArithmeticException("Denominator is zero")

            val sign = num.sign() * den.sign()
            val numAbs = abs(num)
            val denAbs = abs(den)
            val gcd = gcd(numAbs, denAbs)

            return Rational(sign, numAbs / gcd, denAbs / gcd)
        }
    }
}

class RationalTest : StringSpec({
    val rationalArb = arbitrary { Rational.of(it.random.nextInt(), it.random.nextInt(0, Int.MAX_VALUE)) }

    "Subtraction (arbitrary() 사용)" {
        forAll(rationalArb) { a: Rational ->
            (a - a).num == 0
        }
    }
})

 

속성 기반 테스트 프레임워크가 제공하는 데이터나 커스텀 생성기 대신 고정된 데이터 집합을 사용해 테스트를 진행할 수도 있습니다.

io.kotest.data 패키지에는 데이터 기반 테스트를 지원하는 클래스와 함수들이 정의돼 있습니다.

import io.kotest.core.spec.style.StringSpec
import io.kotest.data.forAll
import io.kotest.data.row

infix fun Int.min(n: Int) = if (this < n) this else n

class DataDrivenTest : StringSpec({
    "Minimum" {
        forAll(
            row(1, 1),
            row(1, 2),
            row(2, 1),
        ) { a: Int, b: Int ->
            (a min b).let { it <= a && it <= b }
        }
    }
})

행만 사용하는 대신, 구체적인 헤더를 제공하는 테이블 형태의 객체를 사용할 수도 있습니다.

import io.kotest.core.spec.style.StringSpec
import io.kotest.data.forAll
import io.kotest.data.headers
import io.kotest.data.row
import io.kotest.data.table
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual

class DataDrivenTest : StringSpec({
    "Minimum" {
        forAll(
            table(
                headers("nmae", "age"),
                row("John", 20),
                row("Harry", 25),
                row("Bob", 16),
                row("Dino", 31)
            )
        ) { name: String, age: Int ->
            age shouldBeGreaterThanOrEqual 18
        }
    }
})

// java.lang.AssertionError: Test failed for (nmae, "Bob"), (age, 16) with error 16 should be >= 18

이 방식은 forAll()과 forNone()을 모두 지원하지만 두 함수는 속성 기반 테스트의 forAll()/forNone()과 달리 Unit을 반환하는 람다를 받습니다. 따라서 람다 내부에서 불 값을 반환하는 대신 Matcher를 사용해야 합니다.

픽스처와 설정

픽스처 제공하기

실제 테스트를 진행하기 위해 필요한 환경과 자원을 초기화하고 테스트가 끝나면 정리해야하는 경우가 있는데 코테스트에서는 TestListener interface를 구현해 픽스처를 지정할 수 있습니다.

 

이 코드만 별도로 실행할 수 있도록 fixture라는 디렉터리를 만들고 그 아래에 파일을 저장하라. 인텔리J IDEA의 프로젝트 창에서 해당 디렉터리를 오른쪽 클릭 한 후 Run Tests in fixture를 선택하면 fixture 패키지에 들어있는 테스트만 실행 할 수 있습니다.

package fixture

import io.kotest.core.listeners.TestListener
import io.kotest.core.spec.Spec
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.test.TestCase
import io.kotest.core.test.TestResult
import io.kotest.matchers.shouldBe
import kotlin.reflect.KClass

object SpecLevelListener : TestListener {
    override suspend fun prepareSpec(kclass: KClass<out Spec>) {
        println("PrepareSpec(in SpecLevelListener): ${kclass.qualifiedName}")
    }

    override suspend fun beforeSpec(spec: Spec) {
        println("BeforeSpec: ${spec.materializeRootTests().joinToString { it.testCase.displayName }}")
    }

    override suspend fun beforeTest(testCase: TestCase) {
        println("BeforeTest: ${testCase.displayName}")
    }

    override suspend fun afterTest(testCase: TestCase, result: TestResult) {
        println("AfterTest: ${testCase.displayName}")
    }

    override suspend fun afterSpec(spec: Spec) {
        println("AfterSpec: ${spec.materializeRootTests().joinToString { it.testCase.displayName }}")
    }

    override suspend fun finalizeSpec(kclass: KClass<out Spec>, results: Map<TestCase, TestResult>) {
        println("FinalizeSpec(in SpecLevelListener): ${kclass.qualifiedName}")
    }
}

class NumbersTestWithFixture1 : FunSpec() {
    init {
        context("Addition") {
            test("2 + 2") {
                (2 + 2) shouldBe 4
            }
            test("4 + 4") {
                (4 + 4) shouldBe 8
            }
        }
    }

    override fun listeners(): List<TestListener> {
        return listOf(SpecLevelListener)
    }
}

class NumbersTestWithFixture2 : FunSpec() {
    init {
        context("Multiplication") {
            test("2 * 2") {
                (2 * 2) shouldBe 4
            }
            test("4 * 4") {
                (4 * 4) shouldBe 16
            }
        }
    }

    override fun listeners(): List<TestListener> {
        return listOf(SpecLevelListener)
    }
}

// BeforeSpec: Addition
// BeforeTest: Addition
// BeforeTest: 2 + 2
// AfterTest: 2 + 2
// BeforeTest: 4 + 4
// AfterTest: 4 + 4
// AfterTest: Addition
// AfterSpec: Addition
// BeforeSpec: Multiplication
// BeforeTest: Multiplication
// BeforeTest: 2 * 2
// AfterTest: 2 * 2
// BeforeTest: 4 * 4
// AfterTest: 4 * 4
// AfterTest: Multiplication
// AfterSpec: Multiplication

beforeTest()는 테스트마다 실행되고 테스트가 활성화된 경우에만 호출됩니다.

beforeSpec()은 어떤 명세가 인스턴스화될 때 실행됩니다.

따라서 테스트 함수가 실제 호출될 때 마다 불려야 하면 beforeTest()/afterTest()를 사용하고 명세 클래스의 인스턴스마다 호출하려면 beforeSpec()/afterSpec()을 사용합니다.

하지만 prepareSpec()과 finalizeSpec()은 호출되지 않았습니다.

프로젝트 수준의 리스너인 beforeProject()/afterProject() 구현을 제공하고 싶다면 ProjectConfig 타입의 싱글턴 객체에 리스너를 등록해야합니다. 바로 여기에 prepareSpec()과 finalizeSpec()도 같이 제공됩니다.

package fixture

import io.kotest.core.config.AbstractProjectConfig
import io.kotest.core.listeners.Listener
import io.kotest.core.listeners.ProjectListener
import io.kotest.core.listeners.TestListener
import io.kotest.core.spec.Spec
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.test.TestCase
import io.kotest.core.test.TestResult
import io.kotest.matchers.shouldBe
import kotlin.reflect.KClass

object SpecLevelListener : TestListener {
    override suspend fun prepareSpec(kclass: KClass<out Spec>) {
        println("PrepareSpec(in SpecLevelListener): ${kclass.qualifiedName}")
    }

    override suspend fun beforeSpec(spec: Spec) {
        println("BeforeSpec: ${spec.materializeRootTests().joinToString { it.testCase.displayName }}")
    }

    override suspend fun beforeTest(testCase: TestCase) {
        println("BeforeTest: ${testCase.displayName}")
    }

    override suspend fun afterTest(testCase: TestCase, result: TestResult) {
        println("AfterTest: ${testCase.displayName}")
    }

    override suspend fun afterSpec(spec: Spec) {
        println("AfterSpec: ${spec.materializeRootTests().joinToString { it.testCase.displayName }}")
    }

    override suspend fun finalizeSpec(kclass: KClass<out Spec>, results: Map<TestCase, TestResult>) {
        println("FinalizeSpec(in SpecLevelListener): ${kclass.qualifiedName}")
    }
}

class NumbersTestWithFixture1 : FunSpec() {
    init {
        context("Addition") {
            test("2 + 2") {
                (2 + 2) shouldBe 4
            }
            test("4 + 4") {
                (4 + 4) shouldBe 8
            }
        }
    }

    override fun listeners(): List<TestListener> {
        return listOf(SpecLevelListener)
    }
}

class NumbersTestWithFixture2 : FunSpec() {
    init {
        context("Multiplication") {
            test("2 * 2") {
                (2 * 2) shouldBe 4
            }
            test("4 * 4") {
                (4 * 4) shouldBe 16
            }
        }
    }

    override fun listeners(): List<TestListener> {
        return listOf(SpecLevelListener)
    }
}


object MyProjectListener : ProjectListener, TestListener {
    override val name: String = "MyProjectListener"

    override suspend fun beforeProject() {
        println("Before project")
    }

    override suspend fun afterProject() {
        println("After project")
    }

    override suspend fun prepareSpec(kclass: KClass<out Spec>) {
        println("PrepareSpec: ${kclass.qualifiedName}")
    }

    override suspend fun finalizeSpec(kclass: KClass<out Spec>, results: Map<TestCase, TestResult>) {
        println("FinalizeSpec: ${kclass.qualifiedName}")
    }
}

object ProjectConfig : AbstractProjectConfig() {
    override fun listeners(): List<Listener> {
        return listOf(MyProjectListener)
    }
}

// Before project
// PrepareSpec: fixture.NumbersTestWithFixture1
// ...
// FinalizeSpec: fixture.NumbersTestWithFixture2
// After project

한 가지 더 유용한 기능으로 AutoCloseable Interface를 구현하면 자동으로 해제해주는 기능이 있습니다.

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.io.FileReader

class FileTest : FunSpec() {
    val reader = autoClose(FileReader("data.txt"))

    init {
        test("Line count") {
            reader.readLines().isNotEmpty() shouldBe true
        }
    }
}

테스트 설정

Kotest는 테스트 환경을 설정할 수 있는 여러 가지 수단을 제공합니다. config() 함수를 통해 파라미터를 지정할 수 있습니다.

  • invocations: 테스트 실행 횟수, 모든 실행이 성공해야 테스트가 성공한 것으로 간주합니다. 간헐적으로 실패하는 비결정적 테스트가 있을 때 이런 옵션이 유용합니다.
  • threads: 테스트를 실행할 스레드 개수, invocations가 2 이상일 때만 이 옵션이 유용하고 실행 횟수가 1이라면 병렬화 할 여지가 없습니다.
  • enabled: 테스트를 실행해야 할지 여부, false로 설정하면 테스트 실행을 비활성화 합니다.
  • timeout: 테스트 실행에 걸리는 최대 시간, 테스트 실행시간이 이 타임아웃 값을 넘어서면 종료되고 실패로 간주합니다. 비결정적 테스트에서 유용합니다.
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import kotlin.time.Duration.Companion.minutes
import kotlin.time.ExperimentalTime

@OptIn(ExperimentalTime::class)
class StringSpecWithConfig : StringSpec({
    "2 + 2 should be 4".config(invocations = 10) { (2 + 2) shouldBe 4 }
})

@OptIn(ExperimentalTime::class)
class ShouldSpecWithConfig : ShouldSpec({
    context("Addition") {
        context("1 + 2") {
            should("be equal to 3").config(threads = 2, invocations = 100) {
                (1 + 2) shouldBe 3
            }
            should("be equal to 2 + 1").config(timeout = 1.minutes) {
                (1 + 2) shouldBe (2 + 1)
            }
        }
    }
})

@OptIn(ExperimentalTime::class)
class BehaviorSpecWithConfig : BehaviorSpec({
    Given("Arithmetic") {
        When("x is 1") {
            val x = 1
            And("increased by 1") {
                then("result it 2").config(invocations = 100) {
                    (x + 1) shouldBe 2
                }
            }
        }
    }
})

thread 옵션은 한 테스트 케이스에 속한 개별 테스트를 병렬화할 때만 영향을 미친다. 이런 테스트를 하고 싶다면 AbstractProjectConfig 클래스를 사용해야하고 parallelism 프로퍼티를 오버라이드해서 원하는 동시성 스레드 개수를 지정해야합니다.

import io.kotest.core.config.AbstractProjectConfig

object ProjectConfig : AbstractProjectConfig() {
    override val parallelism: Int? = 4
}

각 테스트를 개별적으로 설정하는 방법 외에도 defaultConfig() 함수를 오버라이드해서 한 명세서에 속한 모든 테스트 케이스의 설정을 한꺼번에 변경할 수 있습니다.

import io.kotest.core.spec.style.StringSpec
import io.kotest.core.test.TestCaseConfig
import io.kotest.matchers.shouldBe

class StringSpecWithConfig : StringSpec({
    "2 + 2 should be 4" { (2 + 2) shouldBe 4 }
}) {
    override fun defaultConfig(): TestCaseConfig {
        return TestCaseConfig(invocations = 10, threads = 2)
    }
}

 

테스트 사이에 테스트 케이스 인스턴스를 공유하는 방법을 격리 모드(isolation mode)라고 부릅니다. 모든 테스트 케이스는 기본적으로 한 번만 인스턴스화되고 모든 테스트에 같은 인스턴스를 사용합니다. 성능 면에서는 좋지만 일부 시나리오에서는 좋지 않을 수 있습니다. 

isolationMode는 3가지가 있습니다.

  • SingleInstance: 테스트 케이스의 인스턴스가 하나만 만들어집니다. 이 옵션이 디폴트 동작입니다.
  • InstancePerTest: 문맥이나 테스트 블록이 실행될 때마다 테스트 케이스의 새 인스턴스를 만듭니다.
  • InstancePerLeaf: (말단에 있는) 개별 테스트 블록을 실행하기 전에 테스트가 인스턴스화 됩니다.
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe

class IncTest : FunSpec() {
    var x = 0

    init {
        context("Increment") {
            println("Increment")
            test("prefix") {
                println("prefix")
                ++x shouldBe 1
            }
            test("postfix") {
                println("postfix")
                x++ shouldBe 0
            }

        }
    }
}

위 테스트의 경우는 두 번째 테스트 케이스가 실패합니다. 직전 테스트에서 대입한 값이 x 변수에 들어있기 때문입니다.

object IncTestProjectConfig: AbstractProjectConfig() {
    override val isolationMode: IsolationMode? = IsolationMode.InstancePerTest
}

// Increment
// Increment
// prefix
// Increment
// postfix

위 코드를 추가하면 두 테스트는 모두 통과하고 출력은 위와 같이 나옵니다.

Increment가 세 번 표시되는 이유는 IncTest가 세 번 인스턴스화 되기 때문입니다. 첫 번째는 문맥블록을 실행하기 위해 초기화되고, 두 번째는 문맥 블록을 실행하고 그 안의 prefix 테스트를 실행하기 위해 초기화되고, 세 번째는 postfix 테스트를 실행하기 위해 초기화 됩니다.

 

isolationMode를 InstancePerLeaf로 하면 문맥 블록 자체만 실행되는 일은 없어지고, 개별 테스트를 실행할 때만 문맥 블록을 실행합니다.

object IncTestProjectConfig: AbstractProjectConfig() {
    override val isolationMode: IsolationMode? = IsolationMode.InstancePerLeaf
}

// Increment
// prefix
// Increment
// postfix
728x90
반응형