Spring WebFlux와 Kotlin으로 만드는 Todo 서비스 – 테스트 슬라이스 적용하기



개요

지난 예제들에선 간단한 Todo 예제를 만들고 개선해봤습니다.  또한 그렇게 만들어진 예제의 CRUD를 curl을 이용해 동작을 검증하였습니다.  하지만 변경 사항이 발생하였을 경우 매번 애플리케이션을 재기동 하여 잘 동작하는지 확인해야 했고 각각의 모듈이 어떤 기능을 하는지 알기 어려웠으며, 정상 수행에 대한 피드백을 빠르게 얻기 힘들었습니다. 물론 현재는 작은 규모의 프로젝트이지만 나중엔 기능을 추가하고 안정성을 강화해 프로덕션 규모의 큰 애플리케이션으로 바뀔 수도 있습니다. 이런 경우엔 테스트를 효율적으로 작성하는 전략이 중요해집니다. 이번 편에선 지난 예제를 기반으로  테스트를 작성해보면서 Spring Boot에서 지원하는 테스트 슬라이스  무엇인지 알아보고 또한 BDD플루언트 어설션(Fluent Assertion)에 대해서도 간략히 알아보겠습니다.

이번 예제는  Spring WebFlux와 Kotlin으로 만드는 Todo 서비스 – 2편을 기반으로 만들었습니다. 만일 못 보셨다면 이전 편을 먼저 읽어보시길 추천해드립니다.

통합 테스트와 단위 테스트

  • 통합 테스트는 일반적으로 여러 컴포넌트에 대한 전체 테스트를 의미합니다.  스프링 환경에선 애플리케이션을 실행하는 것과 동일하게 애플리케이션 컨텍스트에 모든 컴포넌트를 올리고 수행하는 테스트를 말합니다.
    • 예) @SpringBootTest
  • 단위 테스트는 개별적으로 동작하는 테스트를 의미합니다. 스프링 환경에선 불필요한 라이브러리의 의존 없이 필요한 개별의 컴포넌트들을 이용해 테스트하는 걸 말합니다.
    • 예) 테스트 슬라이스(@JsonTest, @WebMvcTest, @DataJpaTest 등)

@SpringBootTest

@SpringBootTest 에노테이션은 스프링 부트 애플리케이션에서 애플리케이션 컨텍스트 전체를 사용하는 통합 테스트를 작성할 때 사용합니다.  그러므로 일반적인 단위 테스트에서 사용하면 테스트를 수행을 위한 빌드 타임이 불필요하게 길어지게 됩니다. 이러한 이유로 이번 예제에서는 개요에서 언급한 대로 @SpringBootTest를 대신해 단위 테스트를 쉽게 하는 테스트 슬라이스를 사용하겠습니다. 다만, @SpringBootTest 가 무조건 나쁘다는 것이 아님을 알아주세요. 오늘 주제인 단위 테스트의 목적에 맞지 않을 뿐 입니다.

테스트 슬라이스(Test Slice)

테스트 슬라이스는 Spring Boot 1.4에서 처음 소개되었습니다. Spring Boot 1.4 이전에는 테스트 코드를 작성할 때 스프링 애플리케이션 컨텍스트 전체를 로딩 한 후에 테스트가 동작하도록 구성할 수밖에 없었습니다. 그 이유는 Auto Configuration이라고 불리는 Spring Boot의 마법 같은 기술 때문이었습니다. 이 기술은 Spring Boot 프로젝트 구성 시 불필요하고 반복적인 설정과 의존성들을 자동화해주는 가장 핵심적인 기술입니다. 하지만 JSON 데이터를 직렬화(Serialization) 또는 역 직렬화(Deserialization) 하는 테스트를 작성하는데 Spring Security 또는 Spring MVC 등을 로딩할 필요가 있을까요? 대부분의 경우는 그렇지 않습니다. 그래서 Spring Boot 1.4에선 각각의 레이어에서 필수적인 요소만 선택적으로 자동 설정하고 불필요한 자동 설정을 해제하는 테스트 슬라이스라는 개념이 추가된 것입니다.

아래는 대표적인 테스트 슬라이스입니다.

  •  @JsonTest
    • JSON에 연관된 직렬화/역 직렬화를 위한 테스트 컴포넌트들을 제공합니다.
  • @DataJpaTest
    • JPA 관련 컴포넌트를 로딩하고 리파지토리 레이어의 테스트가 가능해집니다. 또한 테스트를 위한 인메모리 데이터베이스도 함께 제공합니다.
    • 아쉽게도 DataR2dbcTest는 글 쓰는 시점에는 아직 추가되지 않았습니다.
  • @WebMvcTest
    • Spring MVC 컨트롤러를 테스트할 수 있도록 관련된 설정을 자동 설정합니다.
  • @RestClientTest
    • RestTemplate 설정을 간소화하여 REST 서비스와 통신하는 테스트를 작성할 수 있습니다.
  • @WebFluxTest
    • Spring WebFlux 와 연관된 컴포넌트를 로딩하고 자동 설정합니다.  이번 예제에서 사용하게됩니다.

 @WebFluxTest 살펴보기

@WebFluxTest 는 스프링 부트 테스트가 기본적으로 제공하는 테스트 슬라이스로 다음과 같이 구현되어 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(WebFluxTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(WebFluxTypeExcludeFilter.class)
@AutoConfigureCache
@AutoConfigureJson
@AutoConfigureWebFlux
@AutoConfigureWebTestClient
@ImportAutoConfiguration
public @interface WebFluxTest {

실제 구현 코드를 보면 위의 코드와 같은데 에노테이션 정의 시 위에 또 다른 에노테이션 들을 사용하고 있는 것을 볼 수 있습니다. 이런 에노테이션들을 메타 에노테이션(Meta-Annotation)이라고 부릅니다. 메타 에노테이션은  에노테이션을 만들 때 추가하는 외부 에노테이션을 말하는데 이미 만들어진 에노테이션의 특징을 상속받을 수 있습니다.

@WebFluxTest에는 WebFluxTest에 관련된 컴포넌트만 자동 설정되도록 메타 에노테이션이 적용되어있습니다. @AutoConfigure*로 시작하는 에노테이션들이 자동 설정 에노테이션들 입니다.  이를 통해 Json, Cache, WebFlux, WebTestClient 등이 자동 설정된다는 걸 알 수 있습니다.

@WebFluxTest로 RouterFunction 테스트

src/test/kotlin/com/digimon/demo/TodoApplicationTests.kt

아래 테스트 코드는 앞에서 설명한 @WebFluxTest를 사용한 단위 테스트 입니다.  코드를 보면서 설명하겠습니다.

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.digimon.demo
import com.digimon.demo.domain.todo.Todo
import com.digimon.demo.domain.todo.TodoRepository
import com.digimon.demo.handler.TodoHandler
import com.digimon.demo.router.TodoRouter
import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.BDDMockito.given
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType
import org.springframework.test.context.junit4.SpringRunner
import org.springframework.test.web.reactive.server.WebTestClient
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@WebFluxTest
@RunWith(SpringRunner::class)
class TodoApplicationTests {
    @MockBean
    lateinit var repo: TodoRepository
    lateinit var webClient: WebTestClient
    lateinit var todo1: Todo
    lateinit var todo2: Todo
    @Before
    fun setUp() {
        todo1 = Todo(id = 1L, content = "I have to finish this work by tomorrow")
        todo2 = Todo(id = 2L, content = "Get rid of security flaws in my app")
        val routerFunction = TodoRouter(TodoHandler(repo)).routerFunction()
        webClient = WebTestClient.bindToRouterFunction(routerFunction).build()
    }
    @Test
    @Throws(Exception::class)
    fun `test should return a list of todo`() {
        given(repo.findAll()).willReturn(Flux.just(todo1, todo2))
        val responseBody: List<Todo>? = webClient.get().uri("/todos").accept(MediaType.APPLICATION_JSON)
            .exchange()
            .expectStatus().isOk
            .expectBodyList(Todo::class.java)
            .hasSize(2)
            .returnResult().responseBody
        assertThat(responseBody?.get(1)?.id).isEqualTo(2L)
        assertThat(responseBody?.get(1)?.content).isEqualTo("Get rid of security flaws in my app")
    }
    @Test
    @Throws(Exception::class)
    fun `test should return an item of todo by id`() {
        given(repo.findById(1L)).willReturn(Mono.just(todo1))
        val responseBody: Todo? = webClient.get().uri("/todos/{id}", 1L).accept(MediaType.APPLICATION_JSON)
            .exchange()
            .expectStatus().isOk
            .expectBody(Todo::class.java)
            .returnResult().responseBody
        assertThat(responseBody?.content).isEqualTo("I have to finish this work by tomorrow")
    }
}

  1. @MockBean

    • TodoRepository 선언 시 @MockBean을 사용하였습니다. @MockBean은 내부적으로 모키토(Mokito)를 사용하여 빈(Bean)을 생성하고,  애플리케이션 컨텍스트에 있는 원래 빈의 동작을 대신합니다. 이로 인해 필요한 컴포넌트를 직접 호출하지 않고 흉내 내어 테스트를 편리하게 만듭니다.

  2. setUp()

    • todo1, todo2를 생성합니다.
    • 모킹된 빈인 TodoRepository를 이용하여 TodoRouter와 TodoHandler를 초기화한 뒤 RouterFunction을 리턴합니다.
    • WebTestClient를 서버 구동 없이 테스트하기 위해 WebTestClient.bindToRouterFunction()의 인자로 생성한 RouterFunction을 넣어주고 빌드 합니다. 이렇게 하면 저희가 예전에 Router에 만들었던 API 종단점들을 쉽게 테스트할 수 있습니다.

  3. fun `test should return a list of todo`()

    • 이 테스트 함수는  todo 목록을 반환하는 테스트를 합니다.
    • 첫줄의 given(repo.findAll()).willReturn(Flux.just(todo1, todo2))는 단위 테스트 내에서 findAll() 메서드를 호출하게 되면 willReturn에서 정의한 todo1, todo2가 Flux로 감싸서 반환한다는 걸 의미합니다. 이런 형태를 스텁(stub)이라고 합니다.
    • 스텁이 완료되었으므로 webClient.get().uri("/todos")를 호출하면 스텁된 Flux.just(todo1, todo2)가 서버의 응답 값으로 전달됩니다.
    • AssertJ의 assertThat을 이용해 응답 값의 검증을 진행합니다. isEqualTo에서 기대하는 값과 다르다면 이 테스트는 실패할 것입니다.
      • 저는 개인적으로 JUnit보다 AssertJ를 선호합니다. 이유는 BDD와 플루언트 어설션 구현이 가능하고,  IDE에서 메서드 자동완성도 가능합니다 게다가 스프링 부트 테스트에 기본 제공되는 테스트 라이브러리이므로 별도 라이브러리 추가 없이 사용할 수 있는 장점이 있기 때문입니다.

  4. fun `test should return an item of todo by id`()

    • 이 테스트 함수는 todo 1건을 반환하는 테스트를 합니다.
    • 첫줄에서 given(repo.findById(1L)).willReturn(Mono.just(todo1))로 스텁합니다.
    • webClient.get().uri("/todos/{id}", 1L)를 호출하여 스텁된 값인 todo1 객체를 서버 응답 값으로 전달받습니다.
    • assertThat을 이용해 응답된 todo의 content 내용이 "I have to finish this work by tomorrow"인지 검증하고 기대하는 값과 다르다면 이 테스트는 실패하게 됩니다.

BDD와 플루언트 어설션(Fluent Assertion)

TDD(Test Driven Development)에 대해 많이 들어보셨을 거라 생각합니다. 우리말로 테스트 주도 개발이라 불리는 TDD는 한때 많은 지지자들과 반대파를 양성했었는데(제 생각엔 현재 진행형입니다.)  TDD를 지지하지 않더라도 그 효용성에 대한 많은 개발자들이 공감할 거라고 생각합니다.

BDD는 Behavior Driven Development의 줄임말로 우리말로 하면 행위 주도 개발이라고 부를 수 있습니다. BDD는 자연스럽고 읽기 쉬운 일종의 유비 쿼터스 언어로써 테스트를 작성하는 방법입니다. BDD는 인간 친화적인 방법이기 때문에 개발자가 아닌 사람이 보더라도 테스트의 흐름을 이해할 수 있어야 합니다.  일반적인 패턴은 given, when, then  순서로 테스트를 작성하는데 저희는 조금 전에 이미 BDD로 테스트 코드를 작성하였습니다.  given().willReturn()으로 테스트 값을 스텁하였고(given),  webClient.get()으로 서버를 호출하는 행위를 하였고(when) assertThat으로 성공하는 케이스에 대해 서술하였습니다(then) 설명한 내용을 코드로 분리하면 아래와 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
fun `test should return an item of todo by id`() {
    //Given
    given(repo.findById(1L)).willReturn(Mono.just(todo1))
    //When
    val responseBody: Todo? = webClient.get().uri("/todos/{id}", 1L).accept(MediaType.APPLICATION_JSON)
        .exchange()
        .expectStatus().isOk
        .expectBody(Todo::class.java)
        .returnResult().responseBody
    //Then
    assertThat(responseBody?.content).isEqualTo("I have to finish this work by tomorrow")
}

BDD에 대한 자세한 내용은 이상우님이 쓰신 BDD (Behaviour-Driven Development)에 대한 간략한 정리를 보시면 좋을거 같습니다.

마지막으로 플루언트 어설션(Fluent Assertion)은 플루언트라는 영어 단어의 뜻처럼 유창하게 테스트를 읽을 수 있게 합니다. 플루언트 어설션은 테스트를 자연스럽게 읽히게 만들고 쉽고 빠르게 테스트의 흐름을 알 수 있게 합니다. 즉, 테스트 코드를 분석하는 데 걸리는 시간을 줄여주는 것이 플루언트 어설션의 목적입니다.  플루언트 어설션에선 일반적으로 assertion과 should, expect라는 단어를 사용합니다. 이는 BDD의 철학과도 일맥상통합니다.

마치며

오늘 예제에선 테스트 슬라이스인 @WebFluxTest를 사용하여 Spring WebFlux 기반 애플리케이션의 단위 테스트를 쉽고 빠르게 작성할 수 있었습니다. 또한 BDD와 플루언트 어설션에 대해서도 간략히 알아보았습니다. 예제에선 AssertJ라는 유용한 라이브러리를 사용하여 테스트 코드를 작성했었습니다.  AssertJ는 스프링에서 밀어주는 테스트 라이브러리이므로 믿을만하고 훌륭합니다. 하지만 스프링을 사용하지 않는 상황이라면요? 물론 AssertJ 단독으로 의존성을 추가할 순 있지만 AssertJ의 J는 자바입니다. 저희는 지금 코틀린을 사용하고 있고요. 감이 오셨나요? 다음화에선 코틀린의 플루언트 어설션 라이브러리인 Kluent를 사용하여 코드를 좀 더 코틀린스럽게 변경해 보겠습니다.

오늘 예제로 보신 코드는  https://github.com/spring-webflux-with-kotlin/todo-test 에서 확인 가능합니다.

참고자료

https://www.baeldung.com/spring-tests

https://dzone.com/articles/bdd-unit-tests-and-power


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.