[GDSC] Spring Boot - JUnit5 +MockMvc + Mockito로 Unit test 작성하기

KIM TAEHYUN·2023년 6월 18일
0
post-thumbnail

Introduction

프로젝트 “밍글”을 진행하면서 제한된 시간 내에 빠르게 개발을 진행하느라 테스트코드를 미처 작성하지 못했습니다.

하지만 요구사항과 비즈니스 로직은 계속해서 변화하기 마련입니다. 그럴 때마다 코드를 바꾸고 Postman으로 직접 매번 테스트를 돌리는 것은 매우 비효율적입니다. 또한, 하나의 기능을 바꾸더라도 그 기능이 다른 기능과 연관이 있을 시 연관된 모든 API를 다시 테스트 해보아야 합니다. 더 최악의 경우로는 어떠한 변경사항이 다른 기능에 영향이 있을 지 모르고 넘어가는 것입니다.

이와 같은 상황을 방지하고자 새로운 프로젝트에서는 Spring Boot + JUnit5 + Mockito + MockMvc를 이용하여 Unit test를 작성해보았습니다.


JUnit5

JUnit5는 Java 단위 테스트(Unit test)를 위한 테스팅 프레임워크입니다.

1. MockMvc란?

  • 컨트롤러를 테스트하기 위해서는 HTTP 호출이 필요합니다. 스프링에서는 이를 위한 MockMvc를 제공하고 있으며 이는 컨트롤러 단을 테스트하는데 쓰입니다.
  • MockMvc는 웹 애플리케이션의 컨트롤러를 실행시키고, 응답 결과를 검증할 수 있습니다.

MockMvc 메소드 사용하기

  • MockMvc 객체를 주입받아 perform(httpMethod(”/url”))로 실행합니다.

  • mockMvc.andExpect(), andDo(), andReturn()과 같은 메소드들로 응답 결과를 검증할 수 있습니다. ResultActions interface 에 있는 메소드들입니다.

    1. andExpect(): Allows expectations on the result of an executed request.

      • 아래와 같이 요청의 HttpStatus나 contentType, jsonpath별 값들의 동작을 확인할 수 있습니다.
      mockMvc.perform(get("/person/1")) // performs an HTTP GET request with the specified parameter
          .andExpect(status().isOk()) //checks if the response status is 200 (HTTP OK status).
          .andExpect(content().contentType(MediaType.APPLICATION_JSON))
          .andExpect(jsonPath("$.person.name").value("Jason")); //
    2. andDo(): Perform a general action.

      • 아래와 같이 result detail을 콘솔에 프린트해주는 용도로 쓸 수 있습니다. (디버깅에 용이)
      mockMvc.perform(get("/post")).andDo(print());
    3. andReturn(): Return the result of the executed request for direct access to the results.

      • 아래와 같은 방식으로 request의 result를 받아와 다이렉트하게 접근해 직접 assertEquals 등으로 검증할 수 있습니다.
      MvcResult result = mockMvc.perform(get("/post")
                  .param("category", String.valueOf(category))
                  .contentType(APPLICATION_JSON))
      		.andDo(print()); 
          .andReturn();
      
      int status = result.getResponse().getStatus();
      assertEquals(200, status);
      
      String responseContent = result.getResponse().getContentAsString();
      String expectedContent = objectMapper.writeValueAsString(postList); 
      assertEquals(expectedContent, responseContent);

2. Mockito란?

Mock 객체를 쉽게 만들고, 관리하고, 검증할 수 있는 방법을 제공하는 프레임워크입니다.

  • Mockito는 개발자가 동작을 직접 제어할 수 있는 가짜 객체(Mock)를 지원하는 테스트 프레임워크이다. 일반적으로 Spring으로 웹 애플리케이션을 개발하면, 여러 객체들 간의 의존성이 생긴다. 이러한 의존성은 단위 테스트를 작성을 어렵게 하는데, 이를 해결하기 위해 가짜 객체를 주입시켜주는 Mockito 라이브러리를 활용할 수 있다. Mockito를 활용하면 가짜 객체에 원하는 결과를 Stub하여 단위 테스트를 진행할 수 있다.
  • 애플리케이션에서 데이터베이스, 외부 API 등을 테스트할 때, 해당 제품들이 어떻게 작동하는지 항상 사용하면서 테스트를 작성한다면 매우 불편할 것이다. 이럴 때 어떻게 작동하는지 예측을 하여 Mock 객체를 만들어서 사용하면, 편리한 테스트가 가능하다.

Mock 객체란?

  • 진짜 객체와 비슷하게 동작하지만, 프로그래머가 직접 행동을 관리하는 객체
  • 실제 객체를 만들어 사용하기에 시간, 비용 등의 Cost가 높은경우 사용
    • e.g.) Repository가 어떻게 작동하는지 Mockito를 이용해 만들어 놓으면, repository 객체를 구현하기 전에도 테스트를 작성할 수 있다.
    • 컨트롤러에서 서비스 mock 객체를 만들어 서비스 계층을 stub(객체의 행동 정의)할 수 있다.
  • 가짜 객체를 만들어 가짜 객체가 원하는 행위를 하도록 정의하고(가짜객체를 DI) 타 컴포넌트에 의존하지 않는 순수한 나의 코드만 테스트하기 위해서 사용한다.

@MockBean

  • 원래의 빈 객체를 Mock객체로 대체하여 사용 할 수 있습니다.
  • given, when, verify등 Mockito의 라이브러리를 활용하여 테스트할 수 있습니다.
  • 전체적인 플로우를 검증하는 @SpringBootTest 를 사용하는 경우보다는 @WebMvcTest로 테스트할때 @MockBean을 사용해주어야 합니다.

Mockito 메소드 사용하기

주요 메소드만 살펴보겠습니다. 더 자세한 사용법은 https://scshim.tistory.com/439 를 참고하면 될 것 같습니다.

  • Mockito의 when()메소드를 사용해서 Mock 객체의 행동을 정의할 수 있습니다. (Stubbing 할 수 있다).그 예로, 특정한 값을 리턴하거나 예외를 던지게 할 수 있습니다.
  • 다음 코드는 MemberService의 findById 메서드에 매개변수 1L을 입력하여 호출하면, 특정한 member 객체를 호출하도록 정의한 것입니다.
@Test
    void when_thenReturn사용(){
        when(memberService.findById(1L)).thenReturn(Optional.of(member));
        assertEquals("ted@email.com",memberService.findById(1L).get().getEmail());
    }
  • Mockito의 any() 메소드(Argument Matcher)를 이용하면, 모든 매개 변수에 대하여 같은 행동을 하는 Mock 객체를 만들 수 있습니다.
  • e.g)다음 코드는 MemberService의 findById() 메소드에 어떠한 변수가 들어와도 같은 객체를 반환합니다.
@ExtendWith(MockitoExtension.class)
class StudyServiceTest {
	@Test
	void any사용(){
	  when(memberService.findById(any())).thenReturn(Optional.of(member));
	  assertEquals("ted@email.com",memberService.findById(1L).get().getEmail());
	  assertEquals("ted@email.com",memberService.findById(999L).get().getEmail());
		}
}
  • Mockito의 verify() 메소드를 사용해 특정 메소드가 특정 매개변수로 몇 번 호출 되었는지 확인할 수 있습니다.
@Test
    void Study객체를_생성하면_notify_1번_호출(){
        StudyService studyService = new StudyService(memberService, studyRepository);
        Study study = new Study(10, "테스트");
        
        when(memberService.findById(1L)).thenReturn(Optional.of(member));
        when(studyRepository.save(study)).thenReturn(study);
        
        studyService.createNewStudy(1L, study);

				*// memberService에서 notify(study) 메소드가 한 번 호출 되었는지 확인*
				verify(memberService, times(1)).notify(study);
💡 이 외에도 Mock 객체를 조작하면,

출처: https://scshim.tistory.com/439


이제 위의 프레임워크들을 활용해 게시물 생성 API에 대한 PostControllerTest, PostServiceTest, PostRepositoryTest 클래스를 작성해보겠습니다.

PostControllerTest

컨트롤러 테스트코드 작성방법

  • Controller는 @SpringBootTest와 @WebMvcTest 어노테이션을 사용하여 테스트할 수 있으며 해당 포스팅에서는 @WebMvcTest를 사용합니다.(@WebMvcTest는 모든 빈을 로드하지않으므로 @MockBean을 사용합니다.)
  • MockMvc를 통해 api를 호출하며 해당 컨트롤러에서 의존하고 있는 객체를 Mock객체로 만들어 주입해줍니다.(@MockBean 어노테이션 사용)
  • Mock 객체는 가짜 객체이므로 리턴되는값이 없습니다. 따라서 given, when 등으로 원하는 값을 리턴 하도록 미리 정의해줍니다.
  • 로직이 진행된후 해당 행위가 진행됐는지 verify를 통해 검증해줍니다.

PostMock.java

  • 아래 ControllerTest의 given 파트에서 각 Mock request, response 객체를 만들어주기 위해 쓰이는 메소드입니다.
public class PostMock {

    static CreatePostRequest createPostRequest() {
        return CreatePostRequest.builder()
                .title("title")
                .content("content")
                .categoryId("1")
                .build();
    }
    static CreatePostResponse createPostResponse() {
        return CreatePostResponse.builder()
                .postId(1L)
                .memberId(1L)
                .nickName("nickName")
                .title("title")
                .content("content")
                .createdAt(LocalDateTime.now())
                .build();
    }

PostControllerTest.java

  • 간단한 설명은 주석으로 적어두었고, 숫자가 적힌 주석들은 밑에서 자세히 적어두었습니다.
@WebMvcTest(PostController.class) //1
@ExtendWith(MockitoExtension.class) //2
public class PostControllerTest {

    @Autowired
    private MockMvc mockMvc; //3
    @Autowired
    private ObjectMapper objectMapper; //response를 json으로 바꿔주기위해 필요한 의존성
    @MockBean
    private PostService postService; //4. PostService의 Mock 객체 

		@Test
    @DisplayName("게시물 생성 Controller")
    void createPost() throws Exception {
        //5. given
        Long memberId = 1L;
        CreatePostRequest request = createPostRequest();
        CreatePostResponse response = createPostResponse();
				//6. any() 쓰는 이유
        when(postService.createPost(any(CreatePostRequest.class), eq(memberId))).thenReturn(response); //mock postService.createPost()
				//7. LocalDateTime 트러블슈팅 
        objectMapper.registerModule(new JavaTimeModule()); //LocalDateTime
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

				// Serialize the request object to a JSON string
        String requestJson = objectMapper.writeValueAsString(request);
        String responseJson = objectMapper.writeValueAsString(response);

        mockMvc.perform(post("/post")
                        .contentType(APPLICATION_JSON)
                        .param("memberId", String.valueOf(memberId))
                        .content(requestJson)) //put the converted Json string request
                .andExpect(status().isOk()) // checks if the response status is 200 (HTTP OK status).
                .andExpect(content().json(responseJson)) //8: checks if the response content is equal to the expected JSON content.
                .andExpect(jsonPath("$.postId").exists()) //9: check each parameter
                .andExpect(jsonPath("$.memberId").value(memberId))
                .andExpect(jsonPath("$.nickName").value(response.getNickName()))
                .andExpect(jsonPath("$.title").value(response.getTitle()))
                .andExpect(jsonPath("$.content").value(response.getContent()))
                .andExpect(jsonPath("$.createdAt").exists())
                .andDo(print()); //prints the result details to the console

        verify(postService).createPost(any(CreatePostRequest.class), eq(memberId)); //8
  }
  1. @WebMvcTest(테스트할 컨트롤러.class)
  • 해당 클래스만 실제로 로드하여 테스트를 해줍니다.
  • argument로 컨트롤러를 지정해주지 않으면 @Controller @RestController @ControllerAdvice 등등 컨트롤러와 연관된 bean들이 로드됩니다.
  • 스프링의 모든 빈을 로드하여 테스트하는 방식인 @SpringBootTest어노테이션 대신 컨트롤러 관련 코드만 테스트하고자 할때 사용하는 어노테이션입니다.
  1. @ExtendWith(MockitoExtension.class)
  • JUnit5와 Mockito를 연동하기 위해 적어줍니다.
  1. @Autowired MockMvc mvc;
  • 컨트롤러의 api를 테스트하는 용도인 MockMvc 객체를 주입받습니다. 위에서 설명했던대로 mockMvc.andExpect() 메소드 등을 통해 응답 결과를 검증할 수 있습니다.
  1. @MockBean PostService postService;
  • PostrController는 PostService를 스프링 컨테이너에서 주입받고있으므로 가짜 객체를 만들어 컨테이너가 주입할 수 있도록 해줍니다.
  • 해당 객체는 가짜 객체이므로 실제 행위를 하는 객체가 아니며 기존에 정해진 동작을 수행하지 하지 않습니다.
  • 이에, 우리는 해당 postService의 메서드가 어떤 행위를 하는지 직접 정의해주어야 합니다.
  1. given 파트
  • request와 expected response 객체를 만들어줍니다.
  1. when(postService.createPost(any(CreatePostRequest.class), eq(memberId))).thenReturn(response); 에서 any() 를 쓰는 이유
  • HTTP 요청을 보내면 Spring은 내부에서 MessageConverter를 사용해 Json String을 객체로 변환한다. 그런데 이것은 Spring 내부에서 진행되므로, 우리가 API로 전달되는 파라미터인 SignUpRequest를 조작할 수 없다. 그래서 SignUpRequest 클래스 타입이라면 어떠한 객체도 처리할 수 있도록 any()가 사용되었다. any()를 사용할 때에는 특정 클래스의 타입을 지정해주는 것이 좋다. https://mangkyu.tistory.com/145
  1. LocalDateTime 형식 트러블슈팅

    Response content expected:<{"postId":1,"memberId":1,"nickName":"nickName","title":"title","content":"content","createdAt":[2023,3,28,15,13,39,577115000]}> but was:<{"postId":1,"memberId":1,"nickName":"nickName","title":"title","content":"content","createdAt":"2023-03-28T15:13:39.577115"}>
    Expected :{"postId":1,"memberId":1,"nickName":"nickName","title":"title","content":"content","createdAt":[2023,3,28,15,13,39,577115000]}
    Actual   :{"postId":1,"memberId":1,"nickName":"nickName","title":"title","content":"content","createdAt":"2023-03-28T15:13:39.577115"}
    objectMapper.registerModule(new JavaTimeModule()); //LocalDateTime
    objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    • expected와 actual 날짜 형식이 다르게 나와 맞춰주기 위해 위와 같은 코드를 추가해주었습니다.

검증 방법

  1. 1) 실제 Json Response와 objectMapper를 이용해 만들어놓은 expected Json Response을 직접 비교해 검증하는 방법

     .andExpect(content().json(responseJson)) 
  1. 2) 각 Parameter 별로 비교해 검증하는 방법

      .andExpect(jsonPath("$.postId").exists()) //9: check each parameter
      .andExpect(jsonPath("$.memberId").value(memberId))
      .andExpect(jsonPath("$.nickName").value(response.getNickName()))
      .andExpect(jsonPath("$.title").value(response.getTitle()))
      .andExpect(jsonPath("$.content").value(response.getContent()))
      .andExpect(jsonPath("$.createdAt").exists())

8, 9번중 하나만 써도 되지만 교차 검증을 위해 둘 다 해주었습니다.


PostServiceTest

서비스 계층의 메소드를(게시물 생성) 테스트하기 위해선 다음과 같은 Service 클래스가 필요합니다.

@Service
@Transactional(readOnly = true)
@AllArgsConstructor
public class PostService {

    private final PostRepository postRepository;
    private final MemberRepository memberRepository;

    @Transactional //테스트 할 게시물 생성 메소드
    public CreatePostResponse createPost(CreatePostRequest request, Long memberId) {
        Member member = getMember(memberId);
        Post savedPost = postRepository.save(Post.createPost(request, member));

        return CreatePostResponse.builder()
                .postId(savedPost.getId())
                .memberId(savedPost.getMember().getId())
                .nickName(savedPost.getMember().getNickname())
                .title(savedPost.getTitle())
                .content(savedPost.getContent())
                .createdAt(savedPost.getCreatedAt())
                .build();
    }

이제 ServiceTest를 작성 해보겠습니다.

@SpringBootTest //1
@Transactional //2
@ExtendWith(MockitoExtension.class) //3
public class PostServiceTest {

    @Autowired //4
    private PostService postService; 
    @MockBean
    private PostRepository postRepository; //5
    @MockBean
    private MemberRepository memberRepository; //5
	  
    private Member member;

    @BeforeEach
    @Test
    void setUp() { //6
        member = Member.createTestMember(createMemberRequest()); //7. set memberId=1L
    }
}
  1. @SpringBootTest : 스프링 부트 띄우고 테스트(이게 없으면 @Autowired 다 실패)
  2. @Transactional : 반복 가능한 테스트 지원, 각각의 테스트를 실행할 때마다 트랜잭션을 시작하고 테스트가 끝나면 트랜잭션을 강제로 롤백 (이 어노테이션이 테스트 케이스에서 사용될 때만 롤백)
  3. JUnit5와 Mockito를 연동하기 위해 적어줍니다.
  4. 실제 postService 코드를 테스트하기 위해 PostService를 주입받아줍니다.
  5. PostRepository와 MemberRepository로부터 독립적인 테스트를 작성하기 위해 @MockBean으로 각 repository 메소드의 행동을 정의해주도록 합니다.
  6. @BeforeEach: 현재 테스트 class에서 @BeforeEach가 붙어있는 메소드를 각 @Test를 실행하기 전에 호출해주기 위함입니다. (the annotated method should be executed before each @Test).
  7. Post 관련 메소드를 테스트하기 위해 필요한 member 객체를 각 테스트가 실행되기 전에 만들어줍니다.
    아래 메소드를 통해 memberId 가 1L 인 member 객체를 만듭니다.
public static Member createTestMember(CreateMemberRequest request) {
        Member member = new Member();
        member.setId(1L);
        member.setNickname(request.getNickName());
        member.setEmail(request.getEmail());
        member.setPassword(request.getPwd());
        member.setCreatedAt(LocalDateTime.now());
        member.setUpdatedAt(LocalDateTime.now());
        member.setStatus(Status.ACTIVE);
        member.setRole(request.getRole());
        return member;
    }
static CreateMemberRequest createMemberRequest() {
    return CreateMemberRequest.builder()
            .nickName("nickName")
            .email("email")
            .pwd("pwd")
            .role(MemberRole.MENTEE)
            .build();
}

이제 게시물 생성 테스트코드를 보겠습니다.

@Test
void createPost() {
  //given
  CreatePostRequest request = createPostRequest();
  Post expectedPost = Post.createTestPost(request, member);
  
  when(memberRepository.findById(member.getId())).thenReturn(Optional.of(member)); //mock postRepository.findById()
  when(postRepository.save(any(Post.class))).thenReturn(expectedPost); //mock postRepository.save()

  //when
  CreatePostResponse response = postService.createPost(request, member.getId());

  //then
  assertEquals(1L, response.getPostId());
  assertEquals(member.getId(), response.getMemberId()); 
  assertEquals(member.getNickname(), response.getNickName());
  assertEquals(request.getTitle(), response.getTitle());
  assertEquals(request.getContent(), response.getContent());

  verify(memberRepository).findById(member.getId());
  verify(postRepository).save(any(Post.class));
}

given

  • Mockito의 when() 메소드를 이용해 memberRepository.findById() 와 postRepository.save()를 stub해줍니다. 이 두 메소드들은 postService.createPost() 메소드에 쓰입니다.
  • memberRepository의 findById 메소드에 파라미터로 1L(member.getId())을 입력하여 호출하면, 미리 만들어 정의해두었던 member객체가 리턴됩니다. postRepository.save()도 마찬가지로 미리 만들어둔 Post 객체를 리턴하도록 정의해줍니다.

when

  • 앞서 만들었던 request와 member.getId() (1L) 을 인자로 createPost 메소드를 실행합니다.
  • postService의 게시물 생성 응답값이 잘 반환되는지 request의 필드들과 비교해 검증해주었습니다.

then

  • mockito 의 verify() 를 사용해 만들어진 가짜 객체의 특성 메소드가 호출된 횟수를 검증할 수 있습니다.
  • memberRepository의 findById 와 postRepository의 save 메소드가 1번씩 잘 호출되었는지 검증하기 위해 사용하였습니다.

PostRepositoryTest

마지막으로, repository 계층 단위 테스트를 작성해보겠습니다.

@DataJpaTest //1
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) //2
public class PostRepositoryTest {

    @Autowired
    private PostRepository postRepository;
    private Member member;

    @BeforeEach
    @Test
    void createMember() { //3
        member = Member.createTestMember(createMemberRequest());
    }
}
  1. @DataJpaTest
  • JPA Repository들에 대한 빈들을 등록하여 단위 테스트의 작성을 용이하게 함

스프링 부트는 JPA 레포지토리를 손쉽게 테스트할 수 있는 @DataJpaTest 어노테이션을 제공하고 있습니다. @DataJpaTest를 사용하면 기본적으로 인메모리 데이터베이스인 H2를 기반으로 테스트용 데이터베이스를 구축하며, 테스트가 끝나면 트랜잭션 롤백을 해줍니다.

레포지토리 계층은 실제 DB와 통신없이 단순 모킹하는 것은 의미가 없으므로 직접 데이터베이스와 통신하는 @DataJpaTest를 사용하였습니다.

Repository 타입의 빈을 등록하기 위해서는 @Repository 어노테이션을 붙여주어야 한다. 하지만 JpaRepository 하위에 @Repository가 이미 붙어있으므로 @Repository를 붙여주지 않아도 된다.

또한 테스트를 위해서는 테스트 컨텍스트를 잡아주어야 할텐데, @DataJpaTest는 내부적으로 @ExtendWith( SpringExtension.class) 어노테이션을 가지고 있어서, 이 어노테이션만 붙여주면 된다.

마찬가지로 @DataJpaTest에는 @Transactional 어노테이션이 있어서, 테스트의 롤백 등을 위해 별도로 트랜잭션 어노테이션을 추가하지 않아도 된다.

  1. @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)

만일 h2가 아닌 실제 DB(MySql)와 연결해 테스트를 해보고싶다면 위 어노테이션을 추가해주어야 합니다.

  1. 마찬가지로 Post에 필요한 Member를 만들어줍니다.

이제 유닛 테스트 코드를 보겠습니다.

@Test
@DisplayName("게시물 저장")
void savePost() {
    //given
    Member member = Member.createTestMember(createMemberRequest()); //memberId = 1L
    Post post = Post.createPost(createPostRequest(), member);

    //when
    Post savedPost = postRepository.save(post);

    //then
		assertNotNull(savedPost);
		assertNotNull(savedPost.getId());
		assertEquals(post.getMember(), savedPost.getMember());
		assertEquals(post.getTitle(), savedPost.getTitle());
		assertEquals(post.getContent(), savedPost.getContent());
		assertEquals(post.getCategory(), savedPost.getCategory());
		assertNotNull(savedPost.getCreatedAt());
		assertEquals(member, savedPost.getMember());
}

given

  • 저장할 member와 post 엔티티를 만들어줍니다.

when

  • 실제 postRepository의 save 메소드를 실행합니다.

then

  • save 메소드로 리턴된 객체인 savedPost 와 savedPost의 id가 null이 아님으로 결과 검증을 해주었습니다.
  • 미리 만들어두었던 post와 리턴값인 savedPost의 각 필드가 동일한지 검증해줍니다.

Conclusion

Spring Boot + JUnit5 + Mockito + MockMvc 로 각 계층별 Unit test를 작성해보았습니다.

앞으로 밍글에서도 새로운 기능을 도입할 때 단위 테스트, 그리고 여유가 된다면 꼭 TDD로 개발을 하는 습관을 들여야겠다고 생각한 계기가 되었습니다.

출처

1개의 댓글

comment-user-thumbnail
2024년 3월 19일

좋은 글 잘 보고 갑니다!

답글 달기