본문 바로가기

코틀린

[코틀린] 코틀린 테스팅

 

안드로이드 개발하는 kancho입니다.

이번 포스팅에서는 코틀린의 테스팅(Testing)에 대해 알아보고자 합니다.

'코틀린 완벽 가이드' 책을 참고하였습니다.

 

 

코테스트

 

 

1. 명세


코테스트는 여러 명세 스타일을 지원한다.

지금은 코테스트를 프로젝트에 추가하면 바로 사용할 수 있는 명세 스타일을 살펴보자.

 

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

 

StringSpec 클래스

public abstract class StringSpec public constructor(
    body: io.kotest.core.spec.style.StringSpec.() -> kotlin.Unit
) : DslDrivenSpec, StringSpecRootScope { }

 

StringSpec 클래스를 사용한 예제

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

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

 

StringSpec에서는 테스트에 대한 설명이 들어있는 문자열 뒤에 람다를 추가해서 개별 테스트를 작성한다.

StringSpec의 invoke 확장 함수를 호출하는 것뿐이다.

public interface StringSpecRootScope : RootScope {
    public open operator fun kotlin.String.invoke(
        test: suspend StringSpecScope.() -> kotlin.Unit
    ): kotlin.Unit { }
}

 

실제 검증 코드는 shouldBe 중위 연산자 함수를 사용한다.

public infix fun <T, U : T> T.shouldBe(expected: U?): kotlin.Unit { }

 

수신 객체로 받은 값과 인자로 받은 값이 일치하지 않으면 예외를 던진다.

 

StringSpec은 모든 테스트가 한 클래스 안에 들어가고 모든 테스트가 같은 수준에 정의된 평평한 구조이다.

테스트 블록을 다른 블록 안에 넣으면 런타임 예외가 발생한다.

 

 

WordSpec 클래스를 사용하면 더 복잡한 테스트를 만들 수 있다.

가장 단순한 형태로 2단계로 이루어진 계층 구조를 만들 수 있다.

should 함수 호출에 의해 그룹으로 묶이게 된다.

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

 

should 호출을 when으로 바꾸면 3단계 계층으로 구성할 수 있다.

class NumberTest2 : 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 클래스를 통해 여러 계층을 만드는 예제

class NumberTest3 : 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 함수 호출로 묶는다. 테스트에 대한 설명과 실행할 일시 중단 함수를 인자로 받는다.

public open fun test(
    name: kotlin.String, 
    test: suspend io.kotest.core.test.TestScope.() -> kotlin.Unit
): kotlin.Unit { }

 

StringSpec과 달리 context 블록을 통해 테스트를 한 그룹으로 묶을 수 있다.

 

 

DescribeSpec 클래스를 사용한 테스트

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

 

describe(), context()으로 그룹을 짓고 it()를 통해 내부에 테스트를 담는다.

 

 

ShouldSpec 클래스를 사용한 테스트

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

 

문맥 블록을 통해 그룹을 짓고 마지막에 테스트 블록을 위치시킨다. 테스트 블록 자체는 should 함수 호출로 정의한다.

 

 

FreeSpec 클래스를 사용한 클래스

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

 

문자열에 대한 invoke 함수를 사용해 테스트를 정의하고

마이너스(-) 연산자를 통해 문맥을 소개한다.

 

 

코테스트는 게르킨에서 영감을 받은 BDD(행동 주도 개발) 명세 스타일도 지원한다.

 

FeatureSpec 클래스를 사용한 테스트

class NumberTest7 : FeatureSpec({
    feature("Arithmetec") {
        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 }
        }
    }
})

 

feature 블록에 의해 계층이 만들어지고 구체적인 테스트를 구현하는 시나리오 블록이 들어간다.

feature 안에서 여러 시나리오를 묶어 그룹으로 만들 수도 있다.

 

 

BehaviorSpec 클래스를 이용한 테스트

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

 

given, when, then 함수로 구분되는 세 가지 수준을 제공한다. And, and를 통해 여러 when, then 블록을 묶을 수 있다.

이러한 블록을 사용하면 자연어에 가까운 테스트 설명을 작성할 수 있다.

 

 

AnnotationSpec 클래스를 사용한 테스트

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

 

DSL 같은 테스트 명세를 사용하지 않고 @Test 어노테이션에 의존한다. 테스트에 @Ignore을 붙이면 테스트를 비활성화할 수 있다.

 

 

 

단언문

 

 

1. Matcher(매처)


Matcher는 일반 함수 호출이나 중위 연산자 형태로 사용할 수 있는 확장 함수로 정의된다.

앞에서 사용하던 shouldBe 함수는 매처 중 한 가지이다.

모든 매처는 관습적으로 shouldBe로 시작한다.

 

custom Matcher를 정의하기 위해서는 Matcher 인터페이스를 구현하고 test 함수를 override 해야 한다.

 

public interface Matcher<in T> {
    public companion object {
        public final fun <T> failure(error: kotlin.String): io.kotest.matchers.Matcher<T> { }

        public final inline operator fun <T> invoke(
            crossinline tester: (T) -> io.kotest.matchers.MatcherResult
        ): io.kotest.matchers.Matcher<T> { }
    }
    
    @kotlin.Deprecated public open infix fun <U> compose(fn: (U) -> T): io.kotest.matchers.Matcher<U> { }
    
    public abstract fun test(value: T): io.kotest.matchers.MatcherResult
}

 

 

MatcherResult 클래스

public interface MatcherResult {
    public companion object {
        public final operator fun invoke(
        passed: kotlin.Boolean,
        failureMessageFn: () -> kotlin.String,
        negatedFailureMessageFn: () -> kotlin.String
    ): io.kotest.matchers.MatcherResult { }
    }

    // 단언문 실패를 보여주고 성공시키려면 어떤일을 해야 하는지 알려주는 메세지
    public abstract fun failureMessage(): kotlin.String
    
    // matcher를 반전시킨 버전을 사용했는데 실패하는 경우 표시되는 메세지
    public abstract fun negatedFailureMessage(): kotlin.String
    
    // 단언문을 만족하는가
    public abstract fun passed(): kotlin.Boolean
}

 

Matcher 인터페이스를 구현한 beOdd 함수

// 주어진 숫자가 홀수인지 검사
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"
        )
    }
}

// beOdd 매처를 확장함수에 넘겨 단언문에 사용
class NumbersTestWithOddMatcher : StringSpec({
    "5 is odd" { 5 should beOdd() }
    "4 is not odd" { 4 shouldNot beOdd() }
})

 

매처의 compose 연산은 기존 매처에 타입 변환 함수를 추가해서 새로운 타입에 대한 매처를 만들어준다.

// beOdd 매처를 재사용해 컬렉션의 길이가 홀수인지 검사
fun beOddLength() = beOdd().compose<Collection<*>> { it.size }

 

 

 

2. 인스펙터


코테스트는 매처와 관련된 inspector라는 개념을 지원한다.

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

 

/**
 * forAll() / forNone() -> 단언문을 모든 원소가 만족하는지, 만족하지 않는지 검사
 * forExactly(n) -> 단언문을 정확히 n개의 원소가 만족하는지 검사
 * forAtLeast(n) / forAtMost(n) -> 단언문을 최소 or 최대 n개의 원소가 만족하는지 검사
 * forSome() -> 단언문을 만족하는 원소가 존재하되 모든 원소가 만족하지는 않음을 검사
 */
class NumberTestWithInspectors: 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 } 
    }
    "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()를 통해 어떤 코드가 특정 예외로 중단되었는지 검사한다. try/catch를 간편하게 대신할 수 있다.

 

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

 

실패한 단언문이 던진 예외를 일시적으로 무시하는 soft assertion 기능도 있다.

테스트가 여러 단언문으로 이루어져 있고 실패한 단언문을 모두 보고 싶은 경우 유용하다.

일반적으로 맨 처음 예외가 발생한 시점에 테스트가 종료되지만 assertSoftly 블록으로 우회할 수 있다.

내부에서 발생한 AssertionError 예외를 잡아낸 후 누적시키면서 모든 단언문을 실행한다.

 

/**
 * 4 elements passed but expected 10
 * java.lang.AssertionError: 4 elements passed but expected 10
 * 
 * The following elements passed:
 * 1
 * 2
 * 3
 * 4
 * The following elements failed:
 * 5 => 5 should be < 5
 * 6 => 6 should be < 5
 * 7 => 7 should be < 5
 * 8 => 8 should be < 5
 * 9 => 9 should be < 5
 * 10 => 10 should be < 5
 */
class NumberTestWithForAll : StringSpec({
    val numbers = Array(10) { it + 1 }
    "invalid numbers" {
        assertSoftly {
            numbers.forAll { it shouldBeLessThan 5 }
            numbers.forAll { it shouldBeGreaterThan 3 }
        }
    }
})

 

 

4. 비결정적 코드 테스트


타임아웃과 반복 실행을 편리하게 처리할 수 있다.

 

eventually 함수는 정해진 기간 안에 주어진 단언문을 만족시키는 경우가 한 번이라도 생기는지 검사한다.

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

 

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

@ExperimentalTime
class StringSpecWithEventually : StringSpec({
    "10초 안에 파일의 내용이 계속 한 줄로 유지돼야 함" {
        continually(10.seconds) {
            File("data.txt").readLines().size shouldBe 1
        }
    }
})

 

 

5. 속성 기반 테스트


코테스트는 자동으로 술어를 검증하기 위한, 임의의 테스트 데이터를 생성해주는 속성 기반 테스트(property based test)를 지원한다.

수동으로 준비하고 유지하기 어려운 큰 데이터에 대해 테스트를 할 때 유용하다.

 

// 두 수의 최솟값을 구하는 함수
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))
            }
        }
    }
})

 

단언문 대신 bool 값을 반환하는 식을 사용할 수도 있다. forAll 안에 불 값을 반환하는 람다를 넣어야 한다.

생성한 모든 테스트 데이터에 대해 람다가 true를 반환해야 테스트가 성공한다.

 

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

 

 

코테스트는 Int, Boolean, String 등의 여러 타입에 대한 디폴트 생성기(generator)를 제공한다. 람다 파라미터의 타입에 대한 런타임 정보를 사용해 자동으로 생성기를 선택한다.

코테스트 속성 기반 테스트의 생석니는 Gen 추상 클래스를 상속한다.

 

public sealed class Gen<out A> protected constructor() {
    public open val classifier: io.kotest.property.Classifier<out A>?

    public final fun generate(
        rs: io.kotest.property.RandomSource,
        edgeConfig: io.kotest.property.EdgeConfig
    ): kotlin.sequences.Sequence<io.kotest.property.Sample<A>> { }

    public final fun minIterations(): kotlin.Int { }
}

 

임의의 값을 생성하는 생성기와 정해진 값을 모두 돌려주는 생성기로 나뉜다.

 

Arb -> 미리 하드코딩된 edge case와 난수 샘플을 생성해주는 생성기

Exhaustive -> 주어진 공간에 속한 모든 데이터를 생성한다

 

class RationalTest : StringSpec({
    "Subtraction (Arb 사용)" {
        forAll(
            object : Arb<Rational>() {
                override fun edgecase(rs: RandomSource): Rational? = null
                override fun sample(rs: RandomSource): Sample<Rational> =
                    Sample(Rational.of(rs.random.nextInt(), rs.random.nextInt(0, Int.MAX_VALUE)))
            }
        ) { a: Rational ->
            (a - a).num == 0
        }
    }
})

 

 

'코틀린' 카테고리의 다른 글

[코틀린] 자바와 코틀린의 상호 운용성  (0) 2022.12.04
[코틀린] DSL (도메인 특화 언어)  (0) 2022.11.27
[코틀린] Annotation  (0) 2022.11.21
[코틀린] 제네릭  (0) 2022.11.13
[코틀린] 상속(Inheritance)  (0) 2022.11.06