이번 글에서는 테스트 코드에 대해 섣불리 도전하지 못하는 분들을 위해 테스트 코드를 작성하는 방법과 JaCoCo 테스트 커버리지 100%를 달성해보는 내용을 정리해보려고 한다.
계기
필자도 이전까지 조금씩 테스트 코드를 살펴보거나 작성해본 적은 있어도 각 요소에 대해서 제대로 공부해보거나 모든 요소에 테스트를 작성해 본 적은 없었다.
인프런 워밍업 클럽 2기 - 백엔드 프로젝트(Kotlin, Spring) / 후기
인프런 워밍업 클럽 2기 - 백엔드 프로젝트(Kotlin, Spring) / 후기
Notion - 인프런 워밍업 클럽 2기 - 백엔드 프로젝트(Kotlin, Spring) / 후기이번에 인프런에서 진행되는 워밍업 클럽에 참여해보게 되었다.평소 사용해보고 싶었던 Kotlin 프로젝트를 만들어보기
ppusda.tistory.com
최근에 인프런에서 진행하는 스터디에 참여하게 되었고, 스터디에서 진행한 프로젝트를 피드백 받을 수 있는 기회가 생겼다.
코치님은 테스트 커버리지 100%에 도전했을 때 분명히 얻게되는 게 있을 것이라고 하셨고, 이에 도전하게 되었다.
테스트 코드는 왜 작성해야할까?
테스트 코드에 관한 내용은 생각은 생각보다 더 다양하다.
하지만 중요하지 않다는 말은 어디에서도 볼 수 없으며, 중점을 다른 부분에 두는 것 같다.
이러한 테스트 코드는 단순히 기능 내에서 발생할 수 있는 오류를 사전에 검사할 수 있다는 이점 말고도 여러 이점이 존재한다.
코드 규모에 상관없이 리팩토링 하고 배포할 수 있다.
아래 책의 내용에서 아래와 같은 내용이 있다.
구글도 초창기에는 엔지니어에 의한 자동 테스트를 그다지 중요하게 생각하지 않았다. 그러나 2005년에 구글 웹 서버(GWS) 규모와 복잡성이 무척 커지면서 생산성이 급속도로 떨어지는 경험을 했다. 릴리즈 때마다 버그가 넘쳐났고 다음 릴리즈까지의 길이는 무한정 늘어났다. 서비스를 수정해야 할 때마다 팀원들은 불안해 했고 프로덕션 환경에서만 기능에 영향을 주는 버그도 너무나 자주 발생했다.
구글 테크리드는 엔지니어 주도의 자동 테스트를 정책 차원에서 도입하기로 결정했다. 결과적으로 1년만에 긴급하게 코드를 수정해 배포하는 건수가 '절반'으로 줄었고 이와 동시에 연중 처리 완료하는 이슈 개수도 계속해서 증가하는 추세가 되었다.
구글에서도 테스트를 중요하게 생각하지 않았지만, 서비스를 수정하고 릴리즈 할 때마다 팀원들이 불안에 떨었고, 이를 해결하기 위해 자동 테스트를 도입하게 되었다.
자동 테스트 도입 이후 팀원들은 자신감있게 배포할 수 있게 되었고, 생산성이 늘어나게 되었다고 한다.
좋은 코드는 테스트하기 쉽다.
테스트 코드를 작성하다 보면 자연스럽게 객체 간의 결합도와 의존성에 대해 고민하게 된다.
테스트하기 어려운 코드는 대부분 설계가 잘못된 경우가 많으며, 이를 개선하는 과정에서 더 나은 설계를 만들고 좋은 코드를 향해 갈 수 있다.
특히 단위 테스트를 작성할 때는 각 컴포넌트를 독립적으로 테스트할 수 있도록 설계해야 하므로, 자연스럽게 좋은 설계 원칙들을 따를 수 있다.
디버깅 시간이 줄어든다.
테스트 코드를 작성하면 문제가 발생했을 때 어디서 문제가 발생했는지 빠르게 파악할 수 있다.
문제가 발생했을 때, 문제를 해결하는 시간보다 그 원인을 찾는데 사용하는 시간이 더 길 수 밖에 없다.
특히 각 단위 테스트가 특정 기능을 검증하기에 테스트 실패 시 문제의 원인을 빠르게 찾을 수 있다.
코드리뷰 시간을 절약해준다.
테스트를 통과한 코드는 기본적인 품질이 보장되므로, 리뷰어는 더 중요한 설계나 비즈니스 로직에 집중할 수 있다.
테스트 코드를 작성하는 법
기본적으로 Mocking을 하기 전에 아래와 같은 설정이 필요하다.
Mockito 확장 기능을 이용하기 위해 `@ExtendWith`를 통해 MockitoExtension을 설정해야한다.
@ExtendWith(MockitoExtension::class)
abstract class BaseServiceTest {...}
Mocking을 통해서 우리는 필요한 동작을 대신 수행하거나 동작하지 않게 할 수도 있으며 예외를 던지도록 할 수도 있다.
아래에서 소개할 각 레이어의 테스트 코드들을 통해 어떤 방식으로 단위 테스트를 진행할 수 있을지 살펴보자.
참고로 아래에서 소개하는 코드 중 설정사항은 대부분 BaseTest 클래스를 생성하여 처리하도록 했다.
부족한 코드라 계속 업데이트를 해나가고 있지만 더 자세한 내용이 필요하다면 아래 링크를 참고하길 바란다.
GitHub - ppusda/MML: Add your own music list - My Music List
Add your own music list - My Music List. Contribute to ppusda/MML development by creating an account on GitHub.
github.com
Controller
`@WebMvcTest` 어노테이션을 사용하여 특정 컨트롤러에 대한 테스트를 진행 할 수 있다.
다른 계층의 로직이 아닌 웹 계층의 로직만을 테스트할 때 사용되며 전체 애플리케이션을 로딩할 필요가 없어 보다 빠른 속도로 테스트를 진행할 수 있다.
`@MockBean`을 통해서는 생성한 가짜 객체로 해당 컨트롤러가 가지는 의존성을 주입해야 오류가 발생하지 않는다.
실제 코드
/**
* 음악 목록을 검색합니다.
*
* @param keyword 검색 키워드
* @return 검색어에 조회된 음악 목록
*/
@GetMapping("/v2/musics")
@Operation(summary = "음악 검색", description = "음악을 검색합니다.")
fun searchMusic(@RequestParam keyword: String): List<MusicDTO> {
return musicService.searchMusics(keyword)
}
테스트 코드
@WebMvcTest(MusicController::class)
class MusicControllerTest(
@Autowired private val mockMvc: MockMvc
) : BaseControllerTest(mockMvc) {
@MockBean
private lateinit var musicService: MusicService
@Test
@DisplayName("키워드로 음악 검색 요청을 보낸다")
fun testSearchMusics() {
// given
val keyword = "title"
val uri = "/v2/musics?keyword=$keyword"
// 더미 데이터
val musics = listOf(
Music("title1", "artist1", "url1"),
Music("title2", "artist2", "url2"),
Music("title3", "artist3", "url3"),
)
val musicDTOs = musics.map { MusicDTO(it) }
// Controller 내 비즈니스 로직이 실행되었을 때, 반환될 데이터 설정
`when`(musicService.searchMusics(any()))
.thenReturn(musicDTOs)
// when, then
mockMvc.perform(MockMvcRequestBuilders.get(uri))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk)
.andExpect(MockMvcResultMatchers.jsonPath("$", hasSize<Any>(3)))
.andReturn()
verify(musicService).searchMusics(keyword) // 실행되었는지 검증
}
}
위 코드를 통해 Controller 레이어에서 조회 테스트 흐름을 설명하겠다.
- @WebMvcTest에는 테스트할 컨트롤러를 작성하고 관련 의존성을 @MockBean으로 설정한다.
- Controller의 반환 값으로 예상되는 더미 데이터를 비즈니스 로직의 반환 값으로 설정한다.
- 실제로 요청을 보내어 응답 값을 검증한다.
- 실제로 전달한 내용으로 실행되었는지 검증한다.
Service
Service 테스트는 `@InjectMocks`를 통해서 특정 서비스에 대한 테스트를 진행할 수 있다.
이후 관련 의존성을 `@Mock`을 통해서 만든 가짜 객체를 주입해야 오류가 발생하지 않는다.
실제 코드
/**
* 재생목록 내에 음악을 추가합니다.
*
* @param playlistId 재생목록 아이디
* @param musicId 음악 아이디
* @throws MmlBadRequestException 이미 재생목록 내 존재하는 음악을 추가하려 할 때 발생
*/
@Transactional
fun addMusicInPlaylist(playlistId: Long, musicId: Long) {
val playlist = musicListRepository.findPlayListById(playlistId)
val music = musicListRepository.findMusicById(musicId)
validatePlaylistMusic(playlist, music)
val playlistMusic = mutableListOf (
PlaylistMusic(playlist, music)
)
playlist.addMusics(playlistMusic)
}
private fun validatePlaylistMusic(playlist: Playlist, music: Music) {
if (playlist.playlistMusics.any { it.music == music }) {
throw MmlBadRequestException(MusicListExceptionMessage.ALREADY_EXIST_PLAYLIST_MUSIC.message)
}
}
테스트 코드
class PlaylistMusicServiceTest: BaseServiceTest() {
@InjectMocks
lateinit var playlistMusicService: PlaylistMusicService
@Mock
lateinit var musicListRepository: MusicListRepository
@Test
@DisplayName("재생목록 내에 이미 존재하는 음악을 추가할 때 에러가 발생합니다.")
fun testAddAlreadyExistMusicInPlaylist() {
// given
// 더미 데이터
val playlist = Playlist("name", member)
val music = Music("title", "artist", "url")
val existingPlaylistMusic = PlaylistMusic(playlist, music)
playlist.addMusics(mutableListOf(existingPlaylistMusic))
// 비즈니스 로직 내 DB 로직들이 수행될 때 반환 값 설정
`when`(musicListRepository.findPlayListById(PLAYLIST_ID)).thenReturn(playlist)
`when`(musicListRepository.findMusicById(MUSIC_ID)).thenReturn(music)
// when & then
assertThrows<MmlBadRequestException> {
playlistMusicService.addMusicInPlaylist(PLAYLIST_ID, MUSIC_ID)
}.also { exception ->
assertEquals(ALREADY_EXIST_PLAYLIST_MUSIC.message, exception.message)
}
}
}
위 코드를 통해 Service 레이어에서 저장 시 예외가 발생할 때의 테스트 흐름을 설멍하겠다.
- `@InjectMocks`로 테스트할 서비스를 설정하고 관련 의존성을 @Mock을 통해 생성한다.
- 테스트할 비즈니스 로직 내에서 수행할 로직들의 반환 값이 있다면 설정한다.
- 실제 비즈니스 로직을 실행하여 예외가 발생할 수 있는 상황을 만든다.
- 실제로 예상한 예외가 발생했는지 예외 내용을 검증한다.
위와 같이 예외가 발생할 수 있다면 그런 상황에 대한 테스트를 모두 작성해야한다.
비즈니스 로직에서 예외가 발생하는 상황은 쉽게 연출할 수 있지만, 서비스가 복잡해지면 외부 기능을 이용하는 등 연출하기 힘든 상황을 겪을 수도 있다.
그런 경우는 thenThrow()를 이용하여 예외 상황을 임의로 발생시킬 수 있다.
실제 코드
/** MusicListRepository에 있음
* 아이디로 음악을 검색합니다.
*
* @param id 검색할 음악 아이디
* @return 검색된 음악
* @throws MmlBadRequestException 존재하지 않는 음악을 검색했을 때 발생
*/
@Transactional(readOnly = true)
fun findMusicById(id: Long): Music {
return musicRepository.findById(id).orElseThrow{
throw MmlBadRequestException(MusicListExceptionMessage.NOT_EXIST_MUSIC.message)
}
}
/** MusicListRepository 에 있음
* 아이디에 해당하는 음악을 삭제합니다.
*
* @param musicId 음악 아이디
*/
@Transactional
fun deleteMusicById(musicId: Long) {
musicRepository.deleteById(musicId)
}
/** PlaylistMusicService 에 있음
* 아이디로 검색한 음악을 삭제합니다.
*
* @param id 음악 아이디
* @throws MmlBadRequestException 존재하지 않는 음악을 검색했을 때 발생
*/
@Transactional
fun deleteMusic(id: Long) {
musicListRepository.findMusicById(id)
musicListRepository.deleteMusicById(id)
}
이 코드는 DB를 통해 검증하는 로직이 포함되어있기에 단위 테스트를 작성할 때 문제가 발생할 상황을 직접 연출하기가 힘들다.
테스트 코드
@Test
@DisplayName("존재하지 않는 음악을 삭제하려고 할 때 예외가 발생합니다.")
fun testDeleteNotExistMusic() {
// given
`when`(musicListRepository.findMusicById(any()))
.thenThrow(MmlBadRequestException(NOT_EXIST_MUSIC.message))
// when
val exception = assertThrows<MmlBadRequestException> {
musicService.deleteMusic(MUSIC_ID)
}
// then
assertEquals(NOT_EXIST_MUSIC.message, exception.message)
verify(musicListRepository).findMusicById(MUSIC_ID) // 실제로 동작했는가
verify(musicListRepository, never()).deleteMusicById(any()) // 동작하지 않았는가
}
그렇기에 특정 기능에 대해 임의로 예외를 발생시켜 테스트를 진행할 수 있다.
추가로 verify()로 실제로 조회가 동작했는지, 삭제가 동작하지 않았는지 검증하였다.
이 방법은 실제 로직을 테스트 해야하는 상황에서는 오히려 독으로 작용할 수 있으므로 조심해서 사용해야한다.
Repository
Repository 테스트는 `@DataJpaTest`를 통해서 진행할 수 있다.
Repository의 경우는 DB에 직접 쿼리를 날려 테스트를 진행해야하는 특성 상 DB 로드가 불가피한데, `@DataJpaTest`는 인메모리 데이터베이스를 띄워 테스트를 진행할 수 있기에 실제 DB가 아니더라도 기능을 테스트 할 수 있게 해준다.
사실 Repository의 경우는 직접 만든 메서드가 있는게 아니라면 굳이 테스트를 하지 않아도 된다는 의견도 꽤 많다.
Interface의 경우는 실제로 테스트 목표가 아니거와 이미 구현되어있는 메서드의 경우는 JPA나 Hibernate에서 더 잘 테스트 했을 것이기에 직접 만든 기능들에 대해서만 테스트를 구현하기로 했다.
실제 코드
검색기능 구현을 위해 Kotlin JDSL을 사용했으며, Repository를 커스텀하여 제대로 작동하는지 테스트를 해야겠다고 생각하여 테스트 코드를 작성하게 되었다.
@Repository
internal class CustomMusicRepositoryImpl(
private val kotlinJdslJpqlExecutor: KotlinJdslJpqlExecutor
) : CustomMusicRepository {
override fun findMusicsByKeyword(keyword: String): List<Music> {
return kotlinJdslJpqlExecutor.findAll{
select(entity(Music::class))
.from(entity(Music::class))
.whereOr(
path(Music::title).like("%${keyword}%"),
path(Music::artist).like("%${keyword}%"),
)
}.filterNotNull()
}
}
/**
* 키워드로 음악을 검색합니다.
*
* @param keyword 검색 키워드
* @return 검색된 음악 목록
*/
@Transactional(readOnly = true)
fun searchMusics(keyword: String): List<Music> {
return musicRepository.findMusicsByKeyword(keyword)
}
테스트 코드
참고로 Kotlin JDSL 을 테스트하기 위해서는 `@Import(KotlinJdslAutoConfiguration::class)`를 추가해야한다.
class CustomMusicRepositoryImplTest: BaseRepositoryTest() {
@Autowired
private lateinit var musicRepository: MusicRepository
@BeforeAll
fun beforeAll() {
val musics = mutableListOf(
Music("title1", "artist1", "url1"),
Music("title2", "artist2", "url2")
)
musicRepository.saveAll(musics)
}
@Test
@DisplayName("제목 혹은 아티스트 내 키워드가 포함되어 있는 음악 목록을 반환한다.")
fun findMusicByKeywordTest() {
// given - beforeAll
// when
val findMusics = musicRepository.findMusicsByKeyword("title")
// then
for (findMusic in findMusics) {
Assertions.assertThat(findMusic.title).contains("title")
}
}
}
위 코드를 통해 Repository 레이어에서의 테스트 흐름을 설멍하겠다.
- `@BeforeAll`을 사용해서 테스트에 필요한 데이터를 테스트 이전에 설정해준다.
- 테스트가 필요한 동작을 수행하고 값을 검증한다.
마치며
테스트 커버리지 100%를 처음 달성해보면서 많은 것을 배울 수 있었다. (처음 달성했을 때의 후기는 ReadME에 있다.)
테스트 코드 작성하는 흐름이나 중요성을 좀 더 깨닫게 되었고, 가장 중요한 것은 이렇게 100%를 달성해도 잘못 작성된 테스트나 처리하지 않은 분기문이 더 많다는 것을 알게되었다.
이번 글을 작성하게 되면서 내 코드를 다시 한 번 점검해보게 되었고, 잘못된 부분을 고쳐나가면서 좀 더 좋은 테스트 코드를 짤 수 있도록 나아가고 있는 것 같다는 생각이 들었다.
물론 소개한 내용이 통합 테스트나 Mocking에 대해 직접적으로 다루고 있지는 않아서 도움이 안될 수는 있지만 테스트가 귀찮거나 힘들기만 한 작업은 아니라는 것을 알리고 사람들에게 좀 더 도움이 되었으면 좋겠다는 생각에 글을 정리하게 되었다.
테스트 코드 100%에 집착하게 되면 놓치는 것이 생기게 된다는 말에도 공감을 하게 된 만큼 좋은 테스트 코드를 작성하게 된다면 좋은 코드를 만들 수 있을 것 같아서 앞으로도 좀 더 공부해 볼 생각이다.
참고
'오늘의 공부 > 기타' 카테고리의 다른 글
NCP 이용해보기! (2) | 2023.12.19 |
---|---|
코드 리뷰 작성해보기 (1) | 2023.12.02 |
else 예약어를 지양하자 (1) | 2023.12.02 |