[Spring] JUnit & Mockito 기반 Spring 단위 테스트 코드 작성

Gogh·2023년 1월 2일
16

Spring

목록 보기
17/23

🎯 목표 : Spring 의 단위 테스트 작성의 필요성 이해와 테스트 코드 작성

📒 단위 테스트와 통합 테스트


📌 단위 테스트

  • 단위테스트는 하나의 모듈을 기준으로 독립적으로 진행되는 가장 작은 단위의 테스트다.
  • 하나의 모듈이란 각 계층에서의 하나의 기능 또는 메소드로 이해할 수 있다.
  • 하나의 기능이 올바르게 동작하는지를 독립적으로 테스트하는 것이다.

📌 단위 테스트의 필요성

  • 일반적으로 테스트 코드를 작성한다고 하면 거의 단위 테스트를 의미한다.
  • 통합 테스트는 실제 여러 컴포넌트들 간의 상호작용을 테스트 하기 때문에 모든 컴포넌트들이 구동된 상태에서 테스트를 하게 되므로, 캐시나 데이터베이스 등 다른 컴포넌트들과 실제 연결을 해야하고 어플리케이션을 구성하는 컴포넌트들이 많아 질수록 테스트를 위한 시간이 커진다.
  • 하지만, 단위 테스트는 테스트하고자 하는 부분만 독립적으로 테스트를 하기 때문에 해당 단위를 유지 보수 또는 리팩토링 하더라도 빠르게 문제 여부를 확인 할 수 있다.

📌 단위 테스트의 한계

  • 일반적으로 어플리케이션은 하나의 기능을 처리하기 위해 다른 객체들과 데이터를 주고 받는 복잡한 통신이 일어난다.
  • 단위 테스트는 해당 기능에 대한 독립적인 테스트기 때문에 다른 객체와 데이터를 주고 받는 경우에 문제가 발생한다.
  • 그래서, 이 문제를 해결하기 위해 테스트하고자 하는 기능과 연관된 모듈에서 가짜 데이터, 정해진 반환값이 필요하다.
  • 즉 단위 테스트에서는, 테스트 하고자 하는 기능과 연관된 다른 모듈은 연결이 단절 되어야 비로소 독립적인 단위 테스트가 가능해 진다.

📌 단위 테스트의 특징

  • 좋은 테스트 코드란, 계속해서 변하는 요구사항에 맞춰 변경된 코드는 버그의 가능성을 항상 내포하고 있으며, 이를 테스트 코드로 검증함으로써 해결할 수 있어야 한다.
  • 실제 코드가 변경되면 테스트 코드도 변경이 필요할 수 있으며, 테스트 코드 역시 가독성 있게 작성하여 일관된 규칙과 일관된 목적으로 테스트 코드를 작성 해야한다.
  • FIRST 규칙
    • Fast : 테스트는 빠르게 동작하고 자주 가동 해야한다.
    • Independent : 각각의 테스트는 독립적어이야 하며, 서로에 대한 의존성은 없어야 한다.
    • Repeatable : 어느 환경에서도 반복이 가능해야 한다.
    • Self-Validating : 테스트는 성공 또는 실패 값으로 결과를 내어 자체적으로 검증 되어야 한다.
    • Timely : 테스트는 테스트 하려는 실제 코드를 구현하기 직전에 구현 해야한다.

📌 통합 테스트

  • 모듈을 통합하는 과정에서 모듈 간 호환성을 확인하기 위한 테스트다.
  • 다른 객체들과 데이터를 주고받으며 복잡한 기능이 수행 될때, 연관된 객체들과 올바르게 동작하는지 검증하고자 하는 테스트다.
  • 독립적인 기능보다 전체적인 연관 기능과 웹 페이지로 부터 API를 호출하여 올바르게 동작하는지 확인한다.

📒 단위 테스트 작성


📌 테스트 코드 작성 공통 준수 사항

  • 보통 테스트를 위한 라이브러리로 JUnit과 AssertJ 조합을 사용하여 테스트를 한다.
  • Given/When/Then 패턴
    • Given : 어떠한 데이터가 주어질 때.
    • When : 어떠한 기능을 실행하면.
    • Then : 어떠한 결과를 기대한다.
  @Test
  @DisplayName("Test")
  void test() {
      // Given

      // When

      // Then
  }

📌 Mockito를 사용한 단위 테스트

  • 모키토는, 개발자가 동작을 직접적으로 제어할 수 있는 가짜 객체를 지원하는 테스트 프레임웍이다.
  • Spring 어플리케이션은 여러 객체들 간의 의존성이 생기는데 이러한 의존성을 모키토를 이용하여 단절 시킴으로 단위 테스트를 쉽게 작성하는 것을 도와준다.
  • 앞으로 작성할 예제 테스트 에서는 모키토와 JUnit5의 조합으로 테스트 코드를 작성할 것 이다.
// MemberController.java
@RestController
@RequestMapping("/members")
@Validated
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;
    private final MemberMapper mapper;

    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberDto.Post requestBody) {
        Member member = mapper.memberPostToMember(requestBody);
        member.setStamp(new Stamp());

        Member createdMember = memberService.createMember(member);
        MemberDto.response response = mapper.memberToMemberResponse(createdMember);
        return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.CREATED);
    }
}

//MemberService.java
@Transactional
@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    public Member createMember(Member member) {
        Member savedMember = memberRepository.save(member);
        verifyMemberByEmail(member.getEmail());
        return savedMember;
    }
    private void verifyMemberByEmail(String email) {
      Optional<Member> findMember = memberRepository.findByEmail(email);
      if (findMember.isPresent())
        throw new ServiceLogicException(ErrorCode.MEMBER_EXISTS);
    }
}

//MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email);
}
  • 위 코드의 컨트롤러와 서비스, 레포지토리 테스트 한다고 가정한다.

📌 컨트롤러 계층 단위 테스트

  • 컨트롤러의 단위 테스트를 하기 위해서 Mockito를 이용하여 다른 계층과 의존관계를 단절 시켜 주어야한다.
  • 컨트롤러가 의존하고 있는 객체는 MemberServiceMemberMapper 객체다.
  • 컨트롤러를 테스트 하기 위해서는 HTTP 호출이 필요하다. 스프링 부트는 컨트롤러 테스트를 위한 @WebMvcTest 어노테이션을 제공한다.
  • 이를 이용하면 MockMvc 객체가 자동으로 생성될 뿐만 아니라 테스트에 필요한 요소들을 빈으로 등록해 스프링 컨텍스트 환경을 구성 해 준다.
@WebMvcTest(MemberController.class)
@MockBean(JpaMetamodelMappingContext.class)
public class MemberControllerRestDocsTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private MemberService memberService;

    @MockBean
    private MemberMapper mapper;

    @Autowired
    private Gson gson;

    @Test
    @DisplayName("POST 회원 등록 컨트롤러 로직 확인")
    public void postMemberTest() throws Exception {
        // Given
        MemberDto.Post post = new MemberDto.Post("hgd@gmail.com", "홍길동", "010-1234-5678");
        String content = gson.toJson(post);

        MemberDto.response responseDto =
                new MemberDto.response(1L,
                        "hgd@gmail.com",
                        "홍길동",
                        "010-1234-5678",
                        Member.MemberStatus.MEMBER_ACTIVE,
                        new Stamp());

        given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class))).willReturn(new Member());

        given(memberService.createMember(Mockito.any(Member.class))).willReturn(new Member());

        given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);

        // When
        ResultActions actions =
                mockMvc.perform(
                        post("/members")
                                .accept(MediaType.APPLICATION_JSON)
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(content)
                );

        // Then
        actions
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.data.email").value(post.getEmail()))
                .andExpect(jsonPath("$.data.name").value(post.getName()))
                .andExpect(jsonPath("$.data.phone").value(post.getPhone()));
    }
}
  • 컨트롤러는 MemberServiceMemberMapper 객체에 의존하고 있기 때문에 @MockBean 어노테이션을 사용하여 가짜 객체를 생성해준다.
    @MockBean
    private MemberService memberService;

    @MockBean
    private MemberMapper mapper;
  • 컨트롤러의 로직에서 의존하고 있는 객체의 사용되는 메소드들의 반환값을 지정해 준다.
    given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class))).willReturn(new Member());

    given(memberService.createMember(Mockito.any(Member.class))).willReturn(new Member());

    given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);
  • given(의존관계에 있는 객체의 메소드(파라미터)).willReturn(반환 값)
    • 어떠한 파라미터가 의존관계에 있는 객체의 메소드에 삽입되더라도 항상 반환값은 지정한대로 같은 값만 반환되어 나온다 라는 설정을 해 줌으로, 컨트롤러가 의존하고 있는 객체에 대한 의존성을 테스트 코드에서 단절 시켜주었다.
  • 해당 컨트롤러는 Json 데이터 형태로 요청받아 Json 데이터 형태로 반환한다.
  • 객체를 Json 데이터로 변환 해 주기 위해 Gson 라이브러리를 사용하였다.
  • When 단계에서 mockMvc에 데이터와 함께 POST 요청을 보내야 하는데, 요청정보는 MockMvcperform() 메소드로 작성 가능하다.
  • 요청 정보에는 MockMvcRequestBuilders가 사용되며 메소드 종류 요청 및 응답 데이터 타입, 바디의 데이터 등을 설정할 수 있다.
  • post(),accept(),contentType(),content()메소드 모두 MockMvcRequestBuilders 클래스의 메소드다.
  • When 단계에서 요청에 따른 응답 데이터는 ResultActions 인터페이스 타입으로 반환되며 이를 이용하여, 응답 데이터에 대한 검증을 할 수 있다.
  • Then 단계의 status().isCreated()는 컨트롤러의 단위 테스트 대상 메소드가 응답으로 HttpStatus.CREATED를 반환하기 때문에 응답 코드를 검증하기 위한 메소드다.
  • Then 단계에서 응답 데이터 타입은 Json이다, jsonPath()를 활용하여 실제 테스트에서 응답하는 데이터와 기대하는 응답 데이터 값을 검증 할 수 있다.

📌 서비스 계층 단위 테스트

  • 예제 서비스 코드에서 MemberServiceMemberRepository에 의존 하고 있다.
  • 서비스 계층은 HTTP 호출과 상관 없으며 단순한 로직 검증만 하면 된다.
  • Repository에 저장하는 로직은 단순하기 때문에 입력된 회원 정보가 중복 될시 예외가 발생하는 경우를 테스트 하였다.
@ExtendWith(MockitoExtension.class)
class MemberServiceTest {
    @Mock
    private MemberRepository memberRepository;

    @InjectMocks
    private MemberService memberService;

    @Test
    @DisplayName("중복 회원 생성시 예외 발생 - 이메일 조회")
    void createMemberException() {
        // Given
        Long memberId = 1L;
        Member testMember = createTestMember(memberId);
        when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(testMember));
      // When
        Throwable throwable = catchThrowable(() -> memberService.createMember(
                Member.builder()
                .email("PreventNull")
                  .build()));
        // Then
        assertThat(throwable)
                .isInstanceOf(ServiceLogicException.class)
                .hasMessageContaining(ErrorCode.MEMBER_EXISTS.getMessage());
    }
}
  • JUnit5와 Mockito를 연동하기 위해서는 @ExtendWith(MockitoExtension.class)를 사용해야한다.
  • 의존성 주입을 위해 MemberRepository@Mock을 이용하여 Mock 객체를 생성해 주고.
  • 생성된 Mock 객체를 MemberService@InjectMocks 어노테이션을 이용하여 주입해 준다.
  • when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(testMember));
    • given().willReturn()과 활용 법은 동일하다.
    • memberRepository.findByEmail(anyString())메소드를 호출할때 어떤 String 데이터를 파라미터로 지정하더라도,
    • 항상 Optional.of(testMember)를 반환하도록 설정하였다.
  • catchThrowable()을 이용하여 실제 테스트할 메소드가 반환할 예외를 Throwable타입으로 할당 해 둔다.
    • catchThrowable()는 AssertJ 라이브러리의 메소드다.
        assertThat(throwable)
                .isInstanceOf(ServiceLogicException.class)
                .hasMessageContaining(ErrorCode.MEMBER_EXISTS.getMessage());
  • throwable의 객체를 isInstanceOf()를 이용하여 ServiceLogicException.class로 형 변환 되는지 확인 해 보면, 동일한 객체인지 검증되며,
  • 해당 객체에 hasMessageContaining()를 이용하여 기대하는 메세지를 포함하고 있는지 검증 할 수 있다.

📌 레포지토리 계층 단위 테스트

  • @DataJpaTest 어노테이션은, 스프링 부트에서 JPA 레포지토리를 쉽게 테스트 할수 있게 지원 한다.
  • 해당 프로젝트에서는 H2 인메모리 DB를 구축해 놓았고, @DataJpaTest 어노테이션은 기본적으로 H2를 기반으로 테스트하며, 테스트가 끝나면 트랜잭션 롤백을 한다.
  • 실제 DB와 통신을 하지 않으면 테스트의 의미가 없으므로, 목킹은 하지 않는다.
@DataJpaTest
public class MemberRepositoryTest {
    @Autowired
    private MemberRepository memberRepository;

    @Test
    public void saveMemberTest() {
        // given
        Member member = new Member();
        member.setEmail("hgd@gmail.com");
        member.setName("홍길동");
        member.setPhone("010-1111-2222");

        // when
        Member savedMember = memberRepository.save(member);

        // then
        assertNotNull(savedMember);
        assertTrue(member.getEmail().equals(savedMember.getEmail()));
        assertTrue(member.getName().equals(savedMember.getName()));
        assertTrue(member.getPhone().equals(savedMember.getPhone()));
    }
}
  • 실제로 DB에 Stub 데이터를 저장하고 저장되어 반환된 데이터가 기대값과 같은지 검증한다.
profile
컴퓨터가 할일은 컴퓨터가

1개의 댓글

comment-user-thumbnail
2023년 6월 26일

덕분에 잘 테스트했습니다 ! 감사해요

답글 달기