스프링 프레임워크/test

Springboot test - 2 - Service test with mockito

blogger903 2024. 8. 7. 09:34
728x90

이번 포스트에서는 JUni5에서 Mockito로 Mock을 통한 서비스 애플리케이션 테스트를 다루겠습니다

중간규모테스트(통합테스트)에 한동안 빠져있었으나, 기능을 견고하게 하기위해 현실적으로 가장 강력한 방법은 슬라이스 테스트입니다

물론 배포전 실제 API를 호출해볼 수 있겠지만 시스템의 복잡도는 프로젝트마다 천차만별이고, 외부 시스템 연동 기능의 경우에는 완벽하게 테스트하기가 어려울수 있습니다. 슬라이스 테스트는 결국 모킹인데 그걸 믿을 수 있냐? 라고 볼수 있고, 모킹테스트를 해도 실제 API 동작하지 않는 경우도 있을 수 있습니다. 그 경우에는 특정 관심사 부분의 슬라이스 테스트가 없거나 실패했을겁니다. 우리가 만든 기능에 각 관심사마다 테스트를 작성했고 테스트 커버리지를 높였다면 충분히 믿을 수 있는 테스트 방법입니다

 

Q&A에 대한 대답으로 풀어보겠습니다

Q&A

@Mock, @MockBean 은 어떤 차이가 있나?

@Mock은 mockito에서 제공되는 기능이며
@MockBean은 스프링 테스트에서 지원해주는 기능입니다

@Mock

SUT에서 의존하고 있는 의존성을 격리하고 싶습니다
@InjectMocks 애노테이션으로 SUT^1에 Mock을 주입받겠다고 선언해줍니다
그리고 SUT 테스트에 집중하기 위해 SUT가 가진 의존성을 격리하는걸 권장합니다

흔히 테스트 더블^2을 통해 의존성을 격리합니다

BDDMockito에서 제공해주는 given를 통해
mocking한 객체에 대한 행위를 가짜객체가 대역으로 처리하게됩니다

@Mock 예시


@ExtendWith(MockitoExtension.class)
class MemberServiceV2Test {

    @InjectMocks
    private MemberServiceV2 sut;

    @Mock
    private MemberJpaRepositoryV2 memberRepository;

    @Test
    void getMemberById_Success() {
        // Given
        MemberV2 mockMember = new MemberV2(1L, "user1@example.com", "password1", "User1", null);

        given(memberRepository.findById(1L)).willReturn(Optional.of(mockMember));

        // When
        MemberV2 foundMember = sut.getMemberById(1L);

        // Then
        assertThat(foundMember).isNotNull();
        assertThat(foundMember.getId()).isEqualTo(mockMember.getId());
        assertThat(foundMember.getEmail()).isEqualTo(mockMember.getEmail());
    }

    @Test
    void getMemberById_Failure() {
        // Given
        given(memberRepository.findById(2L)).willReturn(Optional.empty());

        // When & Then
        assertThatThrownBy(() -> sut.getMemberById(2L))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("Member not found with id: 2");
    }
}

@MockBean

@SpringBootTest나 @WebMvcTest에서는 ApplicationContext가 로드되고 컴포넌트 스캔에 대상이 된 Bean들이 등록되어 주입받을 수 있습니다

Controller 테스트에 집중하기 위해 의존한 Service를 격리하기 하는것을 권장합니다
이때 MockBean으로 Service를 격리합니다
Mock이랑 다르게 Bean을 Mocking한다고 보면 될것 같습니다

@MockBean 예시


@WebMvcTest(FeedControllerV2.class)
public class FeedControllerV2Test {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private FeedServiceV2 feedServiceV2;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("[성공] 피드를 조회합니다")
    void getFeeds_Success() throws Exception {
        FeedDtoV2 feedDto = FeedDtoTestFixtureV2.createSampleFeedDtoV2();
        List<FeedDtoV2> feeds = Collections.singletonList(feedDto);
        FeedSearchRequestV2 request = FeedSearchRequestV2.builder()
                .memberName("yobs")
                .page(1)
                .size(10)
                .build();

        given(feedServiceV2.searchFeeds(any(FeedSearchRequestV2.class))).willReturn(feeds);

        String expectedResponse = objectMapper.writeValueAsString(feeds);

        mockMvc.perform(MockMvcRequestBuilders.get("/feeds/v2")
                        .param("memberName", request.getMemberName())
                        .param("page", String.valueOf(request.getPage()))
                        .param("size", String.valueOf(request.getSize())))
                .andExpect(status().isOk())
                .andExpect(content().json(expectedResponse));
    }

    @Test
    @DisplayName("[성공] 존재하는 id로 feed 조회시 Feed를 조회합니다")
    void getFeedById_Success() throws Exception {

        FeedDtoV2 feedDto = FeedDtoTestFixtureV2.createSampleFeedDtoV2();
        given(feedServiceV2.getFeedById(feedDto.getId())).willReturn(feedDto);

        String expectedFeedDto = objectMapper.writeValueAsString(feedDto);

        mockMvc.perform(MockMvcRequestBuilders.get("/feeds/v2/{id}", 1L))
                .andExpect(status().isOk())
                .andExpect(content().json(expectedFeedDto));

    }

    @Test
    @DisplayName("[실패] Feed가 존재하지 않는 경우 BadRequest를 반환합니다")
    void getFeedById_NotFound() throws Exception {
        given(feedServiceV2.getFeedById(anyLong())).willThrow(new IllegalArgumentException("Feed not found"));

        mockMvc.perform(MockMvcRequestBuilders.get("/feeds/v2/{id}", 1L))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.message").value("Feed not found"));
    }

    @Test
    @DisplayName("[실패] 페이지 번호가 유효하지 않음")
    void getFeeds_InvalidPage() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/feeds/v2")
                        .param("memberName", "yobs")
                        .param("page", "0") // Invalid page number
                        .param("size", "300"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @DisplayName("[실패] 페이지 크기가 유효하지 않음")
    void getFeeds_InvalidSize() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/feeds/v2")
                        .param("memberName", "yobs")
                        .param("page", "1")
                        .param("size", "300")) // Invalid size
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.message").value(String.format("size: %s", SIZE_VALIDATION_MESSAGE)));
    }
}

@Spy, @SpyBean은?

@Spy

Mockito 라이브러리에서 지원해주는 기능이며, Stubing^3을 선택할수가 있습니다.

Stubing은 가짜객체의 메서드가 실행됐을때 이렇게 리턴해주도록 설정하는 기능입니다.

Stubing을 선택할 수 있다는 말은 안할 수도 있다는 말입니다. Stubing을 하지 않은다면 진짜 객체가 동작할겁니다. 테스트의 목적은 우리가 기대한대로 동작하는지를 보는것이기 때문에 Mockito 제공해주는 verify기능을 통해 실제객체의 동작이 실행됐는지를 검증할 수 있습니다.

예시

package com.example.mildangbespringstudy.chap02.studyActivityInstance.application;

import com.example.mildangbespringstudy.chap02.domain.domain.StudyActivityInstanceV2;
import com.example.mildangbespringstudy.chap02.domain.domain.StudyModuleInstanceV2;
import com.example.mildangbespringstudy.chap02.domain.domain.StudyUnitInstanceV2;
import com.example.mildangbespringstudy.chap02.external.sns.SNSService;
import com.example.mildangbespringstudy.chap02.studyActivityInstance.application.dto.UpdateAnswerRequestV2;
import com.example.mildangbespringstudy.chap02.studyActivityInstance.dataaccess.SAIJpaRepositoryV2;
import org.junit.jupiter.api.BeforeEach;
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.Spy;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class SAIServiceV2Test {

    @Mock
    private SAIJpaRepositoryV2 repository;

    @Spy
    private SNSService snsService;

    @InjectMocks
    private SAIServiceV2 sut;

    @Test
    @DisplayName("정답을 업데이트하고, 정오답여부를 업데이트합니다")
    void updateAnswer() {
        UUID id = UUID.fromString("4ea0106c-8755-4868-96f4-3e7fa23283a3");
        int userAnswer = 1;
        UpdateAnswerRequestV2 request = new UpdateAnswerRequestV2(userAnswer);

        StudyModuleInstanceV2 smi = new StudyModuleInstanceV2();
        StudyUnitInstanceV2 sui = new StudyUnitInstanceV2();
        StudyActivityInstanceV2 sai = new StudyActivityInstanceV2();

        sui.setStudyModuleInstanceV2(smi);
        sai.setStudyUnitInstanceV2(sui);

        // given: 설정 단계
        sai.setAnswer(userAnswer);
        given(repository.findById(id)).willReturn(Optional.of(sai));
        given(repository.save(any(StudyActivityInstanceV2.class))).willReturn(sai);

        // when: 실행 단계
        sut.updateAnswer(id, request);

        // then: 검증 단계
        verify(repository).findById(id);
        verify(repository).save(sai);
        verify(snsService).send(any(String.class));
        assertThat(sai.getUserAnswer()).isEqualTo(userAnswer);
        assertThat(sai.getIsCorrect()).isTrue();
    }
}

@SpyBean

@MockBean 처럼 SpringFramework Test에서 제공해주는 기능이며, Bean에 대해서 실제 동작은 하고 선택적으로 Stubing도 지원하지만, 동작했는지를 검증할 수 있는 verify를 사용할때 쓰입니다.

@SpringBootTest
@SqlGroup(
        {
            @Sql(value = "classpath:sql/create-team-service-test.data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD),
            @Sql(value = "classpath:sql/delete-team-service-test.data.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD),
        }
)
class TeamServiceTest {

    /**
     * new로 생성해서 주입하지 않습니다.
     */
    @Autowired
    private TeamService teamService;

    @SpyBean
    private SNSService snsService;


    @Test
    public void junitTest() {
        String expected = "Hello JUnit 5";
        String actual = "Hello JUnit 5";
        assertEquals(expected, actual);
    }

    @Test
    public void createTeamTest() {
        // given
        MemberCreateRequest memberCreateRequest = new MemberCreateRequest(
                "이름"
        );
        TeamCreateRequest teamCreateRequest = new TeamCreateRequest(
                "팀 이름",
                List.of(memberCreateRequest)
        );

        // when
        TeamResponse teamResponse = teamService.createTeam(teamCreateRequest);

        // then
        verify(snsService).send(any(String.class));
        assertEquals(teamCreateRequest.getName(),teamResponse.getName());
    }
}

참고:

'스프링 프레임워크 > test' 카테고리의 다른 글

Springboot test - 3 - TestContainers  (0) 2024.08.07
Springboot test - 1 - controller test  (0) 2024.08.07