개발자는 기록이 답이다

트러블 슈팅 - private 메소드를 테스트하려 했지만, 문제는 테스트 코드 로직이었다. 본문

Spring/트러블 슈팅

트러블 슈팅 - private 메소드를 테스트하려 했지만, 문제는 테스트 코드 로직이었다.

slow-walker 2024. 3. 13. 11:01

 

프로젝트를 진행하면서 단위테스트에 대한 트러블 슈팅 과정을 적어보겠습니다.

테스트 코드 작성을 제대로 작성하면 간단하게 풀리는 내용이었지만, 그 과정에서 고민했던 내용들을 복기하면서 실수를 방지하고자 합니다.

1. private 메소드를 테스트하려고 하는 이유

 

해당 메소드들은 상위 메소드에서 호출되는 메소드들입니다. 그래서 findEvent()메소드, findCoupon()메소드를 '단위 테스트'목적으로 아래처럼 작성했었어요.

 

우선 맥락을 이해하기위해 설명하자면, 기존에는 상위메소드에서 @Transactional이 걸려있으면, 하위메소드에서 해당 트랜잭션에 참여하기 위해 @Transactional을 걸어줘야 한다고 생각했습니다. 왜냐하면 @Transactional을 사용하면Propagation.REQUIRED이 기본값이기 때문입니다. 

 

하지만 이미 상위 메소드에서 트랜잭션이 걸려있으면, 물리 트랜잭션에 의해 하위 메소드들도 같은 트랜잭션에 포함되어있다는 피드백을 얻었습니다. 이것과 관련된 내용은 이후에 포스팅할 내용에서 참고해주시길 바랍니다.

 

그래서 해당 메소드들의 접근제어자를 private으로 변경하고, @Transactional(readOnly=true)를 지워줬습니다.

 

하지만 private으로 접근제어자를 변경하면, 아래 컴파일 에러 메세지처럼 기존에 있던 테스트에서 사용할 수가 없었어요.

'findEvent(long)' has private access in 'com.flab.offcoupon.service.couponIssue.sync.DefaultCouponIssueService'

 

이와 관련해서 private 메소드를 테스트하기 위해 어떻게 해야하는지 알아보고자 합니다.

 

 

2. Java Reflection API를 이용한 메소드 호출

 

가장 먼저 시도할 방법은 Java에서 제공해주는 리플렉션 API를 이용하는 방법입니다.

리플렉션을 이용하면 정적으로 고정된 메소드의 코드도 메타정보로 추상화된 Method 타입을 얻어낼 수 있고, 해당 타입을 통해 테스트하고자 하는 Method를 추출해서 invoke해줄 수 있습니다. 

 

하지만 테스트를 해보니 메소드 명이 일치함에도 불구하고 NoSuchMethodException이 발생했습니다. 

$$SpringCGLIB$$0.findEvent(long)라는 메소드를 찾을 수 없다는 내용이었어요.

 

별도로 테스트를 진행해보니 상위메소드에 @Transactional 어노테이션이 붙어있으면 트랜잭션 관리를 위해 프록시를 생성하기 때문에, 리플렉션을 토한 메소드 호출이 예상대로 동작하지 않았습니다.

@Component
public class Test {

    @Transactional
    public String test() {
        return privateMethod();
    }
    private String privateMethod() {
        return "This is a private method";
    }

    public String publicMethod() {
        return "This is a public method";
    }

    protected String protectedMethod() {
        return "This is a protected method";
    }
}

 

@SpringBootTest
class TestReflectionTest {

    @Autowired
    private Test test;

    @org.junit.jupiter.api.Test
    @DisplayName("private 메소드 호출 with transactional 리플랙션 테스트")
    void invoke_private_test() throws Exception {
        // given
        Method testMethod = test.getClass().getDeclaredMethod("test");
        // when
        testMethod.setAccessible(true);

        String invoke = (String)testMethod.invoke(test);
        // then
        assertEquals("This is a private method", invoke);
    }

    @org.junit.jupiter.api.Test
    @DisplayName("private 리플랙션 테스트")
    void private_test() throws Exception {
        // given
        Method testMethod = test.getClass().getDeclaredMethod("privateMethod");
        // when
        testMethod.setAccessible(true);

        String invoke = (String)testMethod.invoke(test);
        // then
        assertEquals("This is a private method", invoke);
    }

    @org.junit.jupiter.api.Test
    @DisplayName("public 리플랙션 테스트")
    void public_test() throws Exception {
        // given
        Method testMethod = test.getClass().getDeclaredMethod("publicMethod");
        // when
        testMethod.setAccessible(true);

        String invoke = (String)testMethod.invoke(test);
        // then
        assertEquals("This is a public method", invoke);
    }

    @org.junit.jupiter.api.Test
    @DisplayName("protected 리플랙션 테스트")
    void protected_test() throws Exception {
        // given
        Method testMethod = test.getClass().getDeclaredMethod("protectedMethod");
        // when
        testMethod.setAccessible(true);

        String invoke = (String)testMethod.invoke(test);
        // then
        assertEquals("This is a protected method", invoke);
    }
}

 

 

private 메소드를 리플렉션을 처리할 수 있을까 고민해봤는데, 어차피 리플랙션을 통해서 private메소드를 테스트 코드를 만드는 것은 지양해야 한다고 합니다. 왜냐하면 private메소드라는 것 자체가 내부를 감추어서 클라이언트와 결합도를 낮추는 것인데, 클라이언트인 테스트 클래스가 내부 메소드를 알고 있으니 결합도가 높아져서 캡슐화가 깨져버립니다.

 

이러한 이유때문에 유지보수할 때 테스트에 대한 비용을 증가시키게 되는데, 메소드 이름이나 파라미터가 변경될 때 다시 테스트 코드도 변경해줘야 하기 때문입니다. 또한 리플레션 자체가 컴파일 에러로 잡지못하기 때문에 최대한 사용을 자제해야 합니다.

 

2. 테스트 코드 로직 변경

 

그렇다면 테스트 코드 로직을 변경해보도록 하겠습니다.

다시 리플렉션을 사용하지 않는 코드로 되돌리면 여전히 private 접근제어자 때문에 사용할 수 없다는 컴파일 에러가 뜨는데요.

 

해당 코드를 보면서 왜 findEvent메소드를 를 호출하고자 했는지 다시 생각했습니다. 

애초에 처음부터 해당 서비스단에 있는 private 메소드를 호출하는게 아니라 repository에 있는 메소드를 호출하면 될텐데 말이죠.

 

아마 제가 잘못 생각했던 부분이 findEvent 메소드를 테스트 해야 단위 테스트다! 라고 생각해서 적었던 것 같은데,

테스트하고자 하는 메소드가 뭔지 다시 한번 리마인드 시켜보니 검증하고자 했던 메소드는 AsynCouponIssueService의 issueCoupon메소드였습니다. findEvent는 DefaultCouponIssueService의 내부 클래스였고 재사용을 위해 사용했던 내용입니다.

 

그러면 테스트의 메소드명도 findEvent_fail_invalid 이 아니라 issueCoupon_fail_with_invalid_period로 바꿔줘야합니다.

여기서 유지보수에서 메소드명이 중요한 이유를 다시 한번 깨닫게 됩니다.

 

그러면 다시 DefaultCouponIssueService 클래스에서 findEvent메소드를 테스트할지에 대해 생각해보겠습니다.

 

DefaultCouponIssueService클래스에서 findEvent 메소드를 단위테스트 목적으로 작성했는데, 여기서는 어떻게 하면 좋을까요?

여전히 여기서도 private 접근제어자 때문에 컴파일 에러가 발생합니다. 

이럴땐 상위 메소드에 있는 issueCoupon메소드로 테스트하면 좋을 것 같습니다. private메소드가 아닌 상위메소드로 변경하겠습니다.

 

그리고 Jacoco 레포트를 통해 코드베이스의 테스트 커버리지를 시각적으로 확인해보겠습니다.

  • 초록색 (Green)
    • 초록색은 코드베이스에서 실행된 테스트를 나타냅니다. 이는 테스트가 해당 코드를 충분히 커버했음을 나타냅니다.
    • 보통 "테스트가 통과한 코드"를 의미합니다.
  • 빨간색 (Red)
    • 빨간색은 코드베이스에서 실행되지 않은 테스트를 나타냅니다. 이는 해당 코드가 테스트되지 않았거나 충분히 테스트되지 않았음을 나타냅니다.
    • 보통 "테스트가 실패한 코드"를 의미합니다.
  • 노란색 (Yellow/Orange)
    • 노란색은 부분적인 코드 커버리지를 나타냅니다. 이는 일부 코드가 실행되었지만 전체 코드베이스에 대해 충분한 커버리지를 제공하지 않음을 의미합니다.
    •  보통 "테스트되지 않은 코드"를 의미합니다.

 

커버리지를 살펴보니 Event 캐시를 이용해서 데이터를 가져오기 때문에 findEvent메소드를 사용하지 않는걸 알게 되었습니다. 그래서 제거해주기로 했습니다. findCoupone 메소드의 경우에는 부분적으로만 테스트되어있기때문에 테스트 코드를 더 채워주면 될 것 같습니다.

 

간단하게 확인하기 위해 성공하는 테스트 케이스를 작성하겠습니다.

    @Test
    @DisplayName("[SUCCESS] 쿠폰 발급 - 쿠폰 발급 성공")
    void issueCoupon_success() {
        // given
        LocalDateTime currentDateTime = LocalDateTime.of(2024, 02, 27, 13, 0, 0);
        long eventId = 1L;
        long couponId = 1L;
        long memberId = 1L;
        // when
        ResponseDTO responseDTO = defaultCouponIssueService.issueCoupon(currentDateTime, eventId, couponId, memberId);
        assertThat(responseDTO.getData()).isEqualTo("쿠폰이 발급 완료되었습니다. memberId : %s, couponId : %s".formatted(memberId, couponId));
    }

 

성공 테스트 코드 하나 추가해줬더니 나머지 private 메소드도 커버리지가 채워지는 것을 확인할 수 있습니다.

결론

private 메소드인 findEvent와 findCoupon을 테스트하려 했는데, 상위메소드를 이용해서 충분한 테스트 케이스를 가지고 테스트 코드를 작성한다면 코드 커버리지가 다 채워진다는 것입니다!

 

  • 테스트 코드 만들때 메소드명이 중요하다! -> 유지 보수 시 헷갈릴 수 있기 때문에 휴먼 에러 발생
  • 상위 메소드를 이용해서 테스트 케이스를 작성하면 하위 private메소드의 코드 커버리지가 다 채워진다!