테스트 커버리지는 개발자의 수치다

zayn_noh·2026년 7월 2일

Behind the Dev

목록 보기
1/1
post-thumbnail

이번 포스트에서는 테스트 코드에 대해 배우고 이를 적용하는 과정에서 겪은 경험을 바탕으로

테스트 코드(Test Code)테스트 커버리지(Test Coverage)에 대해 다루며

최종적으론 좋은 테스트 코드를 위한 최적의 테스트 커버리지에 대해 개인적인 생각을 정리하여 보았다.


1. 목표 설정 단계 : 테스트 코드 작성 전

테스트 코드를 적용하기에 앞서, 이번 과정에서는 단순히 테스트 코드를 작성한다기 보다
테스트 코드가 가지는 본질적인 의미를 함께 고민해보고 싶었다.

사실 아직 테스트 코드에 대해 잘 알고 있다고 말하기 어렵고,
다른 사람들에 비해 많은 시간을 들여 테스트 코드를 작성해본 경험도 부족할 것이다.

그럼에도 지금까지 학습하며 느낀 테스트 코드의 가장 큰 장점은 TDD의 흐름 속에서 잘 드러난다고 생각했다.

TDD에서는 Red-Green-Refactoring이라는 사이클을 반복하며 코드를 구현해나간다.
먼저 실패하는 테스트를 작성하고, 그 테스트를 통과하는 최소한의 코드를 구현한 뒤, 다시 코드를 개선한다.

이 과정에서 테스트 코드는 단순히 코드가 정상적으로 동작하는지 확인하는 도구를 넘어,
구현해야 할 기능을 설명하는 명세처럼 동작한다.

간단한 예제를 통해서도 이 점은 꽤 쉽게 체감할 수 있었다.

테스트 코드가 왜 중요한지, 왜 살아있는 문서라고 불리는지,
그리고 왜 테스트 코드의 명세가 개발을 이끌어가는 TDD라는 방식이 등장했는지 조금은 이해할 수 있었다.

물론 이번 테스트 코드 적용 과정은 엄밀한 의미의 TDD라고 보기는 어렵다.
이미 프로덕트 코드가 작성되어 있는 상태에서 테스트 코드를 추가하는 작업이었기 때문이다.

그럼에도 테스트 코드가 단순히 기존 코드를 따라가는 방식이 되지 않도록 하고 싶었다.
가능하다면 테스트 코드가 요구사항을 먼저 표현하고,
그 테스트를 통해 프로덕트 코드를 점검하는 흐름을 흉내 내보고자 했다.

이를 위해 스스로 다음과 같은 테스트 코드 작성 규칙을 정했다.

  • 테스트 코드 작성 시에는 프로덕트 코드를 별도로 살펴보지 않는다.
  • 테스트 코드 작성 시에는 기획 요구사항과 API 시트를 기반으로 작성한다.

즉, 프로덕트 코드의 구현을 기준으로 테스트를 작성하기보다는,
요구사항을 기준으로 테스트할 명세를 먼저 뽑아내고자 했다.

이번 테스트 코드 적용의 개인적인 목표는 바로 이 명세를 테스트 코드로 표현하고,
이를 통해 기존 프로덕트 코드가 요구사항을 제대로 만족하고 있는지 확인하는 것이었다.


2. 테스트 커버리지 설정 : 테스트 코드 작성 전

테스트 코드 작성에 앞서 가장 먼저 고민한 것은 테스트 커버리지 목표였다.

처음에는 테스트 커버리지의 목표를 정한다는 말이 잘 와닿지 않았다.
단순히 생각하면 모든 프로덕트 코드를 테스트할 수 있다면 당연히 좋은 것 아닌가 싶었다.

테스트하지 않은 코드가 적을수록 더 안전한 코드처럼 느껴졌기 때문이다.

하지만 커버리지 목표를 정하기 전에 테스트 전략과 관련된 글들을 살펴보며,
단순히 높은 커버리지만을 목표로 삼는 것이 좋은 테스트 전략은 아닐 수 있다는 생각을 하게 되었다.

특히 토스 기술 블로그의 가치있는 테스트를 위한 전략과 구현 글에서 이 부분에 대한 힌트를 얻을 수 있었다.

해당 글의 핵심은 모든 코드를 무조건 테스트하는 것이 아니라, 비즈니스 가치유지보수 비용을 함께 고려하여
가치 있는 테스트를 선택적으로 작성해야 한다는 것이었다.

좋은 테스트 전략은 이상적인 원칙이나 숫자만으로 정해지는 것이 아니라,
시스템의 특성과 팀의 상황에 맞게 비용과 가치를 판단하는 데서 출발한다는 내용이었다.

물론 글을 읽었다고 해서 곧바로 체감이 되지는 않았다.

“과한 테스트 코드는 유지보수 비용을 높일 수 있으니 의미 있는 테스트를 작성해야 한다”

말은 이해할 수 있었지만 당장의 나는 어떤 테스트가 의미 있는 테스트인지
어떤 테스트가 비즈니스 가치를 가진 테스트인지 명확하게 구분하기 어려웠다.

그래서 조금은 급작스럽지만 직접 부딪혀보기로 했다.
우선 User 도메인의 테스트 커버리지 목표를 100%로 설정하였다.

이는 테스트 커버리지 100%가 항상 정답이라고 생각했기 때문은 아니었다.
오히려 높은 커버리지를 목표로 테스트를 작성해보면, 어떤 테스트는 실제로 의미가 있고
어떤 테스트는 단순히 숫자를 채우기 위한 테스트처럼 느껴지는지 직접 경험할 수 있을 것이라 생각했다.

추가로는 테스트 코드를 적용할 커뮤니티 시스템에서 User 도메인은 회원가입, 로그인,
프로필 수정, 비밀번호 변경, 권한, 탈퇴 등 서비스의 핵심 흐름과 밀접하게 연결되어 있다고 생각했다.

따라서 User 도메인은 그 자체로 테스트할 가치가 큰 대상이라고 판단했고,
이 도메인에 대해서는 가능한 한 빠짐없이 테스트해보는 것이 의미 있다고 생각했다.

결과적으로 테스트 코드 작성 전 내가 세운 목표는 다음과 같았다.

  • User 도메인과 관련된 주요 기능을 테스트 대상으로 삼는다.
  • 가능한 한 테스트 커버리지 100%를 목표로 한다.
  • 단순히 프로덕트 코드를 실행시키는 테스트가 아니라, 요구사항을 검증하는 테스트를 작성한다.

이때까지만 해도 테스트 커버리지 100%라는 수치가 꽤 명확한 목표처럼 느껴졌다.

또한 테스트 커버리지의 의미를 확실히 알고 내가 세운 목표에 적용했다 판단하였다.

하지만 실제로 테스트 코드를 작성하는 과정에서 조금씩 이상함을 느끼기 시작했다.
처음에는 테스트 커버리지 100%를 목표로 잡았지만,
내가 실제로 하려던 일은 프로덕트 코드의 모든 구문을 실행시키는 것이 아니었다.
기획 요구사항과 API 시트를 바탕으로 User 도메인의 명세를 먼저 정리하고,
그 명세가 테스트 코드로 제대로 표현되는지를 확인하는 것이었다.

즉, 내가 목표로 삼은 것은 구문 커버리지 100%에 가까운 테스트라기보다,
User 도메인에 대한 명세 커버리지 100%에 가까웠다.

이 차이를 명확히 구분하지 못한 상태에서 테스트 커버리지라는 수치를 목표로 세우다 보니,
테스트 코드를 작성하는 과정에서 점차 어색함을 느끼게 되었다.


3. 테스트 코드를 통해 얻은 버그 발견 경험

테스트 코드를 작성하기에 앞서 기획 문서와 전체 API를 살펴보며,
User 도메인에 어떤 명세가 존재하는지 먼저 추출하는 과정을 거쳤다.

이후 추출한 명세를 바탕으로 각 명세마다 하나씩 테스트 코드를 작성하였다.

이 과정에서 테스트 코드의 가장 기본적인 장점 중 하나인,
요구사항과 실제 구현 사이의 어긋남을 발견하는 경험을 할 수 있었다.

@Test
void 회원가입_시_사용자_이미지를_입력하지_않아도_된다() {
    JoinRequestDTO request = joinRequest(
            "test@test.com",
            "tester",
            "Password1!",
            "Password1!",
            null
    );

    assertThat(validator.validate(request)).noneMatch(v -> v.getPropertyPath().toString().equals("userImage"));
}

위 테스트는 다음의 명세를 바탕으로 작성한 테스트 코드이다.
“회원가입을 요청하는 사용자는 이미지를 입력하지 않아도 회원가입할 수 있어야 한다”

하지만 테스트 실행 결과, 다음과 같은 에러가 발생하였다.

Expecting no elements of:
  [ConstraintViolationImpl{interpolatedMessage='user_image_required', propertyPath=userImage, rootBeanClass=class com.example.community.dto.JoinRequestDTO, messageTemplate='user_image_required'}]
to match given predicate but this element did:
  ConstraintViolationImpl{interpolatedMessage='user_image_required', propertyPath=userImage, rootBeanClass=class com.example.community.dto.JoinRequestDTO, messageTemplate='user_image_required'}
java.lang.AssertionError: 

...

에러 내용을 통해 JoinRequestDTO 내부에서 사용자 이미지와 관련된 검증이 잘못 동작하고 있음을 확인할 수 있었다.
실제로 DTO를 확인해보니, 사용자 이미지 필드에 @NotBlank 어노테이션이 붙어 있었다.

@JsonProperty("user_image")
@NotBlank(message = "user_image_required")
private String userImage;

하지만 기획 요구사항에 따르면 사용자 이미지는 필수 입력값이 아니었다.
사용자가 이미지를 입력하지 않은 경우에는 기본 이미지가 설정되어야 했다.

따라서 해당 검증은 요구사항과 맞지 않는 검증이었고, @NotBlank 어노테이션을 제거하였다.

여기서 한 가지 의문이 더 생겼다.
이미지 값이 null로 들어왔을 때, 실제로 기본 이미지 경로가 저장되고 있을까?
이를 확인하기 위해 다음 테스트 코드를 추가로 작성하였다.

@Test
void 사용자_이미지를_입력하지_않고_회원가입하면_기본_이미지가_저장된다() {
    JoinRequestDTO request = joinRequest("test@test.com", "tester", "Password1!", "Password1!", null);

    when(passwordEncoder.encode("Password1!")).thenReturn("hashed-password");
    when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));

    userService.joinProcess(request);

    ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
    verify(userRepository).save(captor.capture());

    assertThat(captor.getValue().getImage()).isNotBlank();
}

이번 테스트 역시 바로 통과하지 못했다.
사용자 기본 이미지에 대한 프로덕트 코드가 제대로 작성되어 있지 않았던 것이다.

테스트 실패 원인을 확인하기 위해 joinProcess 이후 생성되는 User 엔티티를 살펴보았다.

당시 User 생성자는 다음과 같이 작성되어 있었다.

public User(String email, String password, String nickname, String image) {
    this.email = email;
    this.password = password;
    this.nickname = nickname;
    this.image = image;
    this.deletedAt = null;
    this.role = UserRole.USER.getRole();
}

생성자에서는 전달받은 image 값을 그대로 엔티티의 상태로 할당하고 있었다.

따라서 이미지 값이 null로 전달되면 기본 이미지 경로가 설정되지 않고,
그대로 null이 저장되는 문제가 발생했다.

이를 해결하기 위해 생성자 내부에 이미지 값이 없는 경우 기본 이미지 경로를 설정하는 분기를 추가하였다.

public User(String email, String password, String nickname, String image) {
    this.email = email;
    this.password = password;
    this.nickname = nickname;
    this.deletedAt = null;
    this.role = UserRole.USER.getRole();

    if (image != null) {
        this.image = image;
        return;
    }
    this.image = "/images/default-profile.png";
}

수정 후 테스트를 다시 실행하였고,
사용자가 이미지를 입력하지 않아도 기본 이미지 경로가 정상적으로 저장되는 것을 확인할 수 있었다.

이 경험을 통해 테스트 코드는 단순히 코드가 실행되는지 확인하는 도구가 아니라,
요구사항과 실제 구현이 일치하는지를 드러내는 장치라는 점을 체감할 수 있었다.

특히 프로덕트 코드를 기준으로 테스트를 작성한 것이 아니라, 요구사항에서 추출한
명세를 기준으로 테스트를 작성했기 때문에 기존 구현에서 놓치고 있던 문제를 발견할 수 있었다.


4. 테스트 커버리지 올리기

전체 테스트 코드를 작성한 뒤, 다시 한 번 테스트 커버리지에 대해 생각해보게 되었다.

앞서 기획 요구사항과 API 시트를 바탕으로 User 도메인의 명세를 추출했고,
각 명세에 대응하는 테스트 코드를 작성했다. 이후 작성한 테스트들도 모두 통과하였다.

그렇다면 이 상태를 두고 테스트 커버리지 100%를 달성했다고 말할 수 있을까?
내가 작성한 테스트는 User 도메인의 명세를 기준으로 했다.

따라서 내가 달성했다고 볼 수 있는 것은,
적어도 내가 추출한 명세에 대한 테스트 커버리지 100%에 가까웠다.

하지만 처음 목표로 설정했던 테스트 커버리지 100%는 일반적으로 구문 커버리지를 의미하는 경우가 많다.

구문 커버리지는 테스트 실행 과정에서 프로덕트 코드의 구문이 얼마나 실행되었는지를 보여주는 지표이다.
즉, 내가 작성한 명세 기반 테스트가 요구사항을 얼마나 검증했는지와는 다른 관점의 수치였다.

결국 일반적인 의미의 테스트 커버리지 100%와,
내가 목표로 했던 명세 커버리지 100%는 서로 다른 의미를 가지고 있었다.

따라서 내가 작성한 테스트 코드들이 실제 프로덕트 코드의 어느 부분을 실행하고 있는지 확인해보고 싶었다.
이를 위해 테스트를 실행한 뒤 커버리지를 측정했고,
User 도메인의 핵심 로직과 관련된 UserServiceAuthService를 중심으로 살펴보았다.

처음 측정한 결과는 다음과 같았다.

  • AuthService: 메서드 커버리지 50%, 구문 커버리지 48%
  • UserService: 메서드 커버리지 90%, 구문 커버리지 87%

명세를 기준으로 테스트를 작성했다고 생각했지만,
실제 구문 커버리지에는 아직 테스트를 거치지 않은 코드가 남아 있었다.

어떤 부분이 테스트되지 않았는지 확인해보니,

  • AuthService에서는 사용자 로그아웃 관련 로직이,
  • UserService에서는 사용자 회원 탈퇴 관련 로직이 테스트되지 않고 있었다.

이후 해당 기능들에 대한 명세를 다시 정리하고, 각각의 테스트 코드를 추가로 작성하였다.

회원 탈퇴의 경우 존재하지 않는 사용자는 탈퇴할 수 없다, 이미 탈퇴한 사용자는 다시 탈퇴할 수 없다,
탈퇴 성공 시 리프레시 토큰을 삭제하고 사용자를 탈퇴 처리를 진행한다를 테스트하였다.

로그아웃의 경우 Authorization 헤더가 잘못된 경우,
리프레시 토큰이 존재하지 않는 경우, 정상적으로 로그아웃되는 경우를 테스트하였다.

그 결과 UserService는 메서드 커버리지와 구문 커버리지 모두 100%를 달성할 수 있었다.
AuthService는 User 도메인과 관련된 로그아웃 테스트를 추가한 뒤 메서드 커버리지 75%, 구문 커버리지 64%까지 올라갔다.


5. 의문점

앞서 테스트 커버리지를 확인하고, 커버리지를 높이기 위해 누락된 테스트 코드를 추가하는 과정을 거쳤다.

이 과정에서 분명 얻어가는 장점도 있었다.
커버리지를 확인하지 않았다면 놓쳤을 수도 있는 회원 탈퇴나 로그아웃 같은 기능을 다시 발견할 수 있었고,
해당 기능들에 대한 테스트도 추가할 수 있었다.

이러한 점에서 테스트 커버리지는 테스트되지 않은 코드가 어디에 남아 있는지 알려주는 유용한 지표였다.

하지만 동시에 한 가지 의문도 들었다.

프로덕트 코드를 확인한 뒤, 커버리지를 높이기 위해 작성한 테스트 코드는
정말 가치 있는 테스트라고 말할 수 있을까?

본래 처음 목표로 삼았던 방식은 기획 요구사항과 API 시트를 바탕으로 명세를 먼저 추출하고,
그 명세를 테스트 코드로 표현하는 것이었다.

반면 커버리지를 높이기 위해 작성한 테스트는 프로덕트 코드에서 실행되지 않은 부분을 먼저 확인한 뒤,
그 코드를 지나가게 만들기 위해 작성한 테스트에 가까웠다.

물론 이런 테스트가 완전히 의미 없다고 생각하지는 않는다.
실제로 누락된 기능을 발견했고, 특정 분기나 예외 상황이 의도대로 동작하는지도 확인할 수 있었다.

그러나 테스트의 출발점이 요구사항이 아니라 프로덕트 코드가 되는 순간, 테스트가 명세를 검증한다기보다
기존 구현을 따라가는 형태가 될 수 있다는 생각이 들었다.

이 경우 테스트 커버리지 수치는 올라가지만, 그 수치가 곧 테스트의 가치를 보장한다고 말하기는 어렵다.

테스트가 정말 확인해야 하는 것은 코드가 실행되었는지가 아니라,
그 코드가 요구사항과 비즈니스 규칙을 올바르게 만족하고 있는 것인지가 중요하다 배웠기 때문이다.


6. 결론 : 테스트 커버리지는 수치다

이번 테스트 코드 적용 일련의 과정을 통해
테스트 코드는 명세이자 살아있는 문서처럼 동작할 수 있다는 개념을 어느정도 몸으로 느낄 수 있었다.

조금 갑작스럽지만,
앞선 과정을 통해 테스트 코드와 테스트 커버리지에 대해 느낀 개인 견해를 작성하며 마무리하고자 한다.

요구사항을 테스트 코드로 표현하면 단순히 오류를 찾는 것을 넘어,
이후 리팩토링 과정에서도 기존 기능이 깨지지 않았는지 확인하는 안전망이 될 수 있다.
이렇 듯 좋은 테스트 코드를 작성하기 위해 느낀 점 중 하나로는,
테스트 코드가 프로덕트 코드가 아닌 명세를 바라봐야 한다는 점이다.

기존 구현을 기준으로 테스트를 작성하면 단순히 현재 코드를 따라가는 테스트가 되기 쉽지만,
요구사항을 기준으로 작성하면 구현이 명세와 어긋나는 부분이 생겨나게 될 것이다.


테스트 커버리지는 결국 이러한 테스트 코드가
실제 프로덕트 코드에 어느 정도 닿아 있는지를 보여주는 수치라고 생각한다.

커버리지는 테스트의 가치를 직접 증명하지는 않지만,
테스트되지 않은 코드가 어디에 남아 있는지 알려주는 유용한 지표가 될 수 있다.

결국 중요한 것은 커버리지 숫자 자체가 아니라, 그 숫자를 채우고 있는 테스트 코드의 내용이다.
좋은 테스트 코드가 작성되어 있을 때 테스트 커버리지는 의미 있는 수치가 되는 것 같다.

따라서 테스트 커버리지는 목표라기보다 결과에 가까워야 한다는 생각이다.
중요한 명세를 테스트로 검증하고,
그 결과로 따라오는 수치일 때 비로소 테스트 커버리지는 의미를 가진다고 느꼈다.

profile
개발 및 IT기술에 대해 정리하고 기록합니다.

0개의 댓글