Haneul's Blog

[스케줄 관리 프로젝트 - 일치(BE)] Spring 단위 테스트 본문

일정 관리 프로젝트(일치)

[스케줄 관리 프로젝트 - 일치(BE)] Spring 단위 테스트

haneulss 2023. 4. 20. 22:37

통합 테스트

Spring에서는 @SpringBootTest 애노테이션을 통해 간단하게 통합 테스트를 진행할 수 있습니다.

 

통합 테스트는 모듈을 통합하는 과정에서 모듈 간의 호환성을 확인하기 위해 수행되는 테스트로, 간단하게 테스트를 작성할 수 있는 이점이 있지만 고생을 덜 하고 번거롭지 않은 만큼 치명적인 단점들이 있는데, 당장 테스트 하고 싶은 부분이 있는데 모든 빈을 등록하고 테스트 하기 때문에 비교적 시간이 오래 걸린다는 점입니다.
예를 들어 어떤 한 도메인이나 어떤 한 서비스단 등의 테스트를 해보고 싶은데 통합 테스트를 하게 되면 테스트를 진행하는데 시간이 오래 걸리게 됩니다.

 

또 다른 단점으로는, 어디서 문제가 생겼는지 찾는데 시간이 걸릴 수 있다는 점입니다. 통합 테스트를 진행하여 테스트를 했을 때 모두 통과하면 베스트지만 항상 개발이 완벽하기가 쉽지 않기 때문에 오류가 발생하는 부분을 찾아야 하는데 통합 테스트를 진행하면 그런 부분을 찾는게 비교적 어렵습니다.

 

그렇기 때문에 통합 테스트는 어떤 시나리오대로 흘러가는지를 확인하는데만 사용하는 것이 좋고, 기본적으로는 통합 테스트의 단점을 보완한 단위 테스트를 사용하여 테스트를 하면 좋습니다.

단위 테스트

단위 테스트는 위에서 말했다시피 어떤 한 계층만을 테스트하고 싶을 때 사용하기 때문에 당장 필요한 빈들만 주입받아서 사용을 하게 되어 테스트 속도가 비교적 빠릅니다.

프레젠테이션 계층 단위 테스트

프레젠테이션 계층 테스트를 하기 위해서는 @WebMvcTest에 대한 이해가 있어야 합니다.

@WebMvcTest는 프레젠테이션 계층에 관련된 빈들만 찾아서 빈으로 등록해줍니다.

  • @Controller, @RestController
  • @ControllerAdvice, @RestControllerAdvice
  • @JsonComponent
  • Filter
  • WebMvcConfigurer
  • HandlerMethodArgumentResolver

아래 코드는 제 프로젝트의 실제 코드의 일부입니다.

@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class, AuthService.class, JwtAccessToken.class, JwtRefreshToken.class})
@AutoConfigureMockMvc(addFilters = false)
@WebMvcTest(AuthController.class)
class AuthControllerTest  {

    private static final String REFRESH_TOKEN_COOKIE_NAME = "REFRESH_TOKEN";
    private static final String REFRESH_TOKEN = "refreshToken";

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @Autowired
    AuthService authService;

    @Test
    void 구글_로그인() throws Exception {
        LoginRequest loginRequest = new LoginRequest("code");
        String token = "token";
        Long id = 1L;
        AccessTokenResponse accessTokenResponse = AccessTokenResponse.builder()
                .token(token)
                .build();
        RefreshTokenResponse refreshTokenResponse = RefreshTokenResponse.builder()
                .token(token)
                .build();
        JwtAccessToken jwtAccessToken = new JwtAccessToken();

        given(authService.createAccessToken(any())).willReturn(accessTokenResponse);
        given(authService.extractMemberIdByToken(token, jwtAccessToken)).willReturn(id);
        given(authService.createRefreshToken(any())).willReturn(refreshTokenResponse);

        ResultActions result = mockMvc.perform(post("/api/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(loginRequest)));

        result.andExpect(status().isOk()).andExpect(cookie().exists(REFRESH_TOKEN_COOKIE_NAME));
    }

    ...

}

위에서 말했던 @WebMvcTest를 적용했고, 나머지를 설명해보자면 다른 계층에 영향을 받지 않고 이 계층에서만 테스트 하기 위해서 @MockBean을 사용합니다.

 

MockBean은 간단하게 말하면 껍데기만 있는 객체라고 할 수 있고, 내부의 구현 부분은 사용자에게 위임하여 개발자 필요에 의해 조작이 가능합니다.
그렇기 때문에 다른 계층에 영향을 받지 않고 해당 계층을 테스트 할 수 있게 되는 것입니다.

 

@AutoConfigureMockMvc 애노테이션은 MockMvc 인스턴스를 주입받을 수 있게 해주고, MockMvc는 HTTP 요청 및 응답을 테스트하기 위한 모의 객체를 제공해줍니다. 그리고 addFilters = false 옵션은 추가적인 필터를 제거하여 테스트를 보다 정확하게 테스트할 수 있게 해줍니다.

서비스 계층 단위 테스트

프레젠테이션 계층과 다르게 HTTP 요청 응답을 할 필요가 없어서 여러 설정이 크게 필요가 없고, 여기서는 @Mock, @InjectMocks, given, then등의 기능 정도만 알면 됩니다.

 

@Mock은 @MockBean과 비슷한 기능을 하지만 MockBean은 스프링 컨텍스트에 등록된 빈이 있어야 하고, 없다면 Mock을 사용해야 합니다. 서비스 계층에서는 스프링 컨텍스트를 띄우고 테스트를 하는 것이 아니라서 @MockBean을 사용할 수 없고, @Mock을 사용해줘야 합니다.
그리고 @ExtendWith(MockitoExtension.class)는 Mokito를 사용한다는 것을 의미합니다.

 

아래 코드는 제 프로젝트의 실제 코드의 일부입니다.

@ExtendWith(MockitoExtension.class)
class AuthServiceTest {

    private static final String ACCESS_TOKEN = "accessToken";

    @Mock
    private JwtTokenProvider jwtTokenProvider;

    @Mock
    private MemberRepository memberRepository;

    @Mock
    private GoogleOauthManager googleOauthManager;

    @InjectMocks
    private AuthService authService;

    @Test
    void 이미_멤버가_존재할_경우의_토큰_생성() {
        //given
        LoginRequest loginRequest = new LoginRequest("code");
        Member member = Member.builder()
                .role(Role.MEMBER)
                .id(1L)
                .thumbnailURL("thumbnailUR")
                .nickname("haneul")
                .socialId("socialId")
                .build();

        given(googleOauthManager.findMemberByOauthCode(loginRequest.getCode())).willReturn(member);
        given(memberRepository.findBySocialId(member.getSocialId())).willReturn(Optional.of(member));
        given(jwtTokenProvider.createAccessToken(member.getId())).willReturn(ACCESS_TOKEN);


        //when
        AccessTokenResponse accessTokenResponse = authService.createAccessToken(loginRequest);

        //then
        assertThat(accessTokenResponse.getToken()).isEqualTo(ACCESS_TOKEN);

        then(googleOauthManager)
                .should(times(1))
                .findMemberByOauthCode(loginRequest.getCode());
        then(memberRepository)
                .should(times(1))
                .findBySocialId(member.getSocialId());
        then(jwtTokenProvider)
                .should(times(1))
                .createAccessToken(member.getId());
    }

    ...
}

도메인 계층 테스트

도메인 계층은 따로 설정할 것이 별로 없고, 도메인 객체를 임의로 생성한 후 도메인 내의 메서드가 잘 작동을 하는지를 테스트 해주면 됩니다.

 

아래 코드는 제 프로젝트의 실제 코드의 일부입니다.

class MemberTest {

    @Test
    void 닉네임_변경() {
        //given
        Member member = Member.builder()
                .nickname("nickname")
                .build();
        String updateNickname = "updateNickname";

        //when
        member.update(updateNickname);

        //then
        assertThat(member.getNickname()).isEqualTo(updateNickname);
    }
    ...
}

저장소 계층 테스트

저는 프로젝트에서 Spring Data Jpa를 사용했는데, 이를 테스트 하기 위해서는 @DataJpaTest를 사용해야 합니다. 이 애노테이션은 JPA 사용에 필요한 빈들을 등록하여 테스트에 도움을 줍니다.

package com.illch.calendar.repository;

import com.illch.RepositoryTestConfig;
import com.illch.calendar.domain.Calendar;
import com.illch.calendar.domain.CalendarRole;
import com.illch.calendar.dto.CalendarResponse;
import com.illch.calendar.dto.SearchCalendarsRequest;
import com.illch.member.domain.Member;
import com.illch.member.domain.Role;
import com.illch.member.repository.MemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;

import java.util.List;

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

@DataJpaTest
@Import(RepositoryTestConfig.class)
class CalendarRepositoryTest {

    @Autowired
    CalendarRepository calendarRepository;

    @Autowired
    MemberRepository memberRepository;


    @Test
    void 캘린더_제목별_검색_및_총개수_가져오기() {
        //given
        SearchCalendarsRequest request = new SearchCalendarsRequest(1L, 2L, "title");
        Member member = Member.builder()
                .nickname("nickname")
                .thumbnailURL("thumbnailURL")
                .socialId("socialId")
                .role(Role.MEMBER)
                .build();
        Calendar calendar1 = Calendar.builder()
                .title("title1")
                .role(CalendarRole.PUBLIC)
                .member(member)
                .build();
        Calendar calendar2 = Calendar.builder()
                .title("title2")
                .role(CalendarRole.PUBLIC)
                .member(member)
                .build();
        Calendar calendar3 = Calendar.builder()
                .title("title3")
                .role(CalendarRole.PUBLIC)
                .member(member)
                .build();

        memberRepository.save(member);
        calendarRepository.save(calendar1);
        calendarRepository.save(calendar2);
        calendarRepository.save(calendar3);

        //when
        List<CalendarResponse> result = calendarRepository.searchCalendars(request, member);
        Long countAll = calendarRepository.countCalendarsByTitle(request.getTitle(), member);

        //then
        assertThat(result.size()).isEqualTo(2);
        assertThat(countAll).isEqualTo(3);
    }

}