반응형
Notice
Recent Posts
Recent Comments
Link
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
Tags
more
Archives
Today
Total
관리 메뉴

안드로이드 개발자 노트

[코틀린 완벽 가이드] 14장 : 코틀린 테스팅 본문

Kotlin/코틀린 완벽 가이드

[코틀린 완벽 가이드] 14장 : 코틀린 테스팅

어리둥절범고래 2022. 12. 12. 17:24
반응형

1. 코테스트 명세


1. 코테스트 시작하기

 

코테스트 io.kotest:kotest-runner-junit5-jvm:4.5.0 을 사용한다.


2. 명세 스타일

 

코테스트는 여러 명세 스타일을 지원한다. 프로젝트에서 여러 스타일을 섞어 쓰거나 AbstractSpec 클래스 또는 AbstractSpec 클래스의 하위 클래스 중 하나인 AbstractStringSpec과 같은 클래스를 구현함으로써 명세 스타일을 커스텀할 수 있다. 

 

테스트 케이스를 정의하려면 명세 클래스 중 하나를 상속해야 한다. 그 후 클래스 생성자에 테스트를 추가하거나 상위 클래스 생성자에게 전달하는 람다 안에 테스트를 추가한다. 테스트 자체를 정의하는 방법은 스타일에 따라 달라지며, 대부분의 경우 DSL과 비슷한 API를 통해 테스트를 정의한다. 아래는 StringSpec 클래스를 사용하는 예제다.

 

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

StringSpec에서는 테스트에 대한 설명이 들어있는 문자열 뒤에 람다를 추가해서 개별 테스트를 작성한다. 이는 StringSpec이 정의한 String.invoke() 확장 함수를 호출하는 것에 지나지 않는다. 예제에서 실제 검증 코드는 shouldBe 중위 연산자 함수를 사용하며, 이 함수는 수신 객체로 받은 값과 인자로 받은 값이 일치하지 않으면 예외를 던진다.

 

StringSpec은 모든 테스트가 한 클래스 안에 들어가고 모든 테스트가 같은 수준에 정의돼 있는 평평한 구조를 만들어낸다. 테스트 블록을 다른 블록 안에 넣으려고 시도하면 프레임워크가 런타임에 예외를 발생시키면서 실패한다.

 

 

 

WordSpec 클래스를 사용하면 더 복잡한 테스트 레이아웃을 만들 수 있다. WordSpec을 가장 단순한 형태로 사용하면 테스트를 정의하는 부분에서 2단계로 이뤄진 계층 구조를 만들 수 있다. 이때 각 테스트는 StringSpec과 비슷하며, should() 함수 호출에 의해 각각의 그룹으로 묶인다.

class NumbersTest2 : 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단계로 구성할 수도 있다.

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

 

 

 

FunSpec 클래스를 사용하면 테스트 계층을 원하는 만큼 깊게 만들 수 있다. 이 클래스는 테스트 코드를 test() 함수 호출로 묶고 테스트에 대한 설명과 실행할 일시 중단 함수를 인자로 받는다. StringSpec과 달리 이 스타일은 context 블록으로 테스트를 한 그룹으로 묶을 수 있다.

class NumbersTest3 : 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}
        }
    }
})

test와 context 블록을 어떤 깊이에서도 사용할 수 있지만 test 블록을 test 블록 안에서 쓸 수는 없다.

 

 

 

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

class NumbersTest4 : ExpectSpec({
    context("Arithmetic") {
        context("Addition") {
            expect("2 + 2 should be 4") { (2 + 2) shouldBe 4}
        }
        context("Multiplication") {
            expect("2 * 2 should be 4") { (2 * 2) shouldBe 4}
        }
    }
})

 

 

 

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

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

 

 

 

ShouldSpec은 FunSpec과 비슷한 레이아웃을 만들어낸다. 이 명세는 문맥 블록을 그룹짓는 데 사용하고, 말단에 테스트 블록을 위치시킨다. 차이가 있다면 구문의 차이뿐이다. 테스트를 설명하는 문자열에 대해 context()를, 테스트 블록 should() 함수를 호출한다.

class NumbersTest6 : 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()를 사용해 테스트를 정의하며, - (minus() 연산자)를 통해 문맥을 소개한다.

class NumbersTest7 : 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) }
        }
    }
})

 

 

 

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

class NumbersTest8 : 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 블록을 묶어서 그룹 수준을 추가할 수도 있다.

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

이러한 블록으로 자연어에 가까운 테스트 설명을 작성할 수 있다('when x is 1 and increased by 1 then result is 2').

 

 

 

마지막으로 AnnotationSpec은 DSL 같은 테스트 명세를 사용하지 않고 테스트 클래스 메서드에 붙인 @Test 애너테이션에 의존한다.

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

테스트에 @Ignore를 붙이면 어떤 테스트를 비활성화할 수도 있다.

 

 

 

 


2. 단언문


1. 매처

 

매처는 일반 함수 호출이나 중위 연산자 형태로 사용할 수 있는 확장 함수로 정의되며 모든 매처 이름은 shouldBe로 시작한다. 대부분의 매처 이름이 매처 자신의 역할을 잘 설명하고 직관적으로 사용할 수 있다.

 

커스텀 매처를 정의하려면 Matcher 인터페이스를 구현하고 이 인터페이스의 test() 메서드를 오버라이드해야 한다.

abstract fun test(value: T) : MatcherResult

 

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

  • passed: 단언문을 만족하는지(true) 만족하지 않는지(false)를 나타냄
  • failureMessage: 단언문 실패를 보여주고 단언문을 성공시키려면 ㅇ떤 일을 해야하는지 알려주는 메시지
  • negatedFailureMessage: 매처를 반전시킨 버전을 사용하는데 매처가 실패하는 경우 표시해야 하는 메시지

 

 

다음은 주어진 수가 홀수인지 검사하는 예제다.

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

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

매처를 만들어 should() / shouldNot() 등의 내장 확장 함수에 넘겨서 단언문에 사용할 수 있다.

 

 

Matcher 인터페이스의 구현은 자동으로 and / or / invert 연산을 지원한다. 이 연산들은 부울 연산 규칙에 따라 매처를 합성해준다. 이런 연산을 활용해 복잡한 술어로 구성된 단언문을 구성할 수 있다.

 

다음 예제는 beOdd() 매처와 내장 positive() 매처를 합성한다.

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

 

 

매처가 지원하는 또 하나의 연산으로 compose()가 있다. 이 연산을 사용하면 기존 매처에 타입 변환 함수를 추가함으로써 새로운 타입에 대한 매처를 만들어준다.

 

다음 함수는 beOdd() 매처를 재사용해 주어진 컬렉션의 길이가 홀수인지 검사한다.

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

 

 

 

 


2. 인스펙터

 

인스펙터는 컬렉션 함수에 대한 확장 함수로, 주어진 단언문이 컬렉션 원소 중 어떤 그룹에 대해 성립하는지 검증할 수 있게 해준다.

  • forAll() / forNone(): 단언문을 모든 원소가 만족하는지(forAll()), 어느 원소도 만족하지 않는지(forNone()) 검사한다.
  • forExactly(n): 단언문을 정확히 n개의 원소가 만족하는지 검사한다. (n이 1인 경우 forOne() 함수도 있다.)
  • forAtLeast(n) / forAtMost(n): 단언문을 최소 n개의 원소가 만족하는지(forAtLeast(n)), 최대 n개의 원소가 만족하는지(forAtMost(n)) 검사한다. n이 1이라면 forAtLeastOne() / forAtMostOne() / forAny()을 쓸 수 있다.
  • forSome(): 단언문을 만족하는 원소가 존재하되, 모든 원소가 단언문을 만족하지는 않음을 검사한다.

 

다음은 인스펙터를 사용하는 예제다.

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

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

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

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

    "at least on 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 }
    }
})

 

 

 


3. 예외 처리

 

코테스트는 어떤 코드가 특정 예외에 의해 중단됐는지 검사하는 shouldThrow() 단언문을 제공한다. shouldThrow()는 잡아낸 예외를 반환한다.

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

 

 

코테스트에서 실패한 단언문이 던진 예외를 일시적으로 무시하는 기능을 소프트 단언문(soft assertion)이라고 부른다. 일반적으로는 맨 처음 예외가 발생한 시점에서 테스트가 종료되므로 모든 실패한 단언문을 볼 수 없지만, 소프트 단언문을 통해 테스트의 여러 단언문 중 실패한 단언문을 모두 보고싶은 경우 유용하다.

 

코테스트에서는 assertSofty 블록을 사용하면 내부 에서 발생한 AssertionError 예외를 잡아낸 후 누적시키면서 모든 단언문을 실행한다. 블록이 끝나면 assertSofty는 누적시킨 모든 예외를 한 AssertionError에 넣고 호출한 쪽에 던진다.

 

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

위 예제에서 assertSoftly()가 없다면, 첫 번째 forAll()이 실패하기 때문에 두 번째 forAll() 단언문은 아예 실행되지도 않는다. 하지만 이 코드에서는 assertSoftly()가 있으므로 두 단언문이 모두 실행되고 테스트가 예외와 함께 종료된다.

 

 

 


4. 비결정적 코드 테스트하기

 

코테스트에는 여러 번 시도해야 테스트를 통과하곤 하는 비결정적 코드를 다뤄야 할때 타임아웃과 반복 실행을 할 수 있는 eventually() 함수가 있다. 이 함수는 정해진 기간 안에 주어진 단언문을 만족시키는 경우가 한 번이라도 생기는지 검사한다.

 

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

 

 

 

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

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

 

 

 


5. 속성 기반 테스트

 

코테스트는 술어를 지정하여 술어를 검증하기 위한, 임의의 테스트 데이터를 생성해주는 속성 기반 테스트(property based test)를 지원한다. 입력과 기대값을 제공하고, 두 값이 일치하는지 확인하여 일치하지 않으면 테스트에 실패한다. 이 기법은 수동으로 준비하고 유지하기 어려운 큰 데이터 집합에 대해 성립해야 하는 조건을 테스트해야 하는 경우 유용하다.

 

예를 들어 두 수의 최솟값을 구하는 함수가 있다.

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

 

결과가 두 객체보다 항상 작거나 같은지 검사하고 싶다면 단언문을 checkAll()로 감쌀 수 있다.

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

 

 

 

forAll 안에 단언문 대신 불 값을 반환하는 람다를 넣으면 불 값을 반환한다. 이 경우 생성한 모든 테스트 데이터에 대해 람다가 true를 반환해야 테스트가 성공한다. 반대로 모든 테스트 집합 원소에 대해 false를 반환할 때만 성공하는 검사는 forNone()을 사용하면 된다.

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

 

 

 

코테스트 속성 기반 테스트의 생성기는 Gen이라는 추상 클래스를 상속하는데, 크게 임의의 값을 생성하는 생성기와 정해진 값을 모두 돌려주는 생성기로 나뉜다. 두 유형의 생성기를 표현하는 추상 클래스는 다음과 같다.

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

 

 

직접 Arb나 Exhaustive를 상속해 추상 메서드를 구현하면 생성기를 만들 수 있지만 코테스트가 제공하는 기본 생성기를 조합하여 생성기를 쉽게 만들 수 있다.

  • Arb.int(range), Arb.long(range), Arb.nats(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에 널 값을 섞은 데이터를 생성한다. 널이 될 확률을 지정할 수도 있다.

이런 제너레이터를 엮어서 다른 생성기를 만들 수 있다.

 

 

좀 더 편하게 Arb나 Exhausive를 구현할 수 있는 빌더 함수도 있다. Arb는 arbitrary()를, Exhaustive는 리스트 객체에 대해 exhaustive() 확장 함수를 호출하면 된다.

 

 


3. 픽스처와 설정


1. 픽스처 제공하기

 

테스트 픽스처는 실제 테스트를 진행하기 위해 필요한 자원을 초기화하고 테스트가 끝나면 정리하는 환경을 말한다. 코테스트에서는 TestListener 인터페이스를 구현해 픽스처를 지정할 수 있다. 이 인터페이스는 BeforeTestListener 등의 개별 픽스처 인터페이스를 모아둔 리스너다.

 

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> = 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> = 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()을 사용해야 한다. 또한, TestListener에 정의된 prepareSpec과 finalizeSpec은 개별 Spec 안에서 listener를 오버라이드하는 경우 정상 작동하지 않는다.

 

 

프로젝트 수준의 리스너인 beforeProject()/afterProject() 구현을 제공하고 싶다면 ProjectConfig 타입의 싱글턴 객체에 리스너를 등록해야 한다. 이 ProjectConfig 타입의 객체에 리스너를 등록할 때 prepareSpec()과 finalizeSpec() 메서드 구현도 함께 제공하면 다음과 같다.

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> = listOf(MyProjectListener)
}
/*
    Before project
    PrepareSpec: fixture.NumbersTestWithFixture1
    BeforeSpec: Addition
    BeforeTest: Addition
    BeforeTest: 2 + 2
    AfterTest: 2 + 2
    BeforeTest: 4 + 4
    AfterTest: 4 + 4
    AfterTest: Addition
    AfterSpec: Addition
    FinalizeSpec: fixture.NumbersTestWithFixture1
    PrepareSpec: fixture.NumbersTestWithFixture2
    BeforeSpec: Multiplication
    BeforeTest: Multiplication
    BeforeTest: 2 * 2
    AfterTest: 2 * 2
    BeforeTest: 4 * 4
    AfterTest: 4 * 4
    AfterTest: Multiplication
    AfterSpec: Multiplication
    FinalizeSpec: NumbersTestWithFixture2
    After project
 */

 

 

 

추가적인 기능으로, 자원을 할당할 때 AutoCloseable 인터페이스를 구현한 autoClose() 호출로 자원을 감싸면 구현한 자원을 자동으로 해제해준다.

class FileTest : FunSpec() {
    val reader = autoClose(FileReader("data.txt"))
    
    init {
        test("Line count") {
            reader.readLines().isNotEmpty() shouldBe true
        }
    }
}

 

 

 


2. 테스트 설정

 

코테스트는 테스트 환경을 설정할 수 있는 여러 가지 수단을 제공한다. config() 함수를 통해 여러 가지 테스트 실행 파라미터를 지정할 수 있으며 명세의 스타일에 따라 달라지지만, 일반적으로는 config()가 적용된 테스트 블록의 동작을 변경한다.

 

class StringSpecWithConfig : StringSpec({
    "2 + 2 should be 4".config(invocations = 10) { (2 + 2) shouldBe 4 }
})
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)
            }
        }
    }
})

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

 

config() 함수의 파라미터는 다음과 같다.

  • invocations: 테스트 실행 횟수, 모든 실행이 성공해야 테스트가 성공한 것으로 간주한다. 간헐적으로 실패하는 비결정적 테스트가 있을 때 유용하다.
  • threads: 테스트를 실행할 스레드 개수, invocations가 2 이상일 때만 이 옵션이 유용하다.
  • enabled: 테스트를 실행해야 할지 여부, false로 설정하면 테스트 실행을 비활성화한다.
  • timeout: 테스트 실행에 걸리는 최대 시간, 테스트 실행 시간이 이 타임아웃 값을 넘어서면 테스트가 종료되고 실패로 간주된다. 실행 횟수 옵션과 마찬가지로 비결정적 테스트에 유용하다.

 

 

threads 옵션은 한 테스트 케이스에 속한 개별 테스트를 병렬화할 때만 영향을 미친다.여러 테스트 케이스를 병렬로 실행하고 싶다면 AbstractProjectConfig를 사용해 parallelism 프로퍼티를 오버라이드하여 동시성 스레드 개수를 지정하면 된다.

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

 

 

 

defaultConfig() 함수를 오버라이드해서 한 명세에 속한 모든 테스트 케이스의 설정을 한꺼번에 변경할 수도 있다.

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

 

 

 

격리 모드(isolation mode)는 테스트 사이에 테스트 케이스 인스턴스를 공유하는 방법을 지정하는 기능이다. 모든 테스트 케이스는 기본적으로 한 번만 인스턴스화되고 모든 테스트에 같은 인스턴스를 사용한다. 테스트 케이스에 속한 테스트들이 읽고 써야 하는 가변 상태가 있는 경우 인스턴스 공유가 문제될 수 있는데, 이 경우 테스트나 테스트 그룹을 시작할때마다 테스트를 매번 인스턴스화하고 싶을 수 있다. isolationMode 프로퍼티를 오버라이드 하여 사용하면 가능하다. 이 프로퍼티는 isolationMode 이넘에 속하는 세 가지 상수 중 하나를 지정한다.

  • SingleInstance: 테스트 케이스의 인스턴스가 하나만 만들어지며 디폴트 동작이다.
  • InstancePerTest: 문맥이나 테스트 블록이 실행될 때마다 테스트 케이스의 새 인스턴스를 만든다.
  • InstancePerLeaf: 말단에 있는 개별 테스트 블록을 실행하기 전에 테스트가 인스턴스화된다.

 

 

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 변수에 들어있기 때문에 두 번째 테스트가 실패한다. 다음 코드를 추가해 격리 모드를 InstancePerTest로 변경하면 다음과 같다.

 

object IncTestProjectConfig : AbstractProjectConfig() {
    override val isolationMode = IsolationMode.InstancePerTest
}
/*
    Increment
    Increment
    prefix
    Increment
    postfix
 */

두 테스트가 서로 다른 IncTest 인스턴스를 갖기 때문에 두 테스트는 모두 통과한다.

 

 

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

 

 

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

object IncTestProjectConfig : AbstractProjectConfig() {
    override val isolationMode = IsolationMode.InstancePerLeaf
}
/*
    Increment
    prefix
    Increment
    postfix
 */

그 결과 IncTest는 두 번만 인스턴스화되는데, 첫 번째는 prefix를 실행할때 두 번째는 postfix를 실행할때다.

 

반응형