본문 바로가기
코틀린

코틀린으로 Functional 하게 테스트 코드 짜기

by RWriter 2021. 8. 13.
반응형

예제소스는 깃허브에 있습니다.

 

코틀린은 자바보다 함수를 다루기 편하다.

 

함수도 1급 시민으로 다루기 때문에 인자로 함수를 넘기는 방식이 매우 간편해졌다. 

 

그래서 자바에서 코틀린으로 넘어간 후에 코드 스타일이 많이 달라졌는데, 이번에 테스트 코드를 짜면서 함수를 적극적으로 활용해본 사례를 소개해볼까 한다.

 

테스트 전후에 특정 로직 실행하기

테스트를 짤 때 가끔 LocalDateTime.now() 과 같은 전역 함수들을 제어하고 싶은 경우가 있는데 코틀린의 mockk 는 간편하게 static mock 방식을 지원해준다.

 

gradle.kts

testImplementation("io.mockk:mockk:1.10.3")

 

 

@Test
fun `static now test`() {
    val now = LocalDateTime.of(2021, 8, 1, 0, 0)
    mockkStatic(LocalDateTime::class)
    every { LocalDateTime.now() } returns now

    assertEquals(now, LocalDateTime.now())

    clearStaticMockk(LocalDateTime::class)
}

 

하지만 필요할 때마다 매번 테스트코드 앞뒤에 mock 설정 로직을 넣자니 귀찮은 일이다..

junit 에 @BeforeEach, @AfterEach 가 있지만 이것도 단점이 있다고 생각한다.

 

1. 테스트 코드 내 특정 지점 전후로 실행하기 어렵다.

2. 설정해주면 해당 클래스 메서드에 전부 적용이 되어 특정 메서드만 골라서 사용하기가 어렵다.

 

 

함수형으로 작성하면 코드블럭을 넘기는 식으로 위 동작을 구현할 수 있다.

inline fun <T> staticContext(
    now: LocalDateTime = LocalDateTime.now(),
    block: () -> T
): T {
    mockkStatic(LocalDateTime::class)
    every { LocalDateTime.now() } returns now
    return try {
        block() // 테스트 코드블럭 실행
    } finally {
        clearStaticMockk(LocalDateTime::class)
    }
}

 

아래처럼 staticContext 함수 안으로 하고자 하는 테스트를 넣어 mock 설정을 깔끔하게 숨겼다.

@Test
fun `staticContext test`() {
  val now = LocalDateTime.of(2021, 8, 1, 0, 0)

  staticContext(now = now) {
    assertEquals(now, LocalDateTime.now())
  }
}

 

 

또 다른 요구사항으로는 테스트 전후에 db 데이터를 리셋하고 싶은 요구가 있을 수 있는데, 비슷하게 짤 수 있다.

fun dataResetContext(
        vararg repositories: CrudRepository<out BaseEntity, Long>,
        afterRemove: Boolean = true,
        block: () -> Unit
) {
    repositories.forEach {
        it.deleteAll()
    }

    block()

    if (afterRemove) {
        repositories.forEach {
            it.deleteAll()
        }
    }
}

 

이건 굳이 필요한건 아니지만 테스트 코드 실행에 걸린 시간을 기록하기 위해서 비슷한 구조를 만들 수도 있다.

inline fun <T> timeLoggerContext(
    block: () -> T
): T {
    val start = System.currentTimeMillis()
    return try {
        block()
    } finally {
        val end = System.currentTimeMillis()
        println("Took : ${(end - start) / 1000} seconds")
    }
}


@Test
fun `timeLoggerContext test`() {
    timeLoggerContext {
        Thread.sleep(1000)
        assertEquals(true, true)
    } // Took : 1 seconds
}

 

 

이렇게 하나씩 만들어 사용하면 간편한데, 이 함수들을 함께 사용하려니 단점이 생긴다. 

contex를 붙일때마다 뎁스가 늘어나서 코드가 깔끔해보이지 않는다는 점이다.

@Test
fun `context combination test`() {
    timeLoggerContext {
        val now = LocalDateTime.of(2021, 8, 1, 0, 0)
        staticContext(now) {
            Thread.sleep(1000)
            assertEquals(now, LocalDateTime.now())
        }
    }
}

 

함수의 합성으로 실행 컨텍스트 제어하기

 

좀 더 함수형으로 짜본다면 아래와 같은 코드로 만들 수도 있다.

context 를 andThen, compose 같이 함수의 합성으로 만든 다음에 테스트 블럭을 가장 마지막에 넘기는 식이면 좀 괜찮을 것 같다.

그러면 context가 늘어나도 뎁스가 늘지는 않는다.

@Test
fun `context combination test`() {
    val now = LocalDateTime.of(2021, 8, 1, 0, 0)
    timeLoggerContext
        .and(staticContext(now))
        .and(dataResetContext)
        .and(...Context)
        .test {
            Thread.sleep(1000)
            assertEquals(now, LocalDateTime.now())
        }
}

 

 

 

 

아래 typealias 는 꼭 필요하지는 않지만, 사용하지 않으면 이하 만들게 될 메서드의 타입 시그니처가 좀 지저분해져서 사용하는게 나을 것 같다. 

 

 

Unit 을 반환하는 함수는 Effect 라고 정의하고 싶다. 실제로 어떤 반환값이 있는게 아니니 효과를 발생시킨다고 볼 수 있다.

그리고 Effect 를 받아서 Effect 를 반환하는 함수는 Effect 에 대한 Decorator로 생각할 수 있다. 객체로 보면 프록시, 데코레이터 패턴을 사용한다고 보면 될 것 같다. (여길 통과하면 뭔가가 더 꾸며졌다.)

typealias Effect = () -> Unit
typealias EffectDecorator = (Effect) -> Effect

 

 

그러면 위에서 사용했던 context 들을 decorator 로 만들 수 있다.

staticDecorator 는 LocalDateTime 만 넣어주면 EffectDecorator 가 되는 식의 커링(currying)을 사용했다.

val staticDecorator: (LocalDateTime) -> EffectDecorator =
    { now ->
        { testBlock: Effect ->
            { staticContext(now = now, block = testBlock) }
        }
    }

val timeLoggerDecorator: EffectDecorator =
    { testBlock: Effect ->
        { timeLoggerContext(testBlock) }
    }

 

데코레이터들을 만들었으니, 데코레이터를 서로 조합해줄 수 있는 함수가 필요한데, 이것은 Decorator의 확장함수로 구현하면 보기좋다.

fun EffectDecorator.with(next: EffectDecorator): EffectDecorator =
    { effect: Effect -> next(this(effect)) }

이펙트(테스트블락)을 현재 데코레이터에 감싼 후 next에 넣어주는 식인데, 이렇게 하면 with 함수를 사용해 데코레이터를 계속해서 체이닝해서 붙일 수 있다.

 

 

여기까지 하면 데코레이터를 체이닝으로 붙여서 코드를 짤 수 있는데 아래와 같다.

@Test
fun `context combination test2`() {
    val now = LocalDateTime.of(2021, 8, 1, 0, 0)
    timeLoggerDecorator
        .with(staticDecorator(now))
        .invoke {
            staticContext(now) {
                Thread.sleep(1000)
                assertEquals(now, LocalDateTime.now())
            }
        }.invoke()
}

단점은 데코레이터를 effect 를 인자로 받고 Unit이 아닌 () -> Unit 의 함수를 반환하기 때문에 마지막에 () -> Unit 을 Unit으로 만들어 주기 위해 invoke() 를 한번 더 해줘야 한다는 게 보기 불편하다.

 

그래서 확장함수를 더 만들었다.

fun EffectDecorator.test(effect: Effect): Unit =
    this(effect).invoke()
    
@Test
fun `context combination test3`() {
    val now = LocalDateTime.of(2021, 8, 1, 0, 0)
    timeLoggerDecorator
        .with(staticDecorator(now))
        .test {
            staticContext(now) {
                Thread.sleep(1000)
                assertEquals(now, LocalDateTime.now())
            }
        }
}

 

이렇게 하면 꽤 만족스럽게 여러 context 를 원하는 만큼 조합해서 사용할 수 있어서 매우 좋다.

 

 

다이나믹하게 Decorator 만들기

그런데 좀 더 욕심이 나서 아래 함수들을 더 추가했다.

지금까지는 staticConext 같은 함수를 가지고 Decorator 로 재구성 선언해주어야만 조합해 사용할수 있었는데, 테스트 코드 내에서 꼭 데코레이터 코드를 생산하지 않고도 다이나믹하게 붙여 사용할 수 있다면 아래 처럼 될 수가 있다.

@Test
fun `context combination test4`() {
    val now = LocalDateTime.of(2021, 8, 1, 0, 0)
    decorateWith { testBlock ->
        timeLoggerContext(testBlock)
    }.decorate { testBlock ->
        staticContext(now = now, block = testBlock)
    }.test {
        Thread.sleep(1000)
        assertEquals(now, LocalDateTime.now())
    }
}

 

이를 위해선 전역에서 호출가능한 decoratorWith 함수와 EffectDecorator 의 메서드인 decorator 함수 두개가 필요하다. 

fun decorateWith(f: (Effect) -> Unit): EffectDecorator =
    { effect -> { f(effect) } }

fun EffectDecorator.decorate(f: (Effect) -> Unit): EffectDecorator =
    this.with(decorateWith(f))

 

decorateWith 를 사용하면 굳이 모든 함수를 데코레이터로 만들지 않고도 붙여서 사용할 수 있다. 

반응형

댓글