반복되는 테스트 코드 리펙토링 - @ParameterizedTest, @MethodSource

Sol's·2023년 1월 20일
0

스프링부트

목록 보기
4/5

불편함을 느끼다.

테스트 코드를 작성하던중 어느순간부터 ctrl+c, ctrl+v를 하고있는 모습이 보였습니다.

그 이유는 Controller Test, Service Test를 하는 과정에서 반복되는 로직이 많았고
Error의 값과 매개변수들만 조금 달라지는 패턴이 눈에 들어오게 되었습니다.

이렇게되면 가독성또한 좋지 않아서 반복되는 코드를 줄여보자는 생각을 해보았습니다.

💡 테스트 코드도 DI의 개념처럼 외부에서 매개변수를 주입해주면 어떨까?

위와같은 고민을 해결하기 위해 @ParameterizedTest어노테이션을 알게되었고
테스트 코드의 코드길이와 유지비용을 최소 80%이상 줄일 수 있게되어
CommentServiceTest Code
약 300줄 -> 150줄
리펙토링 전 ->

그 내용을 정리해보고자 블로그를 작성하게 되었습니다.

@ParameterizedTest란?

  1. 여려 argument를 이용해 테스트를 할 수 있습니다.
  2. @Test 대신 @ParameterizedTest를 붙이면 됩니다.
  3. @ParameterizedTest를 사용하면 최소 하나의 source 어노테이션을 붙여주어야 합니다.

Source 어노테이션

파라미터에 들어갈 source를 어떻게 넣어줄 것인가를 정의하는 어노테이션입니다.

@MethodSource 는 복잡한 객체들이 매개 변수일 때, 매개 변수를 지정하는 메서드 하나를 생성하여 활용하는 어노테이션.

  • @EnumSource : enum 타입 클래스의 상수 변수들을 매개 변수로 활용하고자 할 때 사용되는 어노테이션.
  • @NullSource, @EmptySource : 각각 null 값, 빈 값 (문자열, Collection, 배열만 해당) 을 활용하고자 할 때 사용.
  • @CsvSource : 쉼표로 분리된 값을 써서 여러 매개 변수를 지정하는 어노테이션.
  • @ValueSource : 하나의 매개 변수를 지정할 때 사용하는 어노테이션.

사용한 어노테이션은 @MethodSource입니다.
왜냐하면 다양한 매개변수를 넣어줘야 하기 때문입니다.

Test코드 리펙토링

리펙토링 전

아래 코드는 리펙토링하기 전 Comment Service의 실패 테스트입니다.

코드를 확인해보면 로직들이 비슷하고 매개변수들만 달라진것을 확인 할 수 있습니다.

@Test
    @DisplayName("댓글 수정 실패 - Comment 없음")
    void comment_modify_fail_Comment없음() {
        //given
        given(userRepository.findByUserName(any())).willReturn(Optional.of(UserEntityFixture.get(userName, userName)));
        given(postRepository.findById(any())).willReturn(Optional.of(PostEntityFixture.get(userName, password)));

        //Comment를 찾지 못함
        given(commentRepository.findById(any())).willReturn(Optional.empty());

        //when
        AppException e = assertThrows(AppException.class, () -> commentService.modify(commentRequest, fixture.getPostId(), fixture.getCommentId(), userName));

        //then
        assertEquals(e.getErrorCode(), ErrorCode.COMMENT_NOT_FOUND);
        assertEquals(e.getMessage(), ErrorCode.COMMENT_NOT_FOUND.getMessage());
    }
 @Test
    @DisplayName("댓글 수정 실패 - Post 없음")
    void comment_modify_fail_Post없음() {
        //given
        given(userRepository.findByUserName(any())).willReturn(Optional.ofNullable(UserEntityFixture.get(userName, userName)));
        given(commentRepository.findById(any())).willReturn(Optional.of(CommentEntityFixture.get(userName,password)));

        //Post가 없음
        given(postRepository.findById(any())).willThrow(
                new AppException(ErrorCode.POST_NOT_FOUND, ErrorCode.POST_NOT_FOUND.getMessage())
        );

        //when
        AppException e = assertThrows(AppException.class, () -> commentService.modify(commentRequest, fixture.getPostId(), fixture.getCommentId(), userName));

        //then
        assertEquals(e.getErrorCode(), ErrorCode.POST_NOT_FOUND);
        assertEquals(e.getMessage(), ErrorCode.POST_NOT_FOUND.getMessage());
    }

리펙토링 후

아래 코드로 리펙토링한 결과 fail_edit_comment메소드 안에서 댓글 수정 실패에대한 Test를 3개나 진행 할 수 있었습니다.
추후에 댓글 삭제에 대한 Test도 provideErrorCases()를 사용하여 유지보수 비용도 줄이고 가독성도 높일 수 있었습니다.

public static Stream<Arguments> provideErrorCases() {
        User userA = UserEntityFixture.get("user a", "1");
        Post postA = PostEntityFixture.get("user a", "1");
        Comment commentA = CommentEntityFixture.get("user a", "댓글 내용 1");
        Comment differentUserNameComment = CommentEntityFixture.get("different", "댓글 내용 1");

        return Stream.of(
                Arguments.of(Named.of("댓글 존재하지 않음", ErrorCode.COMMENT_NOT_FOUND), Optional.of(postA), Optional.of(userA), Optional.empty(),  userA.getUserName()),
                Arguments.of(Named.of("게시글 존재하지 않음", ErrorCode.POST_NOT_FOUND), Optional.empty(), Optional.of(userA), Optional.of(commentA),  userA.getUserName()),
                Arguments.of(Named.of("작성자 불일치", ErrorCode.ID_NOT_MATCH), Optional.of(postA), Optional.of(userA), Optional.of(differentUserNameComment),  userA.getUserName())
        );
    }

provideErrorCases()메소드에서는 Stream<Arguments>Stream 형태로 Arguments를 묶어서 반환합니다.
리펙토링 전의 로직에서 중복코드들을 제외한 Arguments들을 정의해서 Test에서 사용할 수 있게 해주었습니다.

Named interface의 정의입니다
이름을 정하고, payload와 연관시켜줄 수 있습니다.

payload = 전송되는 데이터

	@ParameterizedTest
    @MethodSource("provideErrorCases")
    @DisplayName("실패")
    void fail_edit_comment(ErrorCode errorCode, Optional<Post> post, Optional<User> user, Optional<Comment> comment,  String userName) {

        //given
        given(userRepository.findByUserName(any())).willReturn(user);
        given(postRepository.findById(any())).willReturn(post);
        given(commentRepository.findById(any())).willReturn(comment);

        //when
        AppException e = assertThrows(AppException.class, () -> commentService.modify(commentRequest, fixture.getPostId(), fixture.getCommentId(), userName));

        //then
        assertEquals(e.getErrorCode(), errorCode);
        assertEquals(e.getMessage(), errorCode.getMessage());
    }

실제 테스트를 실행하는 fail_edit_comment 메소드입니다.
@ParameterizedTest어노테이션을 사용해 매개변수를 받는다고 알려주었고
@MethodSource("provideErrorCases")어노테이션을 통해 주입받을 메개변수의 메소드를 적어주었습니다.

이제 provideErrorCases의 Arguments들을 받아와서 테스트를 진행 할 수 있습니다.

정리

첫 시작은 아래와 같은 물음에서 시작하였습니다.

어떻게 하면 반복되는 작업을 하지 않을까?

문제를 해결하면서 시간비용이 많이들었지만, 이후에 리펙토링을 하는 과정에서 너무나도 편하게 리펙토링을 할 수 있었고 테스트코드의 가독성 또한 좋아졌습니다.

또한 앞으로도 테스트코드를 작성할때 생각없이 반복되는 로직들을 복사붙여넣기하지 않고 작성할 수 있어 미래에 들어갈 시간비용도 줄일 수 있었습니다.

추가적으로 이제 개발자다운 생각을 하고 있나? 라는 생각도 하게되어 나름 기분이 좋아졌습니다.
앞으로도 반복되는 작업을 어떻게 줄일지, 리펙토링 하기 좋은 코드는 무엇인지, 가독성이 좋아지려면 어떻게 코드를 작성해야 할지 고민하면서 코드를 작성해야겠다고 생각했습니다.

참고자료

https://lannstark.tistory.com/52
https://gmlwjd9405.github.io/2019/11/27/junit5-guide-parameterized-test.html

profile
배우고, 생각하고, 행동해라

0개의 댓글