- 테스트 커버리지 100% 달성기[1] - 레이어별 100% 달성 과정
- 테스트 커버리지 100% 달성기[2] - 테스트 환경 구축 및 시간 단축
최근 토스에서 테스트 커버리지 100% 달성한 글과 영상을 보면서 테스트 커버리지 100% 달성의 목표를 세우고 이를 달성하였다.
나는 개인적으로 테스트를 굉장히 중요시 생각하며 테스트 코드 작성이 아니더라도 수동 테스트도 많이 진행한다.
프로젝트 구조와 결과에 대해 설명하고 100% 달성 과정에 대해 설명하겠다.
프로젝트 구조
- kotlin(jvm 1.9.22)
- spring-boot 3.2.3
- jpa, querydsl
- 헥사고날 아키텍쳐(멀티모듈)
- presentation layer : controller
- business layer : servcie(pojo)
- infrastructure layer
- repository(jpa)
- storage(s3)
- etc(module) : security(로그인 및 보안), logging(ip 추출 등), identity(본인인증), mail, socket(외부서버 연동) 등등
- 코드 라인수 : 11,000
- 테스트 커버 라인 수 : 4,000
- 테스트 케이스 수 : 600개
- 테스트 커버리지 측정 도구 : jacoco, sonarqube
(소나큐브에서 제공하는 코드라인수에 대해서 잠시 설명하겠다.
Duplications 탭에 있는 16k lines라고 적혀있는 항목은 테스트 코드까지 포함되는 항목이다.
Coverage 탭에 있는 4k는 main 하위의 실제 실행되는 메서드 라인수이다. 이 항목에는 private 생성자, 상수, enum, 인터페이스 등은 제외된다. 그리고 위 이미지에는 존재하지 않지만 소나큐브에 접속하면 상단에 코드 탭이 존재한다. 코드 탭에 접속하면 실제 main 하위의 코드 라인수를 확인할 수 있다. 나의 경우 main 하위 코드가 11,000라인이다.)
위와 같이 나는 커버리지 100%에 도달했다.
테스트 주도 개발은 아니었고, 자바 코드로 작성되었던 프로젝트를 코틀린으로 70% 정도 리팩토링 한 이후 테스트 코드 작성을 시작하였다. 따라서 나는 이미 작성된 70% 정도의 코드는 레이어별로 나눠 테스트 코드 작성을 하였고 나머지 30%는 프로덕션 코드를 작성하며 테스트 코드도 같이 진행하였다.
자 이제 본격적으로 레이어별 테스트 커버리지 100% 달성 과정을 설명하도록 하겠다.
controller 모듈
아래와 같은 우선 순위를 두고 테스트를 진행하였다.
우선순위 | 대상 | 테스트 종류 |
1 | controller | 스프링 web slice |
2 | validator(requet객체 검증) | pure |
3 | util | pure |
4 | exception handler | pure |
5 | 스케줄러 | pure |
6 | request, respose 객체 | pure |
180개 정도의 테스트 코드가 작성되었으며 주 테스트 대상은 controller 메서드이다.
따라서 WebMvcTest로 web단 슬라이스 테스트 환경을 만들고 mockMvc를 통해 대다수의 테스트가 진행되었다
controller 메서드와 메서드의 인자값 객체 검증 validator만 테스트 하더라도 90%까지 도달할 수 있다.
나머지 util과 스케줄러, exception handler 등에도 로직이 있으니 이를 포함하면 97~98%까지 도달 할 것이다.
문제는 dto이다. 사용자의 요청 데이터를 request 객체로 변환하고 init 과 같은 메서드에서 값 검증을 진행해야하기에
이를 별도로 테스트를 진행해야한다.
이또한 로직이기에 당연히 테스트 해야한다. 허나 조금의 허무함은 들 것이다.
service 모듈
우선순위 | 대상 | 테스트 종류 |
1 | service | 단위 테스트 |
2 | dto/vo | 테스트 제외 |
커버리지 100% 도달에 가장 어려움이 있는 모듈이다.
비즈니스 로직이 존재하여 테스트 코드 작성이 가장 필요로 하는 모듈이고 웹서버나 DB와 의존성이 없어 테스트 코드 작성이 가장 수월하지만 예상치 못한 어려움이 있었다.
mvc 패턴에서 주로 나타나는 아래와 같은 싱크홀 안티패턴 때문이다.
class MemberReadService(
private val memberReadOrmPort: MemberReadOrmPort
) : MemberReadCase {
override fun existEmail(email: String): Boolean =
memberReadOrmPort.existEmail(email)
override fun readByIsTmpPassword(isTrue: Boolean, limit: Long): List<UUID> =
memberReadOrmPort.readByIsTmpPassword(isTrue, limit)
override fun readUserIdByHumanStatus(humanStatus: Int): List<UUID> =
memberReadOrmPort.readUserIdByHumanStatus(humanStatus)
}
서비스 객체가 직접적으로 로직을 수행하기 보다는 다른 레이어 객체 메서드를 호출하는 것 밖에 없다.
사실 위와 같은 코드에서 단위테스트를 진행할 수 있는 것은 없다.
객체 스스로 처리하는 것은 없고 의존객체 메서드를 호출하고 이 결과를 그대로 반환하는 것 밖에 없기 때문이다.
그렇다면 싱크홀 안티패턴을 제거하는 것을 고려해야하나??
나의 경우 싱크홀 안티패턴을 제거했다. 싱크홀 안티패턴에 대해 알아보니 80-20의 규칙이 있었다.
단순히 말하면 80%는 로직이 존재하고 20%는 단순히 요청을 전달하는 싱크홀 안티패턴이 있어도 괜찮다는 것이다.
허나 이 비율이 깨진다면 싱크홀 안티패턴을 제거하는 것을 고려하라고 한다.
나의 경우 싱크홀 안티패턴의 비중이 높아 약 15개의 클래스에서 40개의 메서드를 제거하였다.
전부 다 제거한 것은 아니다. 싱크홀 안티패턴 로직에 대한 테스트는 억지 테스트 코드 작성이라고 보일정도로 verify를 통한 메서드 실행 여부를 체크하는 것만으로 진행하였다.
개인적으로 아직도 싱크홀 안티패턴을 없애는게 바람직한가라는 생각이 든다.
불필요한 객체 생성을 막을 수 있고 불필요한 요청 전달을 막아줄 수 있기에 컴퓨터의 성능에는 분명히 장점이다.
허나, 개인적으로 코드 컨벤션이나 프로그램 구조가 깨지는 것은 좋지 않게 생각한다.
단순한 구조가 가장 좋은 프로그램 구조라고 생각하는데 싱크홀 안티패턴을 제거하게 되면 controller->service->repository로 정형화되어 있는 구조에서 controller->repository 구조가 추가된다.
별거 아니어 보이지만 사소한 규칙 하나 추가가 그 규칙을 지키기 위한 많은 기준을 필요로 하기에 좋지 않게 생각한다.
참고로, 레이어 자체를 개방하여 controller에서 repository로 바로 접근하지는 않았다.
service 모듈에 존재하는 인터페이스를 repository에서 구현하였고 controller는 서비스 인터페이스를 통해 주입받아 사용하도록 하였다.
repository 모듈
우선순위 | 대상 | 테스트 종류 |
1 | repository | 단위 : DataJpaTest |
2 | factory method | 단위 : pure |
3 | event listener | 단위 : DataJpaTest |
4 | entity | 단위 : DataJpaTest |
5 | converter | 단위 : pure |
repository 객체는 주로 DataJpaTest를 통한 슬라이스 테스트로 진행하였다.
repository를 단위 테스트 하며 entity와 converter, factory method 등이 대다수 커버리지에 포함되었다.
factory method는 영속화를 위한 entity 생성이기에 repository에서만 사용되어 거의 100% 리포지토리를 테스트하며 커버리지에 포함시킬 수 있었다.
허나, 문제는 entity와 converter이다.
entity의 경우 양방향 연관관계를 설정하였지만 한쪽에서만 사용되는 경우가 있고,
menu 테이블 처럼 프로그래밍 서버에서는 조회 작업만 진행하고 insert를 통한 menu 추가는 db에서 직접 쿼리를 날리는 경우 entity의 세터 메서드에 대한 커버러지 측정이 어렵다.
이는 convert의 경우도 동일하게 적용된다. db값을 프로그래밍 서버에서 사용하는 값으로 변환하는 메서드는 사용되지만, 반대의 메서드가 사용될일이 없다.
이와 같은 케이스는 별도로 직접 테스트를 진행하였다.
entity의 경우 entityManager를 통한 영속화 테스트를 진행하였고, convert의 경우에도 별개로 진행을 하였다.
정리
storage나 email 그리고 로그인, 로깅 모듈과 같은 테스트 코드는 설명에서 제외하겠다.
이 모듈에 대한 테스트도 정말 중요하지만, 해당 라이브러리에 너무 치우쳐지는 설명이 될 것 같아 제외하였다.
총평을 해보겠다.
커버리지 도달에 어려움이 컸던 로직
- getter, setter
- 코틀린 메서드
성과
- 코드 오류 발견
- 불필요한 운영 코드 제거
- 복잡하게 구현된 소스 단순하게 변경
한계 - 커버리지 수치 올리기 위해 급급하게 테스트 진행한 결과
- 과도한 mock 프레임워크 사용
- 테스트 성능 저하
- 병렬 테스트 진행 못함
getter, setter에 대한 테스트는 정말 허무하다.
자바 같은 경우 getter, setter를 직접 구현하지 않고 lombok을 통해 제공 받는데 많은 글들을 읽어보니 lombok으로 생성된 코드 같은 경우 jacoco에서 제외시키는 경우가 많았다.
코틀린의 경우 lombok을 사용하지 않고 변수 선언 키워드에 따라 알아서 생성되서 제외시킬 수 없다.
억지 테스트 코드가 나왔던 부분이다.
개인적으로 98%에서 나머지 2%를 채우기위한 테스트 코드는 정말 의미 없는 억지 테스트 코드가 짜여진다고 과감하게 인정한다.
jacoco 커버리지가 java class 파일로 측정을 하니 코틀린 코드가 자바 클래스 파일로 컴파일된 소스 기준으로 측정한다.
따라서 분기 커버리지 같은 경우 아래와 같은 상황이 생긴다.
코틀린 코드
if (!contentsCreateDto.choiceAnswer.isNullOrEmpty())
자바 코드
multiChoiceType = var5 != null && !var5.isEmpty()
나의 코틀린 코드로는 두개의 분기라고 생각했지만, 자바로 바꾸니 4개의 분기가 생겼다.
위와 같은 패턴이 많이 생긴다.
자바로 변환했을때, 더 단순한 분기 처리의 코틀린 메서드로 운영 코드를 바꿧다. 테스트코드도 무한정 많이 작성되면 파악하기가 어려울 것이라 생각했기에 단순한 분기를 가져갈 수 있다면 단순한 분기 메서드를 선택했다.
이글을 읽으며 거부감이 드는 분들도 있을 거라고 생각한다.
getter, setter를 테스트 한다고?? 저런 사소한 분기도 모두 테스트 한다고?? 사용하지도 않는 엔티티 연관관계와 컨버터까지 모두 테스트 한다고?? 라는 생각으로 비효율적이라는 생각이 드는 분이 있을테지만 이제부터 장점을 설명하겠다.
뭐니뭐니 해도 오류 발견만큼 좋은 건 없을 것이다. 테스트 코드 작성하며 굉장히 많이 잡힌다.
실제로 나는 회사에서도 테스트 코드 작성이 필수가 아니지만 테스트 코드를 작성한다.
오류 1개만 잡아내도 나는 내가 작성한 수많은 테스트 케이스가 전혀 아깝지 않다.
이런 사소한 오류들을 잡기 위해 테스트 코드를 작성한다고 생각한다.
또한 테스트 코드는 운영 코드를 설명하는 하나의 문서이기에 내가 작성한 코드를 다른 팀원에게 설명할 때 좋은 자료로 쓰일 수 있다.
두번째 장점은 불필요한 운영코드를 제거할 수 있다. 사용되는 로직과 사용 되지 않는 로직에 대한 구분이 빠르게 가능해지기에 사용되지 않는 로직은 과감하게 제거할 수 있다.
마지막으로 소개할 장점은 복잡하게 구현된 소스를 단순하게 리팩토링 할 수 있다.
테스트 코드 작성을 위해 production 코드를 보다 분기 조건이 나오면 머릿속에 드는 생각은 "아 분기별로 다 테스트 해야하는구나~"라는 안타까운 생각이 든다.
하지만 사람은 참 간사하고 영리하다. 분기조건을 더 단순하게 바꿀수 있는 아이디어가 떠오른다.
왜 저 코드가 저렇게 많은 분기를 타고 있지, 더 단순하게 만들 수 있는데, 불필요한 분기 조건까지 추가되어있네,
이러한 잘못된 부분이 보인다. 테스트 코드를 작성하며 한줄한줄 테스트 대상을 집어내며 잘못되고 복잡한 부분을 잡아낼 수 있다.
이는 개인적으로 가장 큰 장점이라고 생각한다.
'테스트' 카테고리의 다른 글
테스트 커버리지 100% 달성기[2] - 테스트 환경 구축 및 시간 단축 (0) | 2025.01.27 |
---|