본문 바로가기
스프링/만들면서 배우는 실무 백엔드 개발

9. 단위 테스트 작성하기 (feat. JUnit5 + MockK)

by kdohyeon (김대니) 2023. 4. 2.
반응형

어떤 기능을 구현한다는 것은 테스트까지 작성이 되어야 완성이라고 이야기할 수 있습니다. 그만큼 테스트는 중요한데요. 이번 시간에는 JUnit5 기반으로 단위 테스트를 작성하고 코틀린 스타일로 Mock 객체를 만들 수 있는 MockK 프레임워크를 사용해볼 예정입니다.


Project issue: https://github.com/kdohyeon/crypto-labs/issues/15
Pull request: https://github.com/kdohyeon/crypto-labs/pull/16

의존성 추가

단위 테스트 작성을 위해서는 spring-boot-starter-test 모듈을 불러온다. build.gradle.kts 에 추가해주면 된다.

testImplementation("org.springframework.boot:spring-boot-starter-test")

MockK 관련 라이브러리도 추가해주자.

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

테스트 작성

테스트를 하기 위한 메소드는 마켓을 개별로 조회하는 marketSearchService.marketPair 에 대해 작성하려고 한다. DB에서 조회를 하고 값이 없으면 IllegalArgumentException 예외를 던진다. 값이 존재한다면 MarketDto 로 변환을 하여 반환한다.

@Service
class MarketSearchService(
    private val marketRepository: MarketRepository,
    private val marketDtoConverter: MarketDtoConverter,
) : SearchMarketUseCase {
    override fun marketPair(marketPair: String): MarketDto {
        return marketRepository.findByMarketPair(marketPair)
            .orElseThrow { throw IllegalArgumentException("마켓 [$marketPair] 은 존재하지 않습니다.") }
            .let { marketDtoConverter.convert(it) }
    }
}

테스트를 하고 싶은 부분은 크게 2가지가 존재한다.
1) DB에서 성공적으로 조회를 했을 경우, 그리고
2) 값이 없어 조회에 실패한 경우이다. 
각각에 대해 테스트를 해보자.

MockK

테스트를 수행할 때 직접적으로 DB와 연결을 하지 않을 것이기 때문에 MarketRepository 는 mocking 이 필요하다. Mocking 이라는 것은 해당 객체가 있다고 가정하고 테스트를 진행하는 것이다. MarketRepository 객체가 만들어져 있다고 가정하고 그 다음 코드를 진행한다. (이 테스트에서는 MarketRepository 가 중요한 것이 아니기 때문에 Mocking 처리한다.)
Mocking 은 Mockito 를 통해 할 수도 있지만 코틀린에서는 MockK 를 많이 사용한다.

Test #1. 성공 케이스

(1) MockK 의 Mock 객체를 사용하기 위해 클래스 레벨에 @ExtendWith 어노테이션으로 선언한다. (2) MockK 어노테이션을 통해 MarketRepository 를 Mocking 한다고 선언한다. (3) SpyK 어노테이션을 통해 실 객체를 사용하겠다고 선언한다 (no mocking). (4) InjectMockKs 어노테이션은 Mock 객체를 주입한다. MarketSearchService 테스트를 수행할 때 선언한 Mock 객체를 주입한다.
(5) 각 단위 테스트를 선언하기 위한 어노테이션이고 DisplayName 으로 어떤 테스트인지 작성할 수 있다.
(6) 단위 테스트는 주로 given-when-then 패턴으로 작성되는데, given 에서는 주어진 값을 정의한다. marketPair 와 market 데이터가 테스트에 필요하기 때문에 미리 작성해둔다. 또한 (7) marketRepository 를 mocking 했기 때문에 marketRepository의 findByMarketPair(marketPair) 가 호출되었을 때 어떤 값을 기대할 지 정의할 수 있다. 
(8) 테스트를 하고자 하는 메소드를 실행시킨다.
(9) 테스트가 성공적으로 수행되었는지를 확인하기 위한 선언문들을 작성한다. verify 를 통해 marketRepository의 findByMarketPair 메소드가 한 번 수행되어야 한다고 선언한다. 또한 marketDto.marketPair 의 값과 marketPair 의 값이 동일해야 한다고 선언한다.

@ExtendWith(MockKExtension::class) // (1) 
internal class MarketSearchServiceTest {
    @MockK // (2)
    lateinit var marketRepository: MarketRepository

    @SpyK // (3)
    var marketDtoConverter = MarketDtoConverter()

    @InjectMockKs // (4)
    lateinit var marketSearchService: MarketSearchService
    
    @Test // (5)
    @DisplayName("마켓을 개별로 조회할 수 있다.")
    fun marketPairOnSuccess() {
        // given (6)
        val marketPair = "KRW-BTC"
        val market = Market(
            marketPair = marketPair,
            koreanName = "비트코인",
            englishName = "Bitcoin",
            marketWarning = false,
        )

        // (7)
        every { marketRepository.findByMarketPair(marketPair) } returns Optional.of(market)

        // when (8)
        val marketDto = marketSearchService.marketPair(marketPair)

        // then (9)
        verify(exactly = 1) { marketRepository.findByMarketPair(marketPair) }
        assertEquals(marketPair, marketDto.marketPair)
    }
}

Test #2. 실패 케이스

나머지 부분은 위에 설명한 것과 동일한데, 예외의 경우에는 assertThrows 를 활용할 수 있다. 예외 자체를 기대하는 것이고, 예외가 발생해야 성공이라고 보는 것이다. marketSearchService.marketPair(marketPair) 가 수행될 때 IllegalArgumentException 이 발생되어야 하고 에러 메시지도 같이 체크할 수 있다.

@ExtendWith(MockKExtension::class)
internal class MarketSearchServiceTest {
    @MockK
    lateinit var marketRepository: MarketRepository

    @SpyK
    var marketDtoConverter = MarketDtoConverter()

    @InjectMockKs
    lateinit var marketSearchService: MarketSearchService

    @Test
    @DisplayName("마켓을 개별로 조회할 때, 결과가 없으면 예외가 발생한다.")
    fun marketPairOnFailure() {
        // given
        val marketPair = "KRW-BTC"
        every { marketRepository.findByMarketPair(marketPair) } returns Optional.empty()

        // when
        val exception = assertThrows(IllegalArgumentException::class.java) {
            marketSearchService.marketPair(marketPair)
        }

        // then
        verify(exactly = 1) { marketRepository.findByMarketPair(marketPair) }
        assertEquals("마켓 [$marketPair] 은 존재하지 않습니다.", exception.message)
    }
}
반응형

댓글