본문 바로가기

개발 이야기/Spring Boot

[10분 Boot-up] Kotlin으로 스프링부트 Web Application 만들기 - (7) 단위 테스트(Unit Test) JUnit5 적용하기

Kotiln과 Spring Boot을 모르더라도 10분씩 따라하면서 자연스럽게 Web Application을 만들어보는 것이 목표입니다.
각 챕터에서 나오는 중요한 내용은 별도로 [더 알아보기]에 보충해서 작성할 예정입니다.

시작하기

이번에는 Service의 함수들에 대한 단위 테스트를 작성하는 방법을 소개한다. 단위 테스트란 기존의 작성된 코드를 검증하는 자동 테스트를 말한다. 단위 테스트는 TDD(테스트 주도 개발)에 아주 중요한 도구가 되기도 한다.

보통 단위 테스트를 작성할 때 몇 가지 프레임워크를 함께 사용하는데 대표적으로 JUnitMockK 프레임워크이다. JUnit은 자바의 단위 테스트를 위한 프레임워크 도구로 Assert 단정문을 통해서 테스트 수행 결과를 판별하여 결과를 리포트해준다. Mock 프레임워크도 여러가지가 있지만 Kotlin 세계에서는 Spring에서 공식적으로 가이드하는 MockK를 많이 사용한다. 추가적으로 위에서 말한 Assert 단정문을 조금 더 쉽게 사용할 수 있는 AssertJ도 함께 많이 활용하는 편이다.

여기서 말하는 Mock이란, 다양한 테스트 상황을 위해서 임의의 가짜 객체를 만드는 것인데, 예를 들면 DB를 조회하는 서브루틴이 포함된 함수의 테스트에서 실제 DB를 조회하기는 비용이나 시간이 많이 소요되므로 이 과정을 건너뛰고 마치 DB가 조회된 것 마냥 가짜 객체를 대신 전달하는 상황을 상상하면 된다.

MockK dependency 추가하기

JUnit은 기본적으로 spring-boot-starter-test에 포함되어 있으므로 MockK(io.mockk:mockk)에 대한 dependency와 테스트에서 유용하게 사용할 수 있는 유틸 라이브러리인 org.apache.commons:commons-lang3org.assertj:assertj-core를 추가한다.

(생략)
dependencies {
    implementation("io.github.microutils:kotlin-logging:1.12.5") // Logging
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("org.jetbrains.kotlin:kotlin-allopen")
    implementation("org.jetbrains.kotlin:kotlin-noarg")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")

    runtimeOnly("mysql:mysql-connector-java") // MySQL

    testImplementation("org.apache.commons:commons-lang3:3.11")
    testImplementation("org.assertj:assertj-core:3.20.2")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("io.mockk:mockk:1.12.0")
}
(생략)

테스트 클래스 만들기

StockQueryService의 테스트 클래스를 만들어보자. 보통은 Test 단어를 접미사로 붙이는 것이 일반적이기 때문에 src/main/test/me/sample/myapp/service 패키지 경로에 아래처럼 StockQueryServiceTest.kt를 생성한다.

package me.sample.myapp.service

import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.junit5.MockKExtension
import io.mockk.verify
import me.sample.myapp.model.Stock
import me.sample.myapp.repository.StockRepository
import org.apache.commons.lang3.RandomStringUtils
import org.apache.commons.lang3.RandomUtils
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith


@ExtendWith(MockKExtension::class)
class StockQueryServiceTest {

    @InjectMockKs
    private lateinit var underTest: StockQueryService

    @MockK
    private lateinit var stockRepository: StockRepository

    @Test
    fun listStocks() {
        // given
        val stocks = generateSequence {
            randomStock()
        }
            .distinct()
            .take(randomSmallPlural())
            .toList()
        every {
            stockRepository.findAllAsModel()
        } returns stocks

        // when
        val actual = underTest.listStocks()

        // then
        assertThat(actual).isEqualTo(stocks)
        verify(exactly = 1) {
            stockRepository.findAllAsModel()
        }
        confirmVerified(stockRepository)
    }
}

fun randomStock() = Stock(
    id = randomShortAlphanumeric(),
    type = randomShortAlphanumeric(),
    name = randomShortAlphanumeric(),
    code = randomShortAlphanumeric(),
    ticker = randomShortAlphanumeric(),
)

fun randomSmallPlural(): Int = RandomUtils.nextInt(2, 10)

fun randomShortAlphanumeric(): String = RandomStringUtils.randomAlphanumeric(randomSmallPlural())

@ExtendWith(MockKExtension::class)

일반적으로 JUnit의 테스트 생명주기는 method 단위이지만, 이를 확장하여 추가적인 리소스를 설정하여 Context를 확장할 수 있다. 예를 들어 Spring의 Context를 활용하여 테스트를 해야한다던지, Mock 프레임워크의 Context를 추가하는 경우를 생각해볼 수 있다. JUnit5에서는 이런 확장 모델이 Extension으로 통일됐고, @ExtendWith을 통하여 확장에 사용할 클래스를 선언할 수 있다. 우리는 MockK라는 Mock 프레임워크를 사용하기 때문에 이를 활용하여 MockK의 Context를 추가하자.

@InjectMockKs

자동으로 @MockK 어노테이션이 붙은 lateinit var 혹은 var에 해당하는 Mock 객체를 주입받는다. 실제 StockQueryService는 생성자 파라미터를 통해서 StockRepository 객체를 주입받는데, 테스트를 위한 underTest(StockQueryService)는 이렇게 주입받는 객체들을 Mocking 하여 주입받아야 한다. 물론 @InjectMockKs를 사용하지 않더라도 @BeforEach에 해당하는 함수를 작성하여 직접 테스트용 StockQueryService의 객체를 생성하여 underTest에 할당해도 된다. 참고로 underTest라는 네이밍은 일반적인 검증할 코드(대부분 함수 단위)를 포함한 대상에 일반적으로 사용되는데, sut(System Under Test)라는 네이밍도 많이 사용한다.

@MockK

Mocking 할 대상에 사용하는 어노테이션이다. 우리는 StockRepository를 직접 사용하지 않고 StockQueryService의 코드만 검증할 것이기 때문에 제대로 생성한 객체를 사용할 필요는 없다. 따라서 Mocking 하여 처리하자.

@Test

테스트 단위를 나타내는 어노테이션이다. 일반적으로 테스트는 given, when, then 단계로 코드를 작성하면 이해하기 쉬워진다. 대략 이렇게 주어진 환경에서(given), 이렇게 실행이 된다면(when), 이렇게 결과가 나와야해(then) 정도의 맥락이다. 먼저 given에 해당하는 코드를 잘 살펴보면 StockRepository를 Mocking 했으니 every {}를 사용하여 특정 StockRepositoryfindAllasModel() 함수가 호출되면 내가 정의한 결과를 return하게 설정하고 있는 것을 알 수 있다. when에서는 실제 내가 검증할 함수를 실행한다. 마지막 then에 해당하는 내용은 꽤 중요한데, 일단 when에서 나온 결과가 실제 예상되는 결과와 맞는지 검증해야한다. 그리고 Mocking을 사용했기 때문에 이런 Mock 객체들이 정말 제대로 호출이 됐는지 verify {} 구문을 통해서 확인해야한다. 그리고 혹시 Mock 객체를 사용했지만 verify를 실수로 하지 않는 경우를 방지하기 위해서 confirmVerified {} 구문을 통해서 진짜 Mock 객체들이 verify 됐는지를 최종적으로 검증한다.

마무리

이렇게 Service 함수의 코드를 간단하게 단위 테스트를 통해 검증해봤다. 물론 위 예제가 너무 간단하고 어설픈 것이 사실이다. 애초에 Service 함수의 Business Logic이 너무 간단하기 때문에 테스트 자체가 불필요해보이기도 하고 뻔한 결과를 괜히 테스트로 만든 것 같은 느낌까지 있다. 하지만 코드가 조금만 더 복잡해진다면 Assert 단정문에서 비교하는 expected 객체를 내가 직접 생성해서 확인해야하고, 다양한 Mock 객체를 verify 하는 코드가 정교해져야한다.

JUnit과 Mock 프레임워크는 내가 단위 테스트를 많이 작성할수록 잘 활용할 수 있고 익숙해질 수 있다. 일반적으로 public 함수는 모두 테스트로 검증해야 한다는 생각을 가지면 좋다. (private 함수는 일반적으로 테스트하지 않으며, 테스트가 필요하다면 설계가 잘못됐는지 의심해보자. 또 public 함수 자체도 테스트 자체가 작성하기 어려운 경우도 있는데 이런 경우는 클래스를 분리하거나 리팩토링 해야한다.) 다음에는 Persistence Layer에 해당하는 Repository 클래스를 테스트할 수 있는 Integration Test를 추가해보자.