Spring boot 테스트 코드 작성-Test Double 사용에 따른 컨트롤 할 수 없는 코드 최소화

Mugeon Kim·2023년 8월 15일
0

서론


https://www.yes24.com/Product/Goods/75189146?OzSrank=2

  • 자바와 Junit을 활용한 실용주의 단위 테스트를 읽고 프로젝트에서 테스트 코드를 도입하여 코드의 안전성을 검증을 하였습니다.
  • Service Layer 단위 테스트를 진행하면서 Test Double의 적용의 여부 이에 따른 문제점을 만나게 되었습니다.
  • 제가 테스트 코드를 작성하며 고민한 부분과 만났던 문제점을 해결하기 위해서 리펙토링을 기록하기 위하여 작성을 하였습니다.

본론


1. Test Double이란

  • 테스트 더블은 영화를 촬영할 때 배우를 대신하여 위험한 역할을 하는 스턴트 더블(Stunt Double)이라는 용어에서 유래된 단어이다.

martinfowler
https://martinfowler.com/articles/mocksArentStubs.html

1-1. dummy

  • 아무 것도 하지 않는 깡통 객체

1-2. fake

  • 단순한 형태로 동일한 기능은 수행하나, 프로덕션에서 쓰기에는 부족한 객체 (Ex. FakeRepository)

1-3. stub

  • 테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체
  • 그 외에는 응답하지 않는다.

1-4. spy

  • stub이면서 호출된 내용을 기록하여 보여줄 수 있는 객체
  • 일부는 실제 객체처럼 동작시키고 일부만 Stubbing할 수 있다.

1-5. mock

  • 행위에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체

Stub / Mock

  • Stub / Mock에 대하여 비슷한 부분이 있지만 완전히 같은거는 아닙니다.
  • 둘다 가짜객체이지만 미리 준비한 결과를 제공은 비슷하지만 가장 큰 차이점은 다음과 같습니다.
  • Stub : 상태 검증
    • 미리 제공한 결과에 대한 받을 수 있고 이후 변화된 상태를 검증이 가능하다.
  • Mock : 행위 검증
    • 메소드를 하였을 때 Return의 값을 검증

1-5. Test Double의 장점

  • 컨트롤 할 수 없는 코드 영역에서 실세 서비스를 의존하여 테스트를 진행을 하게 된다면 일관성 있는 테스트가 불가능 하다.
  • 외부 의존송에 의하여 테스트가 이루어지는 시점에 따라 같은 결과를 보장하지 않는 문제점을 해결할 수 있다.
  • Context를 구성하여 테스트의 속도를 향상시킨다.

1-6. Test Double의 고려사항

  • 설명을 들어보면 의존성에 자유롭고 빠르게 테스트가 가능한 Test Double이 좋은거 같은데 왜 주의를 해야되는가.?

1. 과도한 의존성 분리
Test Double을 남용하면 실제 코드와의 연결성이 느슨해지고, 테스트와 실제 동작 간의 불일치가 생길 수 있습니다. 이로 인해 테스트는 잘 동작할 수 있지만, 실제 운영 환경에서 예상치 못한 문제가 발생할 수 있습니다.

2. 가독성 저하
테스트 코드 내에 많은 Mocks, Stubs, Spies 등이 사용되면 코드의 가독성이 저하될 수 있습니다. 코드 내에서 어떤 부분이 실제 구현이고 어떤 부분이 테스트용 객체인지 구분하기 어려울 수 있습니다.

3. 유지보수 어려움
Test Double을 오래 사용하면서 원래 코드가 변경되면, 이에 따라 Test Double도 변경되어야 할 수 있습니다. 이 경우 많은 테스트 코드를 수정해야 할 수 있으며, 그로 인해 유지보수가 어려워질 수 있습니다.

4. Mocking 오버헤드
테스트 Double을 생성하고 설정하는 데 걸리는 시간이 실제 코드를 직접 실행하는 것보다 더 오래 걸릴 수 있습니다. 작은 규모의 단위 테스트의 경우 이 오버헤드가 큰 문제가 되지 않지만, 대규모 시스템에서는 테스트 속도에 영향을 미칠 수 있습니다.

5. 변경에 대한 취약성
실제 코드의 변경이 Test Double에 의존하는 테스트 코드에 영향을 미칠 수 있습니다. 실제 코드 변경 없이도 테스트 코드가 실패하는 경우가 발생할 수 있습니다.

6. 완전한 테스트 커버리지 보장의 어려움
모든 코드 경로를 커버하는 테스트를 작성하기 위해 너무 많은 종류의 Test Double을 만들어야 할 수 있습니다. 이렇게 되면 테스트 코드의 복잡성이 증가하며 유지보수가 어려워질 수 있습니다.

이러한 이유로 Test Double을 사용할 때는 적절한 균형을 유지해야 합니다. 단위 테스트의 목적은 실제 코드의 동작을 검증하는 것이므로, 필요한 경우에만 Test Double을 사용하고 실제 의존성을 최대한 활용하여 실제 동작을 검증하는 테스트를 작성하는 것이 중요합니다.


2. Mock

2-1. Mock이란

Mock을 쉽게 만들고 mock의 행동을 정하는 stubbing 로직이 실행이 되었는지 확인하기 위한 verify()를 이용이 가능하게 해주는 프레임워크 입니다.

2-2. Stub

  • 모의 객체의 메서드 호출에 대한 값을 미리 정의하여 반환을 합니다. 즉. 테스트 코드에서 모듸 객체가 호출될 때 어떤 값을 반환해야 하는지를 미리 정해놓는 것이다.
public interface MyService {
    String getName();
}

@Test
public void testMyService() {
    MyService myService = mock(MyService.class);
    when(myService.getName()).thenReturn("Mockito");

    String name = myService.getName();
    assertEquals("Mockito", name);
}
  • 위 코드를 보면 when().thenReturn()을 통해 값을 반환을 할 수 있게 합니다.

그래서 service에 getName()을 실행하면 위에 설정한 Mockito가 호출이 됩니다.

2-3. Mock이란

  • 가짜 객체를 의미 합니다. 주로 테스트에서 사용되며 테스트할 때 필요한 실제 객체를 동일한 모의 객체를 만들어서 테스트를 할때 효율적으로 만들어 줍니다.

https://ko.wikipedia.org/wiki/%EB%AA%A8%EC%9D%98_%EA%B0%9D%EC%B2%B4

그러면 모의 객체를 사용하면 어떠한 장점이 있을까?

모의 객체를 사용하면 DB에서 값을 읽어오지 않습니다. 즉. DB와 연결을 하지 않는다.

테스트의 관점에서 보게되면 DB와 연결을 하면 위험합니다.

모의 객체를 사용하지 않으면 DB와 연결이 된다. 그렇게 되면 매번 작업을 하면 DB와 연동을 하기 때문에 부하가 많이 걸리게 됩니다. 이렇게 되면 테스트의 시간이 증가하게 됩니다.

테스트 코드를 통해 설명을 하겠습니다.

Mock과 Spy에 대해 비교

Mock과 Spy는 모두 Mockito에서 제공하는 목 객체(Mock Object)를 생성하는 방법 중 두 가지입니다. 하지만 Mock과 Spy는 목적이 다르므로 사용 시 주의해야 합니다.

Mock 객체는 완전한 가짜 객체로, 객체의 메서드 호출을 감시하고 기대하는 동작을 수행합니다. 실제 객체를 대체하여 테스트하기 때문에, 특정한 기능의 메서드를 호출하였을 때 기대한 대로의 반환값을 받아올 수 있는지, 메서드가 적절한 매개변수를 받는지 등을 검증할 때 사용합니다. Mock 객체는 Mockito에서 제공하는 mock() 메서드나 @Mock 어노테이션을 사용하여 생성할 수 있습니다.

Spy 객체는 실제 객체의 일부분을 대신하는 객체로, 실제 객체의 동작을 유지하면서 일부 기능을 변경하거나 추가할 때 사용됩니다. 특정한 객체의 일부 메서드를 호출할 때만 Mock 객체와 같은 방식으로 동작하지만, 그 외에는 실제 객체와 동일한 기능을 수행합니다. Spy 객체는 Mockito에서 제공하는 spy() 메서드나 @Spy 어노테이션을 사용하여 생성할 수 있습니다.

따라서 Mock 객체는 완전한 가짜 객체로 객체의 일부 메서드를 호출하지 않으며, Spy 객체는 실제 객체의 일부분을 가짜로 만들어서 사용합니다. 보통 Mock 객체는 객체의 메서드 호출을 검증하거나, 객체의 동작을 예측하는데 사용하며, Spy 객체는 실제 객체를 조작하거나 변경할 때 사용합니다.

https://scshim.tistory.com/439
https://sun-22.tistory.com/93?category=371101


3. 프로젝트

3-1. 테스트 코드 작성 시 생각

  • 초반에 TDD를 통하여 프로젝트를 진행을 하였지만 이후 협업을 하면서 장단점을 발견을 하였습니다. 테스트를 통하여 협업을 하는 팀원과 더욱 빠르게 코드를 이해를 할 수 있었지만 생산성이 너무 떨어지며 변경이 필요한 부분에서 오히려 비즈니스 로직을 작성하는 시간보다 테스트를 신경쓰는 주객전도가 되는 현상이 발생을 하였습니다.
  • 이러한 문제가 발생하여 로직에 대한 안전성을 검증을 위해 비즈니스 -> 테스트 코드로 협업을 진행을 하였습니다.
  • 이후 어떤 부분에 Junit으로 실제 서비스를 검증하고 Mockito로 검증을 해야되는지 고민을 하게 되었고 Classicist VS Mockist에 대해서 살펴보고 Service Layer의 로직은 실제 서비스를 적용하여 테스트를 진행하며 ( 일부 Mocking) Controller에서는 Mock 방식을 도입을 하였습니다.

3-1-2. Classicist VS Mockist

  • Mockist
    대부분 가짜 객체를 사용하여 의존성을 제거한다.
    service 테스트 시 repository를 가짜 객체로 사용하는 것과 같다.
    가짜 객체를 사용하기 때문에 내부 구현을 커스텀한 응답으로 응답해야 한다.
    특정 행위가 실행되었는지 검증한다. (행위 검증)

  • Classicist (클래식주의자)
    실제 프로젝트에서는 다른 의존성을 사용하며 일관성을 보장하기 위해 실제 로직을 테스트 한다.
    물론 제어할 수 없는 코드는 Test Double을 사용해도 상관없음

  • 프로젝트를 진행하면서 Classicist방식으로 진행을 하였습니다. 왜냐하면 모든 코드를 테스트를 하면 오히려 Mock 오버헤드가 발생하며 실제 테스트에 대한 로직을 검증하여 코드의 일관성을 확인하고 싶었기 때문이다.


3-1-3. layered architecture에 따른 테스트 적용

LocalDataTime

  • jwt를 테스트 하면서 시간에 따른 테스트의 일관성이 보장되지 않습니다.
    public String createToken(Long id, String email, List<String> roles,
                              Long expire, byte[] secretKey) {
        Claims claims = Jwts.claims().setSubject(email);

        claims.put("memberId", id);
        claims.put("roles", roles);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date())
                .setExpiration(new Date(new Date().getTime() + expire))
                .signWith(getSigningKey(secretKey))
                .compact();
    }
    
    public String createTokenWithDate(Long id, String email, List<String> roles, Long expire, byte[] secretKey, Date date) {
        Claims claims = Jwts.claims().setSubject(email);

        claims.put("memberId", id);
        claims.put("roles", roles);

        Date expirationDate = new Date(date.getTime() + expire);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(date)
                .setExpiration(expirationDate)
                .signWith(getSigningKey(secretKey))
                .compact();
    }
    @Test
    @DisplayName("트레이드 오프에 따른 Date 토큰 생성하기")
    public void createToken() throws Exception{
        // given
        Long id = 1L;
        String email = "test@example.com";
        List<String> roles = Arrays.asList("ROLE_USER", "ROLE_ADMIN");
        Long expire = 3600000L; // 1 hour
        byte[] secretKey = "0123456789012345678901234567890123456789".getBytes();
        Date date = new Date(1656789000000L);//Tue Jun 02 2022 12:30:00 GMT+0000
        // when
        String token = jwtTokenizer.createTokenWithDate(id, email, roles, expire, secretKey,date);

        //Then
        assertThat(token).isEqualTo("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibWVtYmVySWQiO" +
                "jEsInJvbGVzIjpbIlJPTEVfVVNFUiIsIlJPTEVfQURNSU4iXSwiaWF0IjoxNjU2Nzg5MDAwLCJleHAiOjE2NTY3OTI2MDB9." +
                "l8ZHBTJj7hYS3jYdJHYp6Sd0Xf-igWTzWl74u6x75cU");
    }
  • 기존의 코드를 보면 Time에 Date를 적용을 했습니다. 하지만 이렇게 되면 시간을 제어할 수 없이 때문에 Date() 부분을 매개변수로 수정을 하였습니다.

Service Layer 로직

  • 실제 서비스 로직에서 시간을 사용하는 부분은 주로 대회를 처리하는 부분이 있었습니다.

  • 이때 기존에는 LocalDateTime을 Service 로직에 처리를 했지만 이걸 Controller에 LocalDateTime에 위임하여 Controller에서 시간을 Service 로직에 매개변수로 전달하여 테스트의 일관성을 보장하게 리펙토링을 하였습니다.

  • 이때 실제 서비스를 사용하기 보다는 Test Double을 사용하여 제어할 수 없는 부분을 일관성 있는 테스트 코드로 변경을 하였습니다.

@Test
    public void testGetCompetitionListFinishTrue() {
        // given
        LocalDateTime now = LocalDateTime.of(2023, 8, 15, 12, 0);
        boolean finish = true;
        Pageable pageable = Pageable.ofSize(10).withPage(0);

        List<Competition> competitionList = new ArrayList<>();

        Competition competition = Competition.builder()
                .competitionStart(now)
                .participants(5)
                .competitionEnd(now.plusHours(1))
                .competitionTitle("제목")
                .build();
        competitionList.add(competition);

        Page<Competition> competitionPage = new PageImpl<>(competitionList);

        //when
        when(competitionRepository.findByCompetitionEndBefore(any(), any())).thenReturn(competitionPage);
        Page<CompetitionListResponseDto> result = competitionService.getCompetitionList(finish, pageable, now);

        //then
        CompetitionListResponseDto responseDto = result.getContent().get(0);
        assertThat(responseDto.getTitle()).isEqualTo("제목");
        assertThat(responseDto.getParticipants()).isEqualTo(5);

        verify(competitionRepository).findByCompetitionEndBefore(any(), any());
    }

 @Test
    public void testGetCompetitionListFinishFalse() {
        // given
        LocalDateTime now = LocalDateTime.of(2023, 8, 15, 12, 0);
        boolean finish = false;
        Pageable pageable = Pageable.ofSize(10).withPage(0);

        List<Competition> competitionList = new ArrayList<>();

        Competition competition = Competition.builder()
                .competitionStart(now)
                .participants(5)
                .competitionEnd(now.plusHours(1))
                .competitionTitle("제목")
                .build();

        competitionList.add(competition);

        Page<Competition> competitionPage = new PageImpl<>(competitionList);

        when(competitionRepository.findByCompetitionEndAfter(any(), any())).thenReturn(competitionPage);

        // when
        Page<CompetitionListResponseDto> result = competitionService.getCompetitionList(finish, pageable, now);

        //then
        CompetitionListResponseDto responseDto = result.getContent().get(0);
        assertThat(responseDto.getTitle()).isEqualTo("제목");
        assertThat(responseDto.getParticipants()).isEqualTo(5);

        verify(competitionRepository).findByCompetitionEndAfter(any(), any());
    }

Service Layer 테스트

  • 서비스를 Mock으로 해야되는지 고민을 했습니다. 하지만 Mock을 하면서 오버헤드가 발생하여 실제 서비스를 기반하여 테스트를 진행을 하였습니다. 기존의 문제를 생성하고 카테고리, 문제에 대한 번호 및 선택을 해야되는 부분을 전부 Mocking을 하려면 너무 많은 코드를 작성해야 되었기 때문에 실제 서비스를 기반으로 테스트를 진행을 하였습니다.

    @BeforeEach
    void setUp(){
        List<CreateChoicesAboutQuestionDto> createChoicesAboutQuestionDto = new ArrayList<>();
        createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                .number(1)
                .content("선택 1")
                .build());
        createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                .number(2)
                .content("선택 2")
                .build());
        createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                .number(3)
                .content("선택 3")
                .answer("정답")
                .build());
        createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                .number(4)
                .content("선택 4")
                .build());

        questionService.createQuestionChoice(CreateQuestionAndCategoryRequestDto.builder()
                .createQuestionRequestDto(CreateQuestionRequestDto.builder()
                        .questionTitle("문제 제목")
                        .questionDesc("문제에 대한 설명")
                        .questionExplain("문제에 대한 해답")
                        .build())
                .categoryRequestDto(CategoryRequestDto.builder()
                        .category("네트워크")
                        .build())
                .createChoicesAboutQuestionDto(createChoicesAboutQuestionDto)
                .build());
    }

    @Test
    @DisplayName("생성된 문제에 대한 문제 찾기 및 카테고리")
    public void findQuestionValidWithChoiceAndCategory() throws Exception {
        //given
        List<CreateChoicesAboutQuestionDto> createChoicesAboutQuestionDto = new ArrayList<>();
        createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                .number(1)
                .content("선택 1")
                .build());
        createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                .number(2)
                .content("선택 2")
                .build());
        createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                .number(3)
                .content("선택 3")
                .answer("정답")
                .build());
        createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                .number(4)
                .content("선택 4")
                .build());

        CreateQuestionAndCategoryRequestDto createQuestionAndCategoryRequestDto = CreateQuestionAndCategoryRequestDto.builder()
                .createQuestionRequestDto(CreateQuestionRequestDto.builder()
                        .questionTitle("문제 제목")
                        .questionDesc("문제에 대한 설명")
                        .questionExplain("문제에 대한 해답")
                        .build())
                .categoryRequestDto(CategoryRequestDto.builder()
                        .category("네트워크")
                        .build())
                .createChoicesAboutQuestionDto(createChoicesAboutQuestionDto)
                .build();

        questionService.createQuestionChoice(createQuestionAndCategoryRequestDto);
        //when
        QuestionResponseDto result = questionService.findQuestionWithChoiceAndCategory(1L);
        //Then
        assertAll(
                () -> assertThat(result.getTitle()).isEqualTo("문제 제목"),
                () -> assertThat(result.getDescription()).isEqualTo("문제에 대한 설명"),
                () -> assertThat(result.getExplain()).isEqualTo("문제에 대한 해답")
        );

        assertAll(
                () -> assertThat(result.getChoices().get(0).getContent()).isEqualTo("선택 1"),
                () -> assertThat(result.getChoices().get(1).getContent()).isEqualTo("선택 2"),
                () -> assertThat(result.getChoices().get(2).getContent()).isEqualTo("선택 3"),
                () -> assertThat(result.getChoices().get(3).getContent()).isEqualTo("선택 4")

        );

        assertAll(
                () -> assertThat(result.getChoices().get(0).getNumber()).isEqualTo(1),
                () -> assertThat(result.getChoices().get(1).getNumber()).isEqualTo(2),
                () -> assertThat(result.getChoices().get(2).getNumber()).isEqualTo(3),
                () -> assertThat(result.getChoices().get(3).getNumber()).isEqualTo(4)
        );

        assertThat(result.getCategoryTitle()).isEqualTo("네트워크");
    }

    @Test
    @DisplayName("recursiveCreateQuestionChoice 테스트")
    public void recursiveCreateQuestionChoice_Valid() {
        // Given

        List<CreateChoicesAboutQuestionDto> createChoicesAboutQuestionDto = new ArrayList<>();
        createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                .number(1)
                .content("선택 1")
                .build());
        createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                .number(2)
                .content("선택 2")
                .build());
        createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                .number(3)
                .content("선택 3")
                .answer("정답")
                .build());
        createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                .number(4)
                .content("선택 4")
                .build());

        List<CreateQuestionAndCategoryRequestDto> requestDtos = new ArrayList<>();
        requestDtos.add(CreateQuestionAndCategoryRequestDto.builder()
                .createQuestionRequestDto(CreateQuestionRequestDto.builder()
                        .questionTitle("문제 제목")
                        .questionDesc("문제에 대한 설명")
                        .questionExplain("문제에 대한 해답")
                        .build())
                .categoryRequestDto(CategoryRequestDto.builder()
                        .category("네트워크")
                        .build())
                .createChoicesAboutQuestionDto(createChoicesAboutQuestionDto)
                .build());

        // When
        questionService.recursiveCreateQuestionChoice(requestDtos);

        // Then
        List<Question> questions = questionRepository.findAll();
        assertThat(questions).hasSize(1);

        Question question = questions.get(0);
        assertThat(question.getTitle()).isEqualTo("문제 제목");
        assertThat(question.getDescription()).isEqualTo("문제에 대한 설명");
        assertThat(question.getExplain()).isEqualTo("문제에 대한 해답");

        List<Choice> choices = question.getChoices();
        assertThat(choices.get(0).getContent()).isEqualTo("선택 1");
        assertThat(choices.get(1).getContent()).isEqualTo("선택 2");
        assertThat(choices.get(2).getContent()).isEqualTo("선택 3");
        assertThat(choices.get(3).getContent()).isEqualTo("선택 4");
        assertThat(choices.get(2).isAnswer()).isTrue();
    }

    @DisplayName("문제 find & category")
    @Nested
    class findQuestionWithChoiceAndCategory {

        void beforeCreateSet() {


            List<CreateChoicesAboutQuestionDto> createChoicesAboutQuestionDto = new ArrayList<>();
            createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                    .number(1)
                    .content("선택 1")
                    .build());
            createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                    .number(2)
                    .content("선택 2")
                    .build());
            createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                    .number(3)
                    .content("선택 3")
                    .answer("정답")
                    .build());
            createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
                    .number(4)
                    .content("선택 4")
                    .build());

            CreateQuestionAndCategoryRequestDto createQuestionAndCategoryRequestDto = CreateQuestionAndCategoryRequestDto.builder()
                    .createQuestionRequestDto(CreateQuestionRequestDto.builder()
                            .questionTitle("문제 제목")
                            .questionDesc("문제에 대한 설명")
                            .questionExplain("문제에 대한 해답")
                            .build())
                    .categoryRequestDto(CategoryRequestDto.builder()
                            .category("네트워크")
                            .build())
                    .createChoicesAboutQuestionDto(createChoicesAboutQuestionDto)
                    .build();

            questionService.createQuestionChoice(createQuestionAndCategoryRequestDto);
        }

참고


https://jojoldu.tistory.com/226
https://scshim.tistory.com/439
https://sun-22.tistory.com/93?category=371101
https://ko.wikipedia.org/wiki/%EB%AA%A8%EC%9D%98_%EA%B0%9D%EC%B2%B4

profile
빠르게 실패하고 자세하게 학습하기

0개의 댓글