- 테스트 커버리지 100% 달성기[1] - 레이어별 100% 달성 과정
- 테스트 커버리지 100% 달성기[2] - 테스트 환경 구축 및 시간 단축
- 테스트 커버리지 100% 달성기[3] - 테스트 코드 가독성 개선
최근 토스에서 테스트 커버리지 100% 달성한 글과 영상을 보면서 테스트 커버리지 100% 달성의 목표를 세우고 이를 달성하였다.
나는 개인적으로 테스트를 굉장히 중요시 생각하여 해당 영상을 보고 도전 해봐야겠다는 생각이 들어 과거에 실사용자들을 대상으로 운영한 서비스를 대상으로 커버리지 100%에 도전하였다.
포스팅 글은 총 3편으로 제공되고 첫번째 글은 레이어별 테스트 대상 소개와 커버리지 100% 달성 과정을 공유하고,
두번째 글은 레이어별 테스트 환경 구축 방법과 테스트 시간을 단축한 과정을 소개한다.
세번째 글은 코드 품질 향상을 위해 리팩토링한 글이다. 커버리지 100%를 달성하였다고 하더라도 잘못 작성된 테스트 코드는 prod 코드에 대한 완벽한 검증이 이뤄졌다고 보기 어렵기에 해당 부분을 학습하고 리팩토링한 내용이다.
프로젝트 구조와 결과에 대해 설명하고 첫번째 글을 시작하겠다.
프로젝트 구조
- 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), email, 외부 연동 서버
- etc(module) : security(로그인 및 보안), logging(ip 추출 등), identity(본인인증), mail 등등
- 코드 라인수 : 11,000
- 테스트 커버 라인 수 : 4,200
- 테스트 케이스 수 : 650개
- 테스트 커버리지 측정 도구 : jacoco, sonarqube
(Duplications 탭에 있는 16k lines라고 적혀있는 항목은 테스트 코드까지 포함되는 항목이다.
Coverage 탭에 있는 4k는 main 하위의 실제 실행되는 메서드 라인수이다. 이 항목에는 private 생성자, 상수, enum, 인터페이스 등은 제외된다.)
위와 같이 나는 커버리지 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%까지 도달 할 것이다.
나머지 2% 정도는 단순 값 검증을 하는 dto 검증이다.
사용자의 요청 데이터를 request 객체로 생성되며 초기화되는 init 메서드를 검증해야한다.
init에는 정말 단순히 0보다 큰지, 몇글자인지와 같은 로직이 주를 이룬다.
너무 단순하여 테스트 하는게 무슨 의미가 있나 싶었지만, 단순 값 검증이라고 할 지라도 프로그램에서 실행되는 영역이므로 테스트를 진행하였다.
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)
}
서비스 객체가 직접적으로 로직을 수행하기 보다는 다른 레이어 객체 메서드를 호출하는 것 밖에 없다.
사실 위와 같은 코드에서 단위 테스트를 진행할 수 있는게 마땅히 없다.
커버리지 100% 달성을 위해서 단순히 실행됬는지 여부를 검증하는 것 밖에 없다.
싱크홀 안티패턴을 제거하는 것을 바람직한가??
불필요한 객체 생성을 막을 수 있고 불필요한 요청 전달을 막아줄 수 있기에 컴퓨터의 성능에는 분명히 장점이다.
허나, 코드 컨벤션이나 프로그램 구조를 깨는 것이 바람직한지 여부도 고려해야한다.
단순한 구조가 가장 좋은 프로그램 구조라고 생각하는데 싱크홀 안티패턴을 제거하게 되면 controller->service->repository로 정형화되어 있는 구조에서 controller->repository 구조가 추가된다.
별거 아니어 보이지만 사소한 규칙 하나 추가가 파생적인 많은 기준을 만들어 낼 수 있기에 좋지 않을 수 있다.
나의 경우 싱크홀 안티패턴을 어느정도 제거하였으나 아직도 이부분이 바람직한지에 대한 의문이 든다.
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 생성이기에 리포지토리를 테스트하며 커버리지에 포함시킬 수 있었다.
허나, 문제는 entity와 converter이다.
양방향 관계의 entity이지만 한쪽에서만 사용하는 경우가 있고,
menu 테이블 처럼 프로그래밍 서버에서는 조회 작업만 진행하는 경우 entity의 세터 메서드에 대한 커버러지 측정이 어렵다.
이는 converter의 경우도 동일하게 적용된다. db column -> class property, class property -> db column으로 변환하는 로직 중 둘 중 하나만 사용되는 경우가 있다.
실제 사용되지 않지만 존재 해야하는 코드들이 있기에 커버리지 100%를 위해 무의미하게 작성되기도 한다.
정리
storage나 email 그리고 로그인, 로깅 모듈과 같은 테스트 코드는 해당 라이브러리에 너무 치우쳐지는 설명이 될 것 같아 설명에서 제외하겠다.
95% 정도의 테스트는 분명히 의미 있는 테스트 코드이다.
허나 5% 정도의 테스트는 무의미 하지 않았나 생각한다
싱크홀 패턴의 코드 테스트, getter 및 setter 테스트, 사용되지 않는 코드에 대한 테스트 등이 그 예시이다.
그럼에도 불구하고 커버리지 100% 달성은 도움이 많이 된다.
개인적으로 느낀 커버리지 100%의 장점에 대해 설명하겠다.
1. 반드시 검증해야하는 테스트를 빼먹을 일이 없음
만약 95%라는 커버리지 기준이 있다면 작성하지 않아도 되는 5% 기준을 무엇으로 둘 것인가?
위에서 설명한 무의미한 테스트가 5%를 넘어간다면 결국에 95%를 달성해야하기에 무의미한 테스트를 진행해야한다.
만약 5%가 아닌 3%라고 해보자.
커버리지 배포 기준이 95%에만 맞춘다면 정말 테스트해야하는 2%를 테스트하지 않고 넘어갈 수 있다.
100%라는 완벽한 기준은 반드시 테스트 해야하는 코드를 빼먹는 상황을 예방할 수 있다.
2. 안정적이고 빠른 리팩토링
2편과 3편에서 설명하겠지만, 나는 커버리지 100%를 달성하고 테스트 시간을 단축하기 위해 코드를 개선하고, 테스트 코드 리팩토링을 진행하였다. 이 과정에서 물론 prod 코드 또한 리팩토링이 일어났다.
허나, 내가 처음 커버리지 100%를 달성했던것과 비교하면 비교도 안되게 빠르게 작업을 진행할 수 있었다.
prod 코드를 설명하는 테스트 코드라는 문서가 100% 설명해주고 있으므로 코드 파악을 빠르게 진행할 수 있다.
또한 이전에는 테스트 코드의 성공과 실패 여부로만 정상 여부를 판단했다면, 이제는 커버리지 100%라는 수치를 통해서도 빠진 것 없이 모두 테스트 됬는지 판단할 수 있다.
3. 복잡한 구조를 단순하게
100%라는 기준은 하나도 빠트림 없이 모두 테스트 됬음을 보장한다.
이는 브랜치 커버리지에서도 마찬가지이기에 모든 분기 처리에 대해서 테스트 해야한다는 것을 의미한다.
분기 처리 테스트 코드를 작성하다보면 서로 다른 분기임에도 작성된 테스트 코드가 거의 같거나, 분기조건을 모두 만족할 수 없을 때가 있다.
이는 필요 이상의 분기 조건이 사용된 경우다.
예를 들어 2가지 조건으로도 충분히 검증을 할 수 있는데 3가지, 4가지 조건을 검증한다거나
한번 검증했던 것을 또 검증하고 있는 코드들이다.
과도하게 방어적으로 코드를 작성하다보면 불필요한 분기조건들로 인해 코드가 복잡해지고 파악하기 어려워질 수 있다.
커버리지 100%를 달성하며 모든 분기조건에 대해 다시 한번 들여다 보며 불필요한 분기조건을 제거할 수 있다.
'테스트' 카테고리의 다른 글
Flyway, TestContainer를 통한 독립된 테스트 DB 환경 구성 (0) | 2025.02.22 |
---|---|
테스트 더블을 직접 구현하여 테스트 환경 구축하기(feat. 동시성 제어) (0) | 2025.02.21 |
스프링 컨텍스트 캐싱을 위한 테스트 환경 구축 (0) | 2025.02.20 |
테스트 커버리지 100% 달성기[3] - 테스트 코드 가독성 개선 (0) | 2025.02.19 |
테스트 커버리지 100% 달성기[2] - 테스트 시간 단축 (0) | 2025.01.27 |