본문 바로가기

Proj/Tripot

스프링부트 테스트 코드 작성하기: Repository, Service

1. 개요

지금까지 회원 관련 MVP를 구현해보았다. 해당 게시글에서는 현재까지 구현한 기능 중 회원 기능에 대한 테스트코드를 작성해보고자 한다. Repository의 테스트코드가 짧으므로 서비스 계층과 묶었다.

2. Repository 계층 테스트

@EnableJpaAuditing
@TestConfiguration
@EnableJpaRepositories(basePackages = "com.junior")
//@EntityScan(basePackages = "com.junior.dto")
public class TestConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

Repository 계층에서 사용한 설정 클래스이다. 해당 프로젝트에서는 querydsl을 사용함에 따라 jpaQueryFactory를 빈으로 등록하였다.

@DataJpaTest
@Import(TestConfig.class)
class MemberRepositoryTest {

    @Autowired
    private MemberRepository memberRepository;

    @BeforeEach
    void init() {

        Member testMember = Member.builder().nickname("테스트닉")
                .username("KAKAO 3748293466")
                .role(MemberRole.USER)
                .status(MemberStatus.ACTIVE)
                .signUpType(SignUpType.KAKAO)
                .recommendLocation("서울")
                .build();

        memberRepository.save(testMember);
    }

    @AfterEach
    void clean(){
        memberRepository.deleteAll();
    }

    @Test
    @DisplayName("정확히 동일한 username을 가진 회원이 있을 때에만 true를 반환")
    void existsByUsername() {

        boolean findExistUsername = memberRepository.existsByUsername("KAKAO 3748293466");
        boolean findNotExistUsername = memberRepository.existsByUsername("KAKAO 3748293465");

        assertThat(findExistUsername).isTrue();
        assertThat(findNotExistUsername).isFalse();
    }

    
    
   ...
}

테스트 코드이다. 전반적으로 비슷한 로직이 들어감에 따라 그 중 일부를 발췌하였다. 하나씩 살펴보자.

@DataJpaTest
@Import(TestConfig.class)
class MemberRepositoryTest
  • @DataJpaTest: Jpa 관련 빈 및 설정들을 자동으로 등록해준다.
  • @Import(TestConfig.class): 앞서 설정한 설정 파일을 import한다.
    @Autowired
    private MemberRepository memberRepository;

    @BeforeEach
    void init() {

        Member testMember = Member.builder().nickname("테스트닉")
                .username("KAKAO 3748293466")
                .role(MemberRole.USER)
                .status(MemberStatus.ACTIVE)
                .signUpType(SignUpType.KAKAO)
                .recommendLocation("서울")
                .build();

        memberRepository.save(testMember);
    }

    @AfterEach
    void clean(){
        memberRepository.deleteAll();
    }

 해당 테스트는 memberRepository가 실제로 작동하는 지 테스트해야 하므로 Autowired를 사용하여 DI를 해준다. 각 테스트 이전에 테스트용 회원 정보를 하나 집어넣고, 테스트가 끝나면 해당 db를 비운다.

    @Test
    @DisplayName("정확히 동일한 username을 가진 회원이 있을 때에만 true를 반환")
    void existsByUsername() {

        boolean findExistUsername = memberRepository.existsByUsername("KAKAO 3748293466");
        boolean findNotExistUsername = memberRepository.existsByUsername("KAKAO 3748293465");

        assertThat(findExistUsername).isTrue();
        assertThat(findNotExistUsername).isFalse();
    }

현재 Kakao 3748293466이라는 username의 회원이 init()을 통해 들어가있다. 이와 동일한 username을 찾으면 true, 이와 다른 username을 가지고 같은 메서드를 사용하면 false가 반환될 것이다. 해당 기능이 잘 동작하는지 검증한다.

3. Service 계층

여기서부터 본격적으로 mockito를 사용하여 테스트를 진행하였다. 그 과정을 알아보자. 해당 게시글에서는 테스트코드 전문을 게시하지 않았다.

@ExtendWith(MockitoExtension.class)
class MemberServiceTest

해당 테스트를 위해 스프링부트 애플리케이션을 실행시키게 되면 사용하지 않을 수 많은 빈을 등록하게 되어 성능이 상대적으로 떨어진다. 해당 테스트는 단위 테스트에 해당하므로 repository 등을 모두 실행시킬 필요는 없다. 따라서 해당 애노테이션을 붙여 mockito를 사용하도록 하자.

    @Mock
    MemberRepository memberRepository;

    @Mock
    S3Service s3Service;

    @InjectMocks
    MemberService memberService;

memberService는 memberRepository, s3Service를 주입받아 사용한다. 하지만 단위 테스트에서 해당 클래스의 기능들이 제대로 동작하는 지 여부는 중요하지 않다. 따라서 해당 클래스들은 가짜 객체인 mock으로 선언, memberService에 InjectMock을 붙여 해당 객체들을 주입받는다.

 요약하자면 다음과 같다.

  • @Mock: 해당 클래스 및 인터페이스의 mock 객체(가짜 객체)를 생성한다.
  • @InjectMocks: @Mock이 붙은 객체를 자동으로 주입받게 된다.

이제 테스트코드를 살펴보자.

 

    @Test
    @DisplayName("프로필 사진 변경이 정상적으로 이루어져야 함")
    void updateProfileImage_success() {

        //given
        Member testMember = createActiveTestMember();
        UserPrincipal principal = new UserPrincipal(testMember);
        MultipartFile profileImage = createMockMultipartFile();

        given(memberRepository.findById(2L)).willReturn(Optional.ofNullable(testMember));
        given(s3Service.saveProfileImage(profileImage)).willReturn("s3.com/newProfile");

        //when
        memberService.updateProfileImage(principal, profileImage);

        //then
        Member updatedMember = memberRepository.findById(2L).get();
        assertThat(updatedMember.getProfileImage()).isEqualTo("s3.com/newProfile");
    }

해당 코드는 프로필 사진 변경 관련 비즈니스 로직 테스트코드이다. 

        //given
        Member testMember = createActiveTestMember();
        UserPrincipal principal = new UserPrincipal(testMember);
        MultipartFile profileImage = createMockMultipartFile();

 

given 파트이다. 우선 회원을 하나 만들고, 이를 담는 UserDetails를 하나 만들었다. 서비스의 단위 테스트이므로 굳이 securityContext에 UserDetails를 저장하고, 다시 꺼내오는 것이 맞지 않다는 생각이 들어 위와 같이 구현하였다. 또, 사진 변경에 사용할 프로필 사진을 하나 만들었다.

    MockMultipartFile createMockMultipartFile() {
        MockMultipartFile profileImg = new MockMultipartFile(
                "프로필 사진",
                "profiles.png",
                MediaType.IMAGE_PNG_VALUE,
                "thumbnail".getBytes()
        );

        return profileImg;

    }

해당 메서드이다. 테스트에서는 MockMultipartFile을 만들어 가짜 파일을 만들어 사용할 수 있다.

 

        given(memberRepository.findById(2L)).willReturn(Optional.ofNullable(testMember));
        given(s3Service.saveProfileImage(profileImage)).willReturn("s3.com/newProfile");

memberRepository, s3Service는 가짜 객체이므로 어떠한 작업도 수행하지 않고 null을 리턴하게 된다. 해당 로직이 잘 작동된다는 하에 프로필 사진 수정에 사용되는 메서드의 리턴 값을 직접 정의해주어야 한다. 

  • memberRepository.findById(2L)을 수행하면 Optional.ofNullable(testMember)가 리턴된다.
  • s3Service.saveProfileImage(profileImage)를 수행하면 해당 프로필 사진의 작동하지 않는 임시 경로가 리턴된다.
        memberService.updateProfileImage(principal, profileImage);

when 파트이다. 해당 메서드를 실행하게 되면 해당 로직에서 위에서 정의한 리턴값을 가지고 서비스 계층의 로직을 수행하게 된다.

        //then
        Member updatedMember = memberRepository.findById(2L).get();
        assertThat(updatedMember.getProfileImage()).isEqualTo("s3.com/newProfile");

then 파트이다. 위에서 정의한 데에 추가로 get()을 사용하여 Member 객체를 꺼내주었다. 이제 수정된 프로필 사진 경로가 해당 객체에 들어갔는 지 확인하면 된다.

테스트가 성공한 것을 확인할 수 있었다.

    @Test
    @DisplayName("ACTIVE 상태가 아닌 회원은 정보 조회를 할 수 없음")
    void updateProfileImage_fail() {


        //given
        Member testMember = createPreactiveTestMember();
        UserPrincipal principal = new UserPrincipal(testMember);
        given(memberRepository.findById(1L)).willReturn(Optional.ofNullable(testMember));
        MultipartFile profileImage = createMockMultipartFile();


        //when, then
        Assertions.assertThatThrownBy(() -> memberService.updateProfileImage(principal, profileImage)).isInstanceOf(NotValidMemberException.class);


    }

회원 상태에 따라 의도한 예외가 발생하는 지 테스트하는 코드이다. PREACTIVE 상태의 회원을 가지고 로직을 수행하게 되면 NotValidMemberException이 발생하는 지 확인하게 된다.

 

4. 이후..

다음은 컨트롤러 계층이다. 여기서부터는 요청을 보내어 응답을 받는 테스트이므로 현재 사용하는 스프링 시큐리티에 의거, securityContext에 principal을 집어넣어야 한다. 다음 게시글에서는 이를 해 볼 것이다.