본문 바로가기
우리 FISA

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

by dvid 2023. 8. 6.

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

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

스프링의 비즈니스 로직은 크게 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에 도전해 보면 좋을 것이다.

 

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

댓글