[Spring] Kotlin으로 unit 테스트 작성하기

kshired·2022년 8월 13일
0

Spring REST Docs를 이용해서, e2e테스트를 작성하고 있었지만 unit 테스트의 필요성을 알고 있었기에 unit 테스트를 어떻게 하면 좋을까를 고민했습니다.

보통 Java에서는 Mockito라는 것을 이용해서 mocking을 한다는데, kotlin에서 사용하기에는 별로인 점이 많아서 보통 mockk라는 것을 많이 쓴다고 합니다.

그래서 이번에는 mockk를 이용한 unit test 작성 방법 및 Jacoco를 이용한 라인 커버리지를 체크하는 방법을 알아보겠습니다.

Mockk

Mockk는 코틀린 스타일로 테스트 코드 작성을 할 수 있도록 도와주는 Mocking 라이브러리입니다.

mocking은 간단하게 말하면, dummy 값을 만들어 통제된 값을 이용하여 테스트 코드를 작성할 수 있게 해주는 방법입니다.

Mocking을 하지 않는다면, db에 저장하고 테스트하는 로직이 필요한 경우 실제 db에 값을 넣고 조회하고 삭제하는 테스트를 작성해야합니다.

유닛 테스트에서는 전체적인 동작보다, 테스트하려는 객체에 집중을 하는 것이 좋기 때문에 Mocking을 통해 그 객체에 집중을 할 수 있게 됩니다.

Mockk 라이브러리 추가

build.gradle.kts 기준으로 간단하게, 아래와 같이 dependency를 추가하면 됩니다.

dependencies {
	// ... other dependencies
    testImplementation("io.mockk:mockk:1.9.3")
}

테스트 코드 작성해보기

이제 특정 service 로직의 단위 테스트를 위해, 테스트 코드를 작성한다고 가정해보겠습니다.

다음과 같이 Aservice라는 클래스가 Arepository라는 의존성을 갖고 있다고 한다면, 실제 클래스의 내용은 대략 다음과 같을 것입니다.

@Service
class Aservice(private val aRepository: Arepository) {
	
    fun findByName(name: String): A {
    	return aRepository.findByName(name)
      		?: throw NotFoundException("$name 을 가진 A가 존재하지 않습니다")
    }
}

이제 우리는 findByName 이라는 함수를 테스트해보고 싶습니다.

의존성 mocking하기

findByName 이라는 함수를 테스트를 해야하는데, 이 때 우리는 service가 잘 작동하는지가 중요한 것이지 repository의 동작이 중요한게 아닙니다.

우리는 repository가 실제로 값을 조회해서, return하는 것보다 특정된 상황에서 service의 함수가 잘 작동하는지를 알고싶은 것이죠.

그렇기에, 우리는 aRepository를 mocking 할 것입니다. mocking은 mockk를 사용하면 다음과 같이 간단하게 할 수 있습니다.

class AServiceTest {
    private val aRepository: ARepository = mockk()
    private val aService: AService = AService(aRepository)
    
}

현재 코드를 살펴보면, Arepository는 mockk에 의해 mocking되었고 그 mocking된 repository가 AService에 주입되고 있는 것을 알 수 있습니다.

이를 통해, 우리는 각 테스트마다 특정 함수 호출에 대한 값을 임의로 조작하면서 특정 상황에 대한 유닛 테스트를 할 수 있게 됩니다.

실제 테스트 작성해보기 1 ( 성공 테스트 작성 )

이제 이렇게 mocking까지 마쳤으니, 성공 테스트를 작성해보겠습니다.

class AServiceTest {
    private val aRepository: ARepository = mockk()
    private val aService: AService = AService(aRepository)
    
    private val expectedA = A(name = "test")
    
    @Test
    fun `이름 조회 성공 테스트`() {
    	// given
        val username = "test"
        every { aRepository.findByName(username) } returns expectedA
        
        // when
        val resultA = aService.findByName(username)
        
        
        // then
        verify(exactly==1) { aRepository.findByName(username) }
        assertThat(username).isEqualTo(resultA.username) 
    }
    
}

성공 테스트를 보면 먼저 every, returns 라는 생소한 키워드를 볼 수 있습니다.

이 코드는 every 내부에 들어가는 함수의 코드가, 항상 returns 뒤에 있는 값을 return 한다는 것을 mocking을 통해 통제 하는 코드입니다.

그렇기에, 우리는 통제된 환경에서 "a를 name으로 찾았을 때" 코드가 잘 동작하는지를 테스트할 수 있습니다.

또, then 절 아래의 코드에서 verify 로 시작하는 코드는 verify 내부에 들어가는 코드가 정확히 몇 번 실행되었는지를 체크하여 mocking한 로직이 예상한대로 동작하였는지를 체크할 수 있게 해줍니다.

실제 테스트 작성해보기 2 ( 실패 테스트 작성 )

위에서 성공하는 테스트를 작성했으니, 이번에는 exception이 throw되는 실패 테스트에 대해 작성해보겠습니다.

class AServiceTest {
    private val aRepository: ARepository = mockk()
    private val aService: AService = AService(aRepository)
    
    @Test
    fun `이름 조회 실패 테스트`() {
    	// given
        val username = "test1"
        every { aRepository.findByName(username) } returns null
        
        // when
        val exception = assertThrows<NotFoundException> {
        	aService.findByName(username)
        }
        
        // then
        verify(exactly==1) { aRepository.findByName(username) }
        assertThat("$username 을 가진 A가 존재하지 않습니다").isEqualTo(exception.message) 
    }
    
}

성공 테스트와 유사하나, 달라진 점은 "test1"이라는 값이 들어 갈 때 repository는 null을 반환하고 그로 인해 exception이 throw된다는 것입니다.

그 것을 assertThrows를 이용해, exception이라는 객체에 throw된 exception을 할당하고 then 절에서 비교할 수 있습니다.

참고

mockk에는 every 외에도 여러 많은 기능을 제공합니다. 이를 살펴보려면 mockk를 방문해보는 것을 추천드립니다.

Jacoco로 coverage 체크하기

우리는 위와 같은 mockk를 통해, unit 테스트를 작성하는 방법을 알아보았습니다.

이번에는 Jacoco를 통해 우리가 작성한 테스트 코드의 커버리지가 어느 정도 되는지를 확인해보겠습니다.

Jacoco는 java에서 많이 사용되는, line coverage를 위한 library로 kotlin 프로젝트에서도 간단한 세팅을 통해 사용이 가능합니다.

plugins {
	// ... other plugins
    jacoco
}

tasks.jacocoTestReport {
    dependsOn(tasks.test)
    reports {
        html.isEnabled = true
        html.destination = file("$buildDir/reports/coverage")
        csv.isEnabled = true
        xml.isEnabled = true
    }

    var excludes = mutableListOf<String>()
    excludes.add("some/path/for/exclude")

    classDirectories.setFrom(
        sourceSets.main.get().output.asFileTree.matching {
            exclude(excludes)
        }
    )
}

tasks.test {
    // ... other tasks
    finalizedBy(tasks.jacocoTestReport)
}

jacoco 는 위와 같이 plugin과 task 정의를 통해 간단하게 사용할 수 있습니다.

간단하게 살펴보면, html, csv, xml을 생성할 것인지? 그것을 어디에 저장할꺼고, 커버리지를 체크할 때 어떤 클래스들을 제외할지.. 등과 같은 세팅입니다.

이러한 작업을 test task에 finalizedBy에 적용해 놓는다면, test가 끝나면 jacoco를 통해 커버리지를 알 수 있게 됩니다.

실제 제가 하고 있는 프로젝트의 커버리지를 살펴보면, 다음과 같은 report를 html을 통해서 간단하게 확인 할 수 있습니다.

마치면서

이번 포스트에서는 kotlin에서 mocking과 coverage 체크를 어떻게 하는지에 대해 간단하게 알아보았습니다.

저는 테스트는 선택이 아닌 필수라고 생각합니다. 테스트를 통해서 안정적인 코드를 확보할 수 있고, 이를 통해 전체적인 프로덕트의 안전성도 확보할 수 있습니다.

또, 리팩토링을 하게 될 때 작성된 테스트들은 여러분의 구세주가 될 것이라고 믿어 의심치 않습니다.

profile
글 쓰는 개발자

0개의 댓글