💻 Backend
TDD? 테스트 코드를 마주하고
해당 글은 프로젝트에 적용하면서 겪은 경험을 기반으로 합니다.
모든 것은 테스트 코드 에서 출발합니다.
테스트 코드를 통해 개발자는 기획을 더 잘 이해할 수 있고,
테스트 코드를 통해 개발자는 로직 구현시 실수를 최소화할 수 있으며,
테스트 코드를 통해 개발자는 더 좋은 설계 방법을 찾을 수 있습니다.
결과론적으로 테스트 커버리지를 보며, “테스트 했기에 안전하다.”가 아니라 테스트 커버리지를 채워가는 과정 전체가 실제 테스트 코드에서 얻는 이점인 것입니다.
실제로 한번만 테스트 코드를 작성해보면 그 장점을 바로 체감할 수 있습니다.
하지만 “어떻게?” 라는 고민거리가 따라옵니다.
그때 살펴봐야할 것은 크게 2가지입니다.
- 기술
- 방법론
기술은 말 그대로 테스트 코드 작성을 위한 문법과 같은 프레임워크 또는 라이브러리를 말하고, 방법론은 그러한 기술을 더 좋은 방식으로 설계하고 적용하기 위한 길잡이입니다.
여기서 등장하는 기술이 Junit, Kotest 와 같은 프레임워크고 mockito, mockk 등의 라이브러리입니다.
이어서 등장하는 방법론이 TDD, BDD와 같은 것들입니다.
이 중에서도 저는 Kotlin으로 프로젝트를 합니다 😊
환경
- SpringBoot : 3.1.4
- Kotlin : 1.8.22
- kotest : 5.6.1
- h2 : 2.0.202
(기타 자세한 내용은 깃헙 프로젝트 내 dependency 참고)
초간단 기능, 기획
테스트를 위한 개인 프로젝트라서 기획은 간단하게 가져갑니다.
게시판을 만들건데, 삭제 기능은 없습니다.
또 로그인 등의 인증과 유저 관련 기능은 없습니다.
- 유저는 글을 등록할 수 있다.
- 제목은 15자 이하여야 한다.
- 유저는 글을 수정할 수 있다.
- 수정하고 나면 제목의 앞에 re: 키워드가 자동 생성된다.
- 제목과 내용에 아무런 변경사항 없이 수정완료 버튼을 누르면 예외가 발생한다.
- 유저는 글을 조회할 수 있다.
- id 기반으로 단 건 조회가 가능하다.
- 검색결과가 없을 경우 NotFoundException 이 발생한다.
- 제목 기반으로 다 건 조회가 가능하다.
- 검색은 라이크 검색으로 한다.
- id 값 기준 최신순으로 정렬한다.
- Page 객체로 응답한다.
요구사항은 이 정도에서 끝입니다.
이렇게 설계한 이유는 1. API 통신 테스트를 하기 위함. 2. 예외에 대한 테스트를 하기 위함. 3. 기능에 대한 테스트를 하기 위함. 4. 유저 행위에 대한 테스트를 하기 위함. 입니다.
즉, 테스트의 모든 내용을 다루기 위해 필요한 것을 하나씩 넣은 겁니다.
BDD by Kotest
먼저 행위에 대한 테스트를 진행했습니다.
class BoardServiceTest() : BehaviorSpec() {
given...
when...
then...
}
Kotest 문서에 보면 BDD에서 자주 쓰이는 given → when → then 구조를 위한 스펙이 위처럼 존재합니다.
실제 이프 카카오 속 영상으로 보면 알 수 있듯이 BDD를 코틀린으로 적용하기 위해선 BehaviorSpec() 을 사용하는 것을 기본으로 합니다.
코드는 이렇게 완성됩니다.
given("유저가 홈화면에 진입했을 때") {
val post = Post(title = "test", contents = "normal-contents")
`when`("글이 존재하지 않는다면") {
then("404 예외가 발생한다.")
val exception = shouldThrow<NotFoundException> {
boardService.getPosts(searchPostsRequest = SearchPostsRequest())
}
exception.shouldBe(NotFoundException())
}
`when`("글이 하나라도 존재한다면") {
postRepository.save(post)
val result = boardService.getPosts(searchPostsRequest = SearchPostsRequest()).totalElements
then("글 목록이 조회된다.")
result shouldNotBe 0
}
}
테스트 코드를 구현하기 전에, 도메인 정의는 먼저 해뒀습니다.
도메인은 링크 속 깃헙을 참고해주세요.
코드를 먼저 보여드렸는데, 사실 BDD 라는 방법론을 적용했다는 것은 저렇게 유저 행위를 따라서 테스트 코드를 작성했다는 의미입니다.
TDD by Kotest
위에 링크걸어둔 이프 카카오 영상을 봤다면 아실테지만, BDD만 가지고는 테스트 커버리지를 올릴 수 없고 BDD에 어울리지 않는 기능도 있습니다.
때문에 TDD 역시 사용했습니다.
여기서 red - green - refactor 방식을 적용했는데, 굉장히 효과적이었습니다.
자세한 내용은 걸어둔 링크를 참고해주시고, 간단히 설명하면
- 먼저 만들고자 하는 기능의 테스트 코드를 짠다 → 레드 단계 ← 당연히 실패한다.
- 테스트 코드의 설계에 맞춰 실제 기능을 구현한다 → 그린 단계 ← 완료 후 테스트 코드가 성공할 수 있다.
- 파라미터 수정이라던지 혹은 설계가 아예 잘못됐다 등의 변경사항을 적용한다 → 리팩토링 단계 ← 반복…
사실 이 TDD 방법론을 적용해야 진정 테스트 코드의 의미를 극대화할 수 있다고 믿습니다.
저 또한 처음에는 의구심이 있었는데요.
심지어 간단한 기능인데 빠르게 로직 구현하지 무엇하러 테스트 코드를 다 짜고 있나…
그치만 직접 해보면 다릅니다.
RED 단계는 단순히 테스트 코드를 짜는 것이 아니에요.
테스트 코드를 설계 함으로써 실제 로직 또한 설계 하게 됩니다.
다시 말해, 설계 도안을 그리는 것과 같은 행위입니다.
그리고 기능 테스트도 있습니다.
context("글이 수정될 때") {
val post = postRepository.save(
Post(
title = "test",
contents = "normal-contents",
)
)
val updatePostRequest = UpdatePostRequest(post.id, post.title, "updated contents")
boardService.update(updatePostRequest = updatePostRequest)
expect("제목 앞에 re: 글자가 자동 생성 된다.") {
val result = boardService.getPost(post.id).title
result.take(3) shouldBe "re:"
}
}
기능 테스트에는 ExpectSpec() 을 사용했어요.
자바에 익숙하다면 jUnit 방식을 선호할 수 있는데, Kotest 에 AnnotationSpec() 도 존재합니다.
@Test
..
@BeforeTest
...
이러한 어노테이션을 작성하면서 테스트하는 Junit 방식도 제공해주지만, 저는 사용하지 않았습니다.
이미 실무에 프로젝트를 코틀린으로 전환하는 과정이라면 도움이 될 수 있는데, 코틀린 자체 프로젝트에서는 Kotest에서 오리지널 스타일로 제공하는 것이 아니기 때문에 제대로 된 코틀린을 쓰자는 생각이었어요.
그 다음에는 그린 단계로 실제 로직을 구현합니다.
fun getPosts(searchPostsRequest: SearchPostsRequest): Page<Post> {
val spec = Specification.ofPostWithTitle(title = searchPostsRequest.title)
val pageable = PageRequest.of(searchPostsRequest.pageNumber, searchPostsRequest.pageSize)
return postRepository.findAll(spec, pageable)
.also {
if (it.totalElements == 0L) throw NotFoundException()
}
}
@Transactional
fun create(createPostRequest: CreatePostRequest): Post {
return postRepository.save(Post(createPostRequest))
}
일단 해봐야 안다.
하면서 느낀게 어마어마합니다.
테스트 코드에서 그려둔 설계 도안 덕분에 실제 로직 작성이 훨씬 쉬워집니다.
또 기획서를 몇 번이고 다시 보게 되는 동시에 기획서가 테스트 코드에 녹아들면서 기획에 혼선이 없어집니다.
마지막으로 서버에 배포하지 않아도 확실하게 동작한다는 자신감을 얻을 수 있어요.
사실 이 정도로 끝나지 않고 정말 좋은 내용이 구구절절 많은데 너무 호들갑떨지 않으려고 해요.
우선 해보셔야 압니다 😄
이슈가 있다. 패키지 구성
하지만 적용하면서 이슈도 많이 있었는데요.
첫번째가 패키징 입니다.
한 Service 클래스를 테스트한다고 했을 때 행위 기반과 단순 기능 테스트가 섞여있을 수 있어요.
이 때 테스트 클래스를 어떻게 생성해야할 지 감이 안왔습니다.
찾아보니, 대게 자유롭게 하고 있었어요.
때문에 팀의 규칙에 맞춰서 해결할 수 있지 싶습니다.
저 같은 경우엔 기능 테스트는 해당 기능의 이름으로 테스트 클래스를 만들어서 진행했고
유저 행위 기반 테스트를 메인 패키지 구성을 따라 가는 방식으로 개발했습니다.
이슈 더 있다. extension 라이브러리 호환성 문제.
코틀린, 스프링부트 할 것 없이 만든 것들이 버전업데이트가 되면서 호환성에 문제가 생겼습니다.
예전에는 io.kotest 에서 모든 라이브러리를 끌고 와서 같은 버전으로 명시했습니다.
io.kotest:kotest-runner...:4.4.3
io.kotest:kotest-extension...:4.4.3
...
이런식으로..!
실제로 많은 블로그들에서도 저렇게 작성되어있습니다.
공식 라이브러리 버전을 보면 4.4.3 버전을 끝으로 더 이상 개발이 되지 않고 있어요.
이게 무슨 일인가 싶어서 확인해보니..
라이브러리를 분리했다고 합니다.
이제는 extension 은 별도의 버전으로 관리되고 있어요.
문서 내용 참고해서 삽질 시간 줄이시길 바랍니다 😂
이 프로젝트의 메이븐을 보시면 알겠지만 현재 kotest 5.6.1 버전은 extension 1.1.3 버전과 호환되고 있습니다.
마침 저는 같은 버전을 쓰고 있어서 이 메이븐을 기준으로 맞출 수 있었어요 ㅎㅎ
처음에는 이 블로거 분처럼 버전을 낮출까 생각했는데, 최신 버전을 경험하기 위해 시작한 프로젝트인데 낮추자니 자존심이 상해서 공식 문서를 열심히 뒤졌네요 😖
이슈 또 있다. 스프링부트 테스트 설정을 감지하지 못하는 문제.
Could not detect default configuration classes for test class...
라고 해서 단위 테스트 내의 테스트 하나씩은 실행이 되는데, 클래스 전체를 실행하면 로그에 위처럼 뜨면서 죽었다.
등잔 밑이 어두웠던 사례인데, 당연히 테스트 클래스가 메인의 어떤 클래스와 매핑될지 알아야합니다.
테스트 클래스를 위에서 말한 것처럼 패키징하면서 여러개로 갈라놓고 왜 안되지.. 하고 있었어요 😂
더 알고 싶다면 아래의 링크를 통해 읽어보셔도 좋아요!
끝나지 않은 테스트
사실 이걸로 테스트가 끝이 아닙니다.
보시는 것처럼 아직 75%밖에 되지 않아요.
구글이 제시한 기준에 따르면, 아래와 같습니다.
- 60% → 허용 가능한 수준
- 75% → 칭찬할만한 수준
- 90% → 훌륭한 수준
그러나 여기서 75%에 만족할 순 없겠죠. 이건 개인 프로젝트니까요😇
아직 빠진 부분이 있습니다.
바로 API 테스트를 하지 않았어요.
통신에 대해서는 Mocking 이 필수입니다.
곧 다음 글로 돌아오겠습니다 🔥