우리 FISA

우리FISA 클라우드 서비스 개발 - Spring Framework에서 테스트

dvid 2023. 8. 6. 23:37

프로젝트 요구사항에 단위 테스트에 대한 요구사항이 있었다.

오늘 팀원들과 함께 스프링 부트로 만든 게시판 예제를 활용해서 단위테스트를 정리해 봤다.

스프링의 비즈니스 로직은 크게 Controller, Service, Repository에 존재하기 때문에 이 세 가지에 대한 단위 테스트를 정리하려고 한다.

테스트 환경

  • Java 11
  • Spring boot 2.7.13
  • Spring Data JPA
  • JUnit 5.8.2
  • H2 2.1.214

Repository

우리 팀은 JPA를 사용할 예정이기 때문에 @DataJpaTest를 사용해서 테스트를 하려고 한다.

아직 복잡한 비즈니스로직이 없기 때문에 간단하게 하겠다.

 

import static org.assertj.core.api.Assertions.*;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

@DataJpaTest
class PostRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Autowired
    PostRepository postRepository;

    @DisplayName("Post 저장")
    @Test
    void testFindById() {
        Member member = new Member("name", "email", "password");

        memberRepository.save(member);

        Post post = new Post("title", "content", member);

        Post save = postRepository.save(post);

        assertThat(save.getId()).isEqualTo(post.getId());
    }

}

Post를 save 하고 다시 조회하는 로직이다. Post는 Member의 외래키를 가지고 있기 때문에 영속성 컨텍스트에서 관리되는 Member를 넣어줘야 하기 때문에 우선 DB에 저장하고, Post 생성 시 다시 넣어줬다.

 

결과를 보면 Member save 시 한 번, Post save 시 한 번 총 2회 insert query가 발생했다. 또한, 테스트가 종료되고 Rollback이 되어 DB가 초기화되었다.

 

@DataJpaTest@Transactional가 함께 있기 때문에 정상적으로 Rollback이 되었다.

MySQL 같은 경우에는 auto-increment 속성 추가 시 Rollback 되어도 이때 만들어진 PK값 이후로 다시 만들어지니 조심해야 한다.

 

예를 들어 이번 테스트에서 생성된 post의 ID가 2라고 할 때, Rollback 되었다고 해서 운영환경에서 다음 post의 ID가 2가 아닌 3부터 채워지므로 테스트에 하드코딩된 ID를 넣어뒀다면 추후에 오류가 생길 수 있다는 점을 조심해야 한다.

Service

Production Code

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

    private final PostRepository postRepository;
    private final MemberRepository memberRepository;

    public PostServiceImpl(PostRepository postRepository, MemberRepository memberRepository) {
        this.postRepository = postRepository;
        this.memberRepository = memberRepository;
    }

    @Transactional
    @Override
    public PostResponse createPost(
        final MemberSession memberSession,
        final PostRequest postRequest) {

        Member member = memberRepository.findById(memberSession.getId())
            .orElseThrow(MemberNotFoundException::new);

        Post post = Post.builder()
            .member(member)
            .title(postRequest.getTitle())
            .content(postRequest.getContent())
            .build();

        Post savedPost = postRepository.save(post);

        return PostResponse.fromEntity(savedPost);
    }
}

Test Code

import static org.assertj.core.api.Assertions.*;
import static org.mockito.BDDMockito.*;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class PostServiceImplTest {

    @Mock
    PostRepository postRepository;

    @Mock
    MemberRepository memberRepository;

    @InjectMocks
    PostServiceImpl postService;

    @DisplayName("게시물 생성")
    @Test
    void testCreatePost() {
        Long id = 1L;
        String title = "타이틀";
        String content = "컨텐트";

        MemberSession memberSession = mock(MemberSession.class);
        PostRequest postRequest = mock(PostRequest.class);

        Member member = mock(Member.class);

        given(memberSession.getId()).willReturn(id);
        given(postRequest.getTitle()).willReturn(title);
        given(postRequest.getContent()).willReturn(content);
        given(memberRepository.findById(id)).willReturn(Optional.of(member));

        Post post = Post.builder()
            .member(member)
            .title(postRequest.getTitle())
            .content(postRequest.getContent())
            .build();

        given(postRepository.save(any(Post.class))).willReturn(post);

        PostResponse resp = postService.createPost(memberSession, postRequest);

        assertThat(resp.getTitle()).isEqualTo(title);
        assertThat(resp.getContent()).isEqualTo(content);

        then(memberRepository).should(times(1)).findById(id);
        then(postRepository).should(times(1)).save(any(Post.class));
    }

}

JUnitMockito를 연동할 때 필요한 @ExtendWith(MockitoExtension.class)를 클래스 레벨에 선언했다.

우선 PostService와 의존관계에 있는 MemberRepository, PostRepository@Mock을 이용해 선언했다.

 

또한, @InjectMocks을 활용해 Mocking 한 의존성을 PostService에 주입하고 본격적으로 테스트 작성을 시작한다.

production code의 createPost 메서드의 파라미터를 mocking을 하고 given 메서드를 활용해 행위를 설정해 준다.

 

또한, mocking 된 의존성들인 memberRepositorypostRepository 또한 행위를 설정해 준 후에 테스트하려는 createPost를 동작시킨다.

 

우리가 설정한 값으로 확인할 수 있는 title, content를 assertThat을 활용해 확인하고 mocking 한 의존성 클래스들이 동작했는지 then 메서드를 활용해서 확인했다.

 

Service 클래스의 단위 테스트는 따로 DB를 활용하지 않았기 때문에 테스트를 성공한다면 콘솔에 따로 뜨는 것은 없다.

Controller

Production Code

@RestController
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @PostMapping("/signup")
    public ResponseEntity<CommonResponse<MemberResponse>> signup(@RequestBody @Valid SignupRequest signupRequest) {

        MemberResponse signup = memberService.signup(signupRequest);

        return CommonResponse.success(HttpStatus.OK, 200, signup);
    }

}

Test Code


import static java.nio.charset.StandardCharsets.*;
import static org.hamcrest.Matchers.*;
import static org.mockito.BDDMockito.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.times;
import static org.springframework.http.MediaType.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

@WebMvcTest(MemberController.class)
class MemberControllerTest {

    MockMvc mockMvc;

    @Autowired
    MemberController memberController;

    @MockBean
    MemberService memberService;

    @Autowired
    ObjectMapper om;

    @BeforeEach
    void setUp() {
        mockMvc = MockMvcBuilders
        .standaloneSetup(memberController)
        .alwaysDo(print())
        .build();
    }

    @DisplayName("회원가입")
    @Test
    void testSignup() throws Exception {

        Long id = 1L;
        String email = "email@email.com";
        String name = "홍길동";
        String password = "password";
        MemberResponse memberResponse = new MemberResponse(id, email, name);
        DummySignupRequest dummySignupRequest = new DummySignupRequest(email, password, name);
        String requestBody = om.writeValueAsString(dummySignupRequest);

        // System.out.println(om.writerWithDefaultPrettyPrinter().writeValueAsString(dummySignupRequest));

        given(memberService.signup(any(SignupRequest.class))).willReturn(memberResponse);

        mockMvc.perform(
                post("/members/signup")
                	.characterEncoding(UTF_8)
                    .contentType(APPLICATION_JSON).
                    content(requestBody))
            // assertThat
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.code", is(HttpStatus.OK.value())))
            .andExpect(jsonPath("$.data.id", is(id), Long.class))
            .andExpect(jsonPath("$.data.email", is(email)));

        then(memberService).should(times(1)).signup(any(SignupRequest.class));
    }

}

 

Controller 테스트는 스프링 프레임워크가 받은 요청을 처리해줘야 해서 Service나 Repository의 테스트와 설정이 조금 다른 것 같다.
@WebMvcTest 내부의 @ExtendWith(SpringExtension.class)는 Spring Context와 JUnit을 사용할 때 필요한 Annotation이라고 한다.

 

또한, @BeforeEach는 추후 테스트 할 메서드가 더 늘어날 때 각각의 테스트 동작 이전에 실행된다.


여기서 나는 mockMvcstandAloneSetup을 활용해 초기화해주었는데, 이 것은 Unit test를 위한 설정이다. 추후 통합 테스트를 하기 위해서는 webAppContextSetup() 메서드를 활용해 초기화해 주자.

 

또한, alwayDo(print())는 요청의 상세 내용과, 응답의 상세 내용을 콘솔에 출력해준다.

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /members/signup
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"68"]
             Body = {"email":"email@email.com","password":"password","name":"홍길동"}
    Session Attrs = {}

Handler:
             Type = com.woorifisa.board.domain.member.controller.MemberController
           Method = com.woorifisa.board.domain.member.controller.MemberController#signup(SignupRequest)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"code":200,"data":{"id":1,"email":"email@email.com","name":"홍길동"}}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

 

스프링이 받는 Http 요청은 문자열로 이루어져 있다. 따라서 Http Body는 JSON 형식의 문자열이다. 이 문자열을 스프링이 파싱 해서 객체로 바꿔주는 것이다.

 

우리가 설정한 DTO는 직렬화를 위한 기본생성자와 getter 메서드만 있다.

 

@NoArgsConstructor
@Getter
public class SignupRequest {

    @Email
    @NotBlank
    private String email;

    @NotBlank
    private String password;

    @NotBlank
    private String name;

}

프로덕션 코드를 해치지 않기 위해 테스트 패키지에 필드명이 같은 더미 클래스를 만들고, 이 클래스를 생성해 스프링의 JSON 관리 클래스인 ObjectMapper로 JSON 형식의 String으로 변환했다.

{
  "email" : "email@email.com",
  "password" : "password",
  "name" : "홍길동"
}

 

주석의 writerWithDefaultPrettyPrinter()를 활용하면 JSON을 포맷팅해서 보여준다. 콘솔에 찍을 때 유용하다.

 

이후에 mockMvcperform메서드를 활용해 요청을 보낸다.

헤더에 인코딩과 request body의 형식을 지정하고, 우리가 String으로 만든 실제 Request Body를 넣어준다.

 

그리고 andExpect를 활용해 asserThat과 같이 우리의 결과를 검증할 수 있다.
간단하게 Http Status와 jsonPath를 활용해 response body를 체크했다.

 

jsonPath의 문법은 다양하기 때문에 필요할 때 찾아 쓰는 것을 추천한다.

andExpect(jsonPath("$.data.id", is(id), Long.class))여기서 Long.class를 추가해 준 이유는 id는 Long타입인데, Long 타입은 L이 뒤에 붙어서 그런지 파라미터에 Long.class 없이 사용 시 오류가 났다.

정리

Spring boot 사용 시 가장 많이 쓰이는 3개의 Layer에 대한 테스트를 간단히 알아봤다.

테스트 할 때 은근 신경쓰이는 것이 어떤 패키지의 메서드를 import 해야하나 인 것 같다.

import 부분도 함께 작성했으니 참고하면 좋을 것 같다.

 

테스트 코드를 작성하는 것이 처음에는 많이 어렵고 어색하겠지만, 본인이 작성한 시나리오를 잘 따라간다면 분명 좋은 테스트 코드를 작성할 수 있을 것이다.

 

내가 짠 코드가 정답이 아닐 수도 있고, 다른 다양한 상황에서 테스트를 작성해야 할 것이다.
하지만 이 예제를 바탕으로 다양한 상황에서 많은 테스트 코드를 작성하고, 더 나아가 TDD에 도전해 보면 좋을 것이다.

 

테스트 코드를 통해 더 안정적인 서버 애플리케이션을 개발하면 좋을 것 같다.