안녕하세요.
오늘은 테스트 코드
작성 방법에 대해 고민한 내용을 나눠보고자 합니다.
사내에서 수많은 테스트 코드
를 작성하고 관리하는 많은 개발자 분들이 계실텐데요. 이렇게 한 번 질문해보고 싶습니다. "여러분이 열심히 짠 테스트는 안녕하십니까?"
사내에서 리팩토링
을 수행하면서 비어있는 테스트 코드 폴더
를 채워나가며 꽤나 많은 테스트를 단기간에 작성해오면서 생긴 문제
들과 앞으로 테스트
를 작성할 때 어떻게 작성해야 할지 결정한 나름의 기준에 대해서 생각
을 나누려고 이 글을 작성
하게 되었습니다.
🛎 이 글은
개인적인 생각
이 많이 내포되어 있으므로, 기존여러 이론
과부합
하지 않는 주장이 있을 수도 있습니다.
사내에서 테스트 코드
를 작성할때 어떻게 작성하고 계신가요?
저는 아래와 같은 기준을 잡고 테스트 코드
를 작성하고 있고, 그 기준에 따라 많은 테스트를 작성했습니다.
Repository Test
는 Mocking 하지 않고 Inmemory-DB
를 활용해 실제 쿼리를 실행한다.Service Test
는 Repository에 대해서 Mocking
하고 Business Layer
에서 나오는 예외를 검증한다.Controller Test
는 요청에 대한 검증과 올바른 응답이 나오는지 여부를 검증하는 것에 검증한다.대부분의 프로젝트에서는 Database
에 접근해서 쿼리를 하는 Persistence-Layer
가 포함되어 있습니다.
Persistence-Layer
는 Database
에 직접 접근해서 쿼리
를 해보고 정상적으로 동작하는지 검증하는 것이 중요하다는 판단이 들어서 예외적으로 Repository Test
만큼은 직접 동작시켜 봐야 한다는 생각
을 했습니다.
다만 Test Code
를 작성함에 있어서 제 1원칙으로 삼는 것은 어떤 환경에서 몇 번을 수행시켜도 늘 같은 결과
를 보여줘야 한다는 것입니다.
운영환경
이나 개발환경
에서 DB의 일시적인 장애라던지 DB가 꺼져있거나 네트워크 문제
가 있는 등의 문제가 생김으로 인해서 Test
가 실패하는 것까지도 이 원칙에 부합하지 않는다는 생각을 하게 되었습니다.
Business-Layer
부터 Mocking
으로 쌓아올려서 Repository 부터 검증을 해왔기 때문에 나머지 부분은 Mocking 해도 된다고 생각을 했습니다.
이 원칙을 잘 지켜서 테스트 코드
를 작성해서 문제가 안생겼다면 이런 글을 작성
하지 않았겠지만 이번에 제가 테스트 코드
가 제대로 작성
되지 않아서 생긴 문제와 이 문제에 대해서 앞으로 어떻게 방식
을 개선
할건지 나눠보고자 합니다.
사내에서 한 마이크로 서비스
에 대해서 리팩토링
을 수행하고 테스트 코드를 작성하고 있었습니다.
그 와중에 어떤 Service Class
에서 Repository
를 단순히 호출하는 일만 하는 함수만 5개 정도 존재하는 클래스였습니다.
이런 함수
에 대해서 테스트 코드
를 작성할 것인가? 이것에 대해서 고민하게 되었습니다.
@Service
@Transactional(readOnly = true)
class LoadBoardService(
private val boardRepository: BoardRepository,
) : LoadBoardUseCase {
override fun loadBoardById(id: Long) = boardRepository.findById(id)
}
위와 같은 함수가 있는 클래스
에서 테스트 코드를 과연 작성해야 할까? 라는 의문이 들었어요.
그렇다면 이 함수
를 테스트 하려면 어떻게 테스트
를 작성해야 할까요?
internal LoadBoardServiceTest : BusinessLayerTestSupport() {
private val boardRepository = mockk<BoardRepository>()
private val loadBoardService = LoadBoardService(boardRepository)
this.Given("게시글 하나를 조회하려는 상황에서") {
val boardReponse = fixture<BoardResponse>()
every { boardRepository.findById(any<Long>()) } returns boardResponse
When("조회를 시도하면") {
val actualResult = loadBoardService.loadBoardById(1L)
Then("정상적으로 조회되어야 한다.") {
actualResult shouldBe boardResponse
verify(exactly=1) { boardRepository.findById(any<Long>()) }
}
}
}
}
생각보다 많은 양의 코드를 작성하지는 않지만 이것 또한 비용
이라는 생각
이 들었습니다.
여기서 생각해봐야 할 부분은 테스트를 위한 테스트를 작성하고 있지는 않은가? 라는 부분
입니다.
테스트 코드
는 장기적으로 코드
의 안정성
을 높여주고 그로 인해 많은 버그
를 예방할 수 있으면 CI/CD 환경에서 지속적으로 검증
되고 통합
되게 됩니다.
하지만 테스트 코드
역시 지속적으로 관리해야 하는 부채에 해당되며 테스트 코드
또한 품질이 저하될경우 리팩토링의 대상이 되어야 합니다. 이는 지속적으로 비용
을 증가
시키는 행위인 것입니다.
지금껏 테스트 코드
를 작성해오면서 이 서비스 클래스도 언젠가는 더 많은 책임
을 지게 될 것이고 "미리 테스트를 작성해두어야 해!" 라는 생각으로 부채
를 늘려가고 있었던 것입니다.
테스트 코드
를 작성하는 것이 반드시 비용
을 감소
시키는 행동이 될 수 없습니다.
테스트 코드
를 관리하는 것은 분명한 비용
이 있으며 그 비용이 전체적인 프로덕션 안정화에 쓰던 기존 비용이 저렴할때 테스트 코드
와 TDD의 의미
를 갖출 수 있는 것입니다.
사내에서 대부분의 프로젝트
에서는 JPA
를 사용하고 있습니다.
Business-Layer
에서 특정 Repository
들을 호출하고 그로 인해 값이 변경되어야 하는 로직이 있다고 했을때 Mocking
을 사용해서 테스트를 하면 문제
가 왜 생기는지 보겠습니다.
interface BoardRepository : JpaRepository<Board, Long> {
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("some modifying query~")
fun modifyBoardStatus(boardId: Long, status: Status)
}
@Service
@Transactional
class ModifyBoardService(
private val boardRepository: BoardRepository,
) : ModifyBoardUseCase {
override fun modifyBoard(modifyBoardServiceRequest: ModifyBoardServiceRequest) {
// Board 조회
val board = boardRepository.findById(modifyBoardServiceRequest.boardId)
// Board 상태 변경 쿼리 실행
boardRepository.modifyBoardStatus(modifyBoardServiceRequest.boardId, Status.Common)
// 내부 상태 변경 -> Dirty Checking으로 쿼리가 나갈 것을 기대
board.setBoardName("새로운 게시글")
}
}
이런 서비스 클래스를 Mocking
해서 테스트 하면 어떤 문제가 생길까요?
이 문제
를 단 한번에 캐치하셨다면 최고의 JPA Master
이십니다! 저는 이 문제를 찾는데 꽤나 많은 시간을 보냈습니다.
여기서 문제는 Repository
의 변경 쿼리가 나가는 함수에 있습니다.
interface BoardRepository : JpaRepository<Board, Long> {
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("some modifying query~")
fun modifyBoardStatus(boardId: Long, status: Status)
}
이 코드에서 영속성 컨텍스트
를 비우는 옵션을 true
로 켜두었기 때문에 문제가 생깁니다.
Board
를 최초 조회했을때 이 엔티티는 영속성 컨텍스트
에서 관리하게 되고 영속성 컨텍스트
에서 관리하는 엔티티만 내부 상태를 변경했을때 트랜잭션 종료시에 스냅샷과 비교하여 변경 쿼리
가 나가게 됩니다.
우리는 이걸 변경 감지 즉, Dirty-Checking
이라고 합니다.
그렇다면 이 서비스 클래스
의 함수는 어떤 오류를 가지고 있을까요?
// Board 조회
val board = boardRepository.findById(modifyBoardServiceRequest.boardId)
// Board 상태 변경 쿼리 실행
boardRepository.modifyBoardStatus(modifyBoardServiceRequest.boardId, Status.Common)
// 내부 상태 변경 -> Dirty Checking으로 쿼리가 나갈 것을 기대
board.setBoardName("새로운 게시글")
이 코드
를 살펴보면 아래와 같은 순서
로 동작합니다.
Dirty-Checking
동작을 기대한다.여기서 문제는 Dirty-Checking
이 예상한대로 동작
하지 않는 것이 가장 큰 문제
였습니다.
2번에서 BoardRepository
함수를 호출할때 영속성 컨텍스트를 비웠기 때문에 Board
엔티티는
영속성 컨텍스트
에서 관리되지 않으므로 변경 감지가 동작하지 않아서 Mocking
한 테스트 코드로는 이 상황을 인지할 수 없었습니다.
그래서 이 글의 결론
으로 저만의 테스트 코드
작성 원칙을 정했습니다.
저는 이번 이슈를 해결하며 이번 일을 계기로 아래와 같은 테스트 코드
작성 방향을 정했습니다.
Classsist Style
로 작성한다!JPA Dirty Checking
과 같이 런타임에만 동작을 확인할 수 있는 경우에는 실제 구현체를 동작시켜봐야 하기 때문에 Mocking
을 하지 않기로 했습니다.HttpClient
요청 등에 대해서만 Mocking을 한다.Http 요청 실패
등으로 테스트가 실패하면 안되며, 특정 상황에서 어떤 응답이 올것이라는 기본 기대값
을 가지고 테스트
를 하는 것이 맞다고 판단이 들었습니다. 테스트 코드
를 작성하면서, 테스트 코드
는 비용이고 추후에 기술 부채
가 될 여지가 다분하다는 것을 인지하고 현재 시스템
을 안정화 시키고 리팩토링 내성이 강한 애플리케이션
을 만들기 위한 초석으로 테스트 코드를 보수적
으로 작성
해 나가는 것이 중요하다는 것을 알게 된 사건이였습니다.
저는 이번 일로 데이터
가 잘못 들어가고 있다는 것을 알게되어 데이터 보정 스크립트
를 작성하고 약 200만건
이상의 데이터를 수정하게 되었습니다.
값비싼 수업료
를 냈지만, 그래도 시행착오를 겪으며 성장하는 제 모습을 기대하며 이 글을 마칩니다.
오늘도 제 글을 읽어주셔서 감사합니다!
테스트에 대한 가치관과 재미있는 일화네요! 글 감사합니다!