웹/스프링

Spring Boot에서 통합 테스트코드 구현하기(JUnit5 + Mockito)

mrban 2022. 4. 17. 22:39

아래 코드만으로 부족한 경우 깃허브를 참고해주세요: https://github.com/HangHae99Zzz/RoomEscape_BE/tree/main/src/test/java/com/project/roomescape

 

GitHub - HangHae99Zzz/RoomEscape_BE

Contribute to HangHae99Zzz/RoomEscape_BE development by creating an account on GitHub.

github.com

 

1. JUnit5란

자바 개발자들이 국내에서 가장 많이 사용하는 테스트 프레임워크이다.

Junit5는 3가지로 구성되어 있다.

- JUnit Platform : 테스트를 실행해주는 런처와 TestEngine API를 제공함.

- JUnit Jupiter : TestEngine API 구현체로 JUnit5에서 제공함.

- JUnit Vintage : TestEngine API 구현체로 JUnit3, 4(이전 버전과의 호환성 목적)에서 제공함.

 

2.Mockito란?

객체를 mock과 같은 가짜 객체로 대체해 테스트를 도와주는 테스트 프레임워크이다. 의존성을 끊어내고 싶을 때 사용하는 프레임워크이다.

 

3. JUnit5 + Mockito를 선택한 이유

JUnit5같은 경우 스프링 부트 2.2.x 이상이라면 기본적으로 제공되고 있기 때문에 별도의 dependency를 추가할 필요가 없다는 장점이 있다. 또한 JUnit5에서 제공하는 다양한 어노테이션이 있어 간편하게 테스트 코드를 작성하는 것이 가능하다고 생각했다. 결정적으로 JUnit5의 경우 국내에서 가장 많이 사용하는 테스트 프레임워크인 만큼 자료가 많다는 점도 선택 이유가 되었다. Mockito도 마찬가지로 별도의 dependency를 추가할 필요가 없고 제공하는 어노테이션을 통해 간단하게 객체 의존성을 끊어낼 수 있다는 장점이 있기 때문에 사용하였다. 

 

4. MockMVC를 사용하지 않은 이유

프로젝트의 실제 WAS 환경에서 문제없이 잘 응답하는지 확인하고 싶었기 때문에(실제 배포 환경과 최대한 같은 환경에서 잘 돌아가는지) MockMvc를 사용하지 않았다. 물론 MockMVC를 사용하지 않았기 때문에 테스트 코드 시행할 때 WAS를 시행하는데 오랜 시간이 걸리지만 그걸 감안하기로 하였다. 대신 빌드과정에서 테스트코드는 자동으로 실행되는 과정이 있는데 이는 빌드시 너무 많은 시간 소요를 만들어냈다. 따라서 build.gradle파일 설정에 테스트 코드 제외하는 설정을 추가함으로써 빌드시에는 테스트코드 실행을 제외하였다. 이에 따라 프로젝트 팀원들간에 서비스 배포전에 반드시 테스트코드를 자발적으로 실행할 것을 협의하였다.

 

5. 통합테스트에서 TestRestTemplate vs WebTestClient

이 질문은 결국 통합테스트에서 RestTemplate을 사용할 것인가 이나면 webClient를 사용할 것인가에 대한 질문이 된다. 왜냐하면 TestRestTemplate은 통합테스트에서 RestTemplate의 기능을 간편하게 제공하는 인터페이스이고 WebTestClient도 마찬가지로 WebClient의 기능을 간편하게 제공하기 위한 테스트용 인터페이스이기 때문이다.  RestTemplate은 Spring 3.0 부터 지원하는 인터페이스로 Spring에서 HTTP 통신을 RESTful 형식에 맞게 손쉬운 사용을 제공해주는 템플릿이다. WebCleint는 스프링 5.0에서 추가된 인터페이스로 마찬가지로 HTTP 통신을 위한 손쉬운 사용을 제공합니다. 즉, 둘 다 요청 인터페이스인데 프로젝트에서는 WebClient를 선택하였다. 이유는 다음과 같다.

  • Spring Docs RestTemplate 클래스 파일에 따르면 Spring 5.0부터 RestTemplate은 유지보수만 할 것이기 때문에 앞으로 버그에 대한 픽스밖에 이뤄지지 않을 것이라고 한다. 또한 직접적으로 더 현대적인 API등을 제공하는 WebClient를 사용할 것을 권하고 있다. 즉, 한마디로 RestTemplate은 앞으로 더이상 사용되지 않고 WebClient를 사용하게 될 것이다라는 것이다. 이런 상황에서 굳이 RestTemplate을 쓸 이유는 없어 보였다.
  • RestTemplate은 synchronous(동기성)하다. 즉, web 요청을 하고 다음 행동을 하려면 response가 올때까지 기다려야한다는 뜻이다. 반면에 WebTestClient는 response 올 때까지 기다릴 필요가 없고, response가 오면 알림을 받는다. 심지어 WebTestClient는 block()을 통해서 동기식으로 설정가능하다. 이 점에서 나는 WebClient가 완벽하게 RestTemplate의 상위호환인 것 같은 느낌을 받았다.

6. 구체적인 방식 및 코드리뷰

대표적으로 통합테스트 한 부분만 떼와서 설명하겠다.

//@SpringBootTest는 통합 테스트를 제공하는 기본적인 스프링 부트 테스트 어노테이션 이다.
//webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT는 웹 테스트 환경 설정을 하는데 랜덤포트로 listen하겠다는 설정이다.
//properties는 기존의 properties가 아닌 새로운 테스트 전용 properties로 하겠다는 것이다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {"spring.config.location=classpath:application-test.properties"})
//테스트 인스턴스의 생명주기를 Class단위로 합니다.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
//@Order 순서로 테스트를 진행합니다.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class RoomIntegrationTest {
	//@Autowired로 의존성 주입을 합니다.
    @Autowired
    private WebTestClient webTestClient;

    @Autowired
    private RoomRepository roomRepository;

    @MockBean
    private GameResourceRepository mockGameResourceRepository;

    @Test
    @Order(1)
    @DisplayName("방 개설하기")
    void createRoom_OneRoom_CreateOneRoom(){
        String teamName = "테스트팀";
        String userId = "테스트유저ID";
        RoomRequestDto roomRequestDto = new RoomRequestDto(teamName, userId);

        GameResource gameResource1 = new GameResource("userImg", "임시url1");
        GameResource gameResource2 = new GameResource("userImg", "임시url2");
        GameResource gameResource3 = new GameResource("userImg", "임시url3");
        GameResource gameResource4 = new GameResource("userImg", "임시url4");
        List<GameResource> mockGameResourceList = new ArrayList<GameResource>(){{
            add(gameResource1);
            add(gameResource2);
            add(gameResource3);
            add(gameResource4);
        }};
		//mockGameResourceRepository.findAllByType를 호출시에 내가 만든 mockGameResourceList를 return합니다.
        when(mockGameResourceRepository.findAllByType(gameResource1.getType())).thenReturn(mockGameResourceList);

        webTestClient.post().uri("/rooms")
        		//post requestbody의 형식이 Application JSON형식인지 체크합니다.
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(roomRequestDto)
                .exchange()
                //200으로 응답이 정상 처리되는지 체크
                .expectStatus().isOk()
                //response의 헤더에 컨텐츠 타입이 JSON인지 체크
                .expectHeader().contentType(MediaType.APPLICATION_JSON)
                .expectBody()
                //response body의 각각의 key와 value값이 제대로 나오는지 체크
                .jsonPath("$.roomId").isEqualTo(1L)
                .jsonPath("$.teamName").isEqualTo("테스트팀")
                .jsonPath("$.createdUser").isEqualTo("테스트유저ID")
                .jsonPath("$.currentNum").isEqualTo(1)
                .jsonPath("$.url").isEqualTo("/room/1")
                .jsonPath("$.userList").isNotEmpty()
                .jsonPath("$.startAt").isEmpty();
    }

다음은 단위테스트 부분이다.

//Mockito를 사용하기 위한 어노테이션입니다.
@ExtendWith(MockitoExtension.class)
public class QuizServiceTimeTest {

    @Spy
    ClueRepository mockClueRepository;

    @Spy
    QuizRepository mockQuizRepository;
	//생성한 Spy들을 QuizService에 주입합니다.
    @InjectMocks
    private QuizService quizService;

    @Test
    @DisplayName("퀴즈 Aa생성 시간 테스트")
    void createQuiz_QuizTypeAa_CreateQuizAa() {
    	//로직 전에 시간측정을 합니다.
        long beforeTime = System.currentTimeMillis();
        Room room = new Room("임시팀", "임시유저");
		//실제 quizService.createQuizAa를 테스트합니다.
        QuizResponseDto quizResponseDto = quizService.createQuizAa(room, "Aa");
		//로직 후에 시간측정을 합니다.
        long afterTime = System.currentTimeMillis();
        //두 시간에 차 계산(초로 변환)
        double secDiffTime = (double)(afterTime - beforeTime)/ 1000;
        System.out.println("실행시간(m) : " + secDiffTime);
        assertNotNull(quizResponseDto.getAnswer());
    }

위와 같이 코드를 짠 이유를 알려면 프로젝트에서 단위테스트의 목적을 이해해야한다.

프로젝트 당시 우리는 QuizService에서 퀴즈를 생성하는 로직을 시간측정을 하겠다는 것이 당시 단위테스트를 만들게 된 의도였다. 따라서 QuizService에 테스트 시행시 실제 객체의 기능을 사용해야했고 이에 따라 Stubbing 하지 않은 @Spy를 선택하였다. 참고로 @Spy는 @Mock과는 다르게 stubbing(dummy객체가 마치 실제로 동작하는 것 처럼 보이도록 만들어놓은 것)을 안해주면 실제 객체 로직을 따르게 되는 특성을 가지고 있다.

 

두번째 이유는 @Mock으로 처리하기가 굉장히 어렵다는 점이다. 만약 @Mock으로 만들었다면 when().thenReturn()같은 메서드를 반드시 명시해줘야하는데 실제 테스트시 quizRepository.save(roomId)와 clueRepository.findAllByRoomId(room.getId())에서 RoomId와 테스트 코드에서 제가 직접 만든 RoomId는 일치할 수가 없기 때문에(테스트 시에 만들어질 RoomId를 사전에 알아내는 것이 불가능하기 때문에) when().thenReturn() 메서드를 작성하는 것이 불가능하다 판단했습니다.

 

이 두가지 이유로 QuizService에 의존성 주입을 @Spy로 했습니다.

 

7. 기타

참고로 @Autowired 방식으로 의존성 주입한 이유는 Spring과 Spring 통합 테스트에서 autowire를 핸들링하는 방식에 차이가 존재하기 때문에 생성자 주입 방식으로 하려면 별도의 설정이 필요했기 때문입니다. 

필드주입방식이 순환참조가 발생하는 경우에는 좋은 방법은 아니지만 순환참조가 발생할 가능성이 없다는 판단하에 간단한 필드주입방식을 선택했습니다.

 

최대한 실제 WAS 환경에서 문제없이 잘 응답하는지 확인하고 싶었기 때문에 Mockito의 Mock 기능을 최대한 사용하지 않으려고 했습니다. 다만 GameResource같은 경우에는 기존의 DB에 존재하는 파일 정보들을 의미하기 때문에 테스트 환경에서는 실제 로직으로 접근하는 것이 불가능하다고 판단하였습니다. 이런식으로 정말 어쩔수 없이 의존성을 끊어야하는 경우만 @MockBean을 사용했습니다.

 

@Mock은 @InjectMocks을 통해서만 의존성 주입이 가능하고 @MockBean은 @SpringBootTest를 통해서 의존성 주입이 됩니다.

 

단위테스트의 경우 시간측정을 목적으로 만들었지만 0.0초 ~ 0.06초 사이로 별로 유의미한 결과를 얻지 못했습니다. 애초에 시간측정을 하려고 한 이유가 오해에서 비롯되었습니다. 기존 퀴즈생성 로직은 하드코딩이 많이 존재했는데 이 하드코딩을 개선한다면 얼마나 생성 로직의 속도가 빨라질까라는 오해가 있었습니다. 즉, 하드코딩 --> 시간이 엄청 오래 걸리는 로직이라는 잘못된 인과관계로 생각했기 때문에 시간 측정 단위테스트를 만든 것입니다. 즉, 하드코딩은 가독성이 떨어지고 메모리를 효율적으로 사용하지 못하며 유지보수하기 어려운 코드를 의미할 뿐입니다. 이를 개선하더라도 가시적인 속도 개선 효과는 얻기 어렵습니다.

 

8. 참고한 사이트: https://steady-coding.tistory.com/349

https://loopstudy.tistory.com/279?category=1003677 

https://junit.org/junit5/docs/current/user-guide/#writing-tests-annotations

https://doongjun.tistory.com/71

https://hyper-cube.io/2017/08/06/spring-boot-test-1/

https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.testing

 

https://withhamit.tistory.com/300

 

[JUnit5] @TestInstance

TestInstance 는 테스트 인스턴스의 라이프 사이클을 설정할 때 사용한다. - PER_METHOD : test 함수 당 인스턴스가 생성된다. - PER_CLASS : test 클래스 당 인스턴스가 생성된다. @TestInstance(TestInstance.L..

withhamit.tistory.com