이번 포스트에서는 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 |