글 작성자: beaniejoy

(메인 사진 출처: https://famunity.net/kotlin-junit5/)

개인프로젝트 진행하며 카페 도메인 신규 생성 서비스 로직에 대한 단위테스트를 적용했던 내용을 기록겸 작성한 글입니다. 서비스 단위 테스트를 위해 JUnit5, mockito를 이용하여 진행하였습니다.


 

📌 1. 서비스 테스트를 위한 기본 구성

@ExtendWith(MockitoExtension::class)
@TestMethodOrder(MethodOrderer.DisplayName::class)
internal class CafeServiceTest {
    @InjectMocks
    lateinit var mockCafeService: CafeService

    @Mock
    lateinit var mockCafeRepository: CafeRepository
    
    //...
 }

CafeService에 대한 테스트 클래스 구성 내용입니다. Mockito를 이용한 Mock 객체를 활용한 단위 테스트를 진행하고자 MockitoExtension을 적용하였습니다.

CafeService 실제 로직 안에서는 CafeRepository를 사용하기 때문에 이에 대해서 mock 객체를 주입받습니다. 적용하고자 하는 테스트는 CafeService 단위에서 수행하는 로직이 잘 작동하는지에 대한 테스트이지 CafeRepository에 대한 테스트는 아닙니다.

이에 따라 CafeRepository는 mock 객체로 적용해서 return 값에 대해 미리 설정하고 CafeService 로직 테스트에 집중하고자 mock을 적용하였습니다.

@InjectMocks는 해당 어노테이션이 붙은 객체에 @Mock 가짜 객체를 주입시켜줍니다. 다시 말해서 가짜 CafeService bean을 생성하기 위해 의존성이 있는 CafeRepository를 @Mock을 통해 가짜 객체를 받아와 알아서 주입시켜 준다고 생각하시면 됩니다.

@TestMethodOrder는 테스트 결과 내용을 표시할 때 @DisplayName에 지정한 테스트 이름을 기준으로 순서대로 표현하기 위해 설정한 내용입니다.

 


 

📌 2. 기본적인 카페 신규 생성 테스트 코드 구성

@Test
@DisplayName("카페 신규 생성 테스트")
fun create_cafe_test() {
    // given
    // 카페 생성을 위한 CafeRequestDto 구성 & mock 객체의 return 내용 지정
    
    // when
    // 실제 카페 생성 서비스 로직 실행(mockCafeService.createCafe)
    
    // then
    // 카페 생성 서비스 내부 로직이 실행됐는지 여부 체크(verify)
    // 생성 서비스 로직 결과로 return하는 id 값 일치 여부 체크(assertEquals)
}

단위 테스트에서 가장 기본적으로 사용하는 given - when - then 틀을 사용하였습니다.

given에는 카페 생성을 위한 재료가 되는 CafeRequestDto를 구성하고 카페 생성 서비스 로직에서 호출되는 mockCafeRepository 메소드에 대한 return 내용을 설정하였습니다.

when에서는 실제 테스트하려는 대상 메소드가 실행되어야 하기 때문에 given에서 구성했던 requestDto 정보들을 가지고 mockCafeService.createCafe(...)를 직접 호출합니다.


then
에서는 그 결과값이 예상하는 값과 일치하는지 체크하는 것과 테스트 대상이 되는 생성 서비스 내부 로직들이 잘 호출되고 있는지 검증하는 코드를 구성하였습니다.

 


 

📌 3. given

// given
val cafeRequestDto = CafeTestUtils.createCafeRequestDto()
val savedMockCafeId = 100L

`when`(mockCafeRepository.findByName(cafeRequestDto.name!!)).thenReturn(null)
`when`(mockCafeRepository.save(any(Cafe::class.java))).thenAnswer {
    injectCafeId(it.getArgument(0), savedMockCafeId)
}

//...

private fun injectCafeId(
    cafe: Cafe,
    newCafeId: Long,
): Cafe {
    val idField = cafe.javaClass.declaredFields
        .find { f ->
            f.getAnnotation(GeneratedValue::class.java) != null
        } ?: return cafe

    idField.isAccessible = true
    idField.set(cafe, newCafeId)

    return cafe
}

CafeRequestDto 객체를 생성하는 내용은 다른 곳에서도 자주 사용되기 때문에 utils 클래스를 따로 두어 메소드화 하였습니다.
Cafe Entity를 생성하고 반환되는 Cafe id 값을 미리 설정하였습니다. (savedMockCafeId)

카페 생성 서비스 로직에서 호출되는 것들 중에 따로 return을 지정해야 하는 부분은 크게 두 부분인데요. CafeRepository의 findByNamesave입니다.

findByName은 null 로 return하게 해서 신규 생성하려는 카페의 이름이 기존에 없는 것으로 가정하였습니다.
(findByName에서 반환되는 Cafe Entity가 존재한다는 것은 기존에 이미 저장된 카페 정보가 있다는 의미이므로 Exception을 반환하게 됩니다. 이에 대해서 따로 Test 코드를 구성하여야 합니다.)

save 메소드에 대해서는 return하는 Cafe Entity가 미리 설정한 cafe id인 savedMockCafeId 값을 가지고 있어야 합니다. 이를 위해서 thenAnswer를 통해 save의 argument로 들어오는 Cafe Entity 내용을 가로채서 기대하는 savedMockCafeId 값을 주입하면 됩니다.
(thenAnswer는 when에서 설정한 메소드에 들어가는 인자를 가로채서 해당 내용을 조작하여 return하도록 할 수 있게 해줍니다.)

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L

Cafe의 id 필드는 val로 지정되어 있기 때문에 기본적으로 setter 접근이 어렵습니다. 원하는 값으로 주입하기 위해서 Reflection을 이용해 id 필드값에 주입한 것을 테스트 코드에서 확인할 수 있습니다.
(isAccessible = true 를 통해 setter가 없는 private 필드에 접근하여 값을 주입할 수 있습니다.)

 


 

📌 4. when

// when
val savedCafeId = mockCafeService.createCafe(
    name = cafeRequestDto.name!!,
    address = cafeRequestDto.address!!,
    phoneNumber = cafeRequestDto.phoneNumber!!,
    description = cafeRequestDto.description!!,
    cafeMenuRequestList = cafeRequestDto.cafeMenuList
)

when에서는 테스트 대상이 되는 카페 생성 서비스 메소드를 직접 호출합니다. 인자 값으로는 given에서 미리 설정했던 CafeRequestDto 내용으로 설정하면 됩니다.


 

📌 5. then

// then
verify(mockCafeRepository).findByName(cafeRequestDto.name!!)
verify(mockCafeRepository).save(any(Cafe::class.java))

assertEquals(savedCafeId, savedMockCafeId)

then에서는 카페 생성 서비스 내부 로직들이 잘 호출되었는지와 생성 로직의 최종 결과값이 기대했던 값과 일치하는지를 체크하게 됩니다.

verify를 통해 생성 서비스 로직에서 호출되는 내용들이 잘 호출되었는지 체크합니다. 여기서 체크해볼 대상들은 CafeRepository의 findByNamesave 메소드입니다.

그리고 카페 생성 서비스 로직의 최종 결과값으로 save 이후 반환되는 cafe id 값을 반환하게 되는데 given에서 미리 설정했던 기대값인 savedMockCafeId와 값을 비교하면 됩니다.

 


 

📌 6. (참고) Cafe Entity 객체를 create하는 메소드

CafeService createCafe 메소드 안에는 인자로 받은 RequestDto 내용들을 가지고 Cafe Entity 객체를 생성합니다. 생성한 Cafe Entity를 JpaRepository save를 통해 영속화시키고 DB에 데이터들을 저장하게 됩니다.

val cafe = Cafe.createCafe(
    name = name,
    address = address,
    phoneNumber = phoneNumber,
    description = description,
    cafeMenuRequestList = cafeMenuRequestList
)

Cafe class의 companion object을 통해 Cafe Entity 객체 생성 로직을 메소드화하였습니다.

하지만 CafeServiceTest에서는 이에 대한 테스트 검증을 따로 하지 않았는데요. Cafe Entity에 대한 Test 코드를 따로 구성하여 Cafe.createCafe를 테스트를 따로 진행하고 있기 때문에 CafeServiceTest에서는 검증할 필요를 느끼지 못하여 제외하였습니다.


 

위와 같이 테스트 코드를 구성하고 테스트를 진행하면 기분 좋게 테스트를 성공하는 것을 확인할 수 있습니다.

Service 단계에서 단위 테스트를 구성할 때 테스트를 해야하는 대상들을 선별하는 것과 Mock 객체를 활용해 Service 단위의 로직들만을 가지고 테스트하도록 하는 것이 중요하다는 것을 알 수 있었습니다.

kotlin 관련 테스트 모듈을 가지고 더 세련된 코드를 구성할 수도 있을 것 같네요.

(단위 테스트 구성에 대한 저의 얕은 지식으로 인해 틀린내용이 있을 수 있고 코드가 상당히 저급할 수 있습니다. 이에 대한 코멘트 언제나 환영합니다.)