수박 겉핥기식으로 수박맛을 알지 말자
본 포스트는 작성자의 주관적 생각이 들어간 글임을 먼저 밝힙니다
또, 스프링 부트만을 예시로 다룹니다.
여러 예제들을 찾으면, 대부분 계층별 테스트로 나누지 않고, @SpringBootTest 어노테이션만을 사용한 예제들을 더러 볼 수 있었다. 깃허브 역시 @SpringBootTest 어노테이션만을 사용하여 계층별 테스트를 진행한 리포지토리 역시 심심치 않게 찾을 수 있었다.
우선 FIRST 규칙이 뭔지 모르는 분들을 위해 간단하게 설명을 하자면
위 규칙은 좀 더 안정적인 서비스를 하고, 작성한 코드를 테스트하는 데 드는 비용(시간)을 줄일 수 있습니다. (S에 해당하는 Self-Validating은 JUnit5 등 많은 라이브러리들이 해결해주기때문에 제외하였습니다. 또, T 역시 개발자의 상황에 따라 다르기에 이 역시 제외했습니다.)
앞서 설명했듯이 많은 예제들이나 깃허브에서 @SpringBootTest 어노테이션만을 사용하여 모든 테스트를 진행한다고 한다. 하지만 @SpringBootTest는 전체 테스트 어노테이션 즉, 통합 테스트를 진행할 때 사용하는 어노테이션이기 때문에 애플리케이션에 주입된 모든 Bean들을 전부 가져옵니다. 이는 단위 테스트를 진행하는 데, 큰 비용을 요구하기 때문에 FIRST 원칙을 위배하게 됩니다.
보통 단위 테스트는 계층(레이어)별로 테스트를 진행하기에 굳이 통합테스트용 어노테이션을 사용할 필요는 없습니다.
스프링부트에서 지원해주는 테스트 어노테이션은 다음과 같다.
어노테이션 | 요약 | Bean 요약 |
---|---|---|
@SpringBootTest | 통합 테스트 어노테이션 | 애플리케이션에 주입된 모든 Bean 로드 |
@WebMvcTest | Controller 계층 테스트 | MVC 관련 Bean(Controller, Service) 로드 |
@DataJpaTest | JPA (DB, I/O) 테스트 | JPA 관련 Bean(EntityManager) 로드 |
@RestClientTest | REST API 테스트 | Rest Template 등 일부 Bean 로드 |
@JsonTest | Json 데이터 테스트 | Json 관련 일부 Bean 로드 |
@WebFluxTest | WebFlux 컨트롤러 테스트 | WebFlux 관련 Bean(Controller, Service, Converter등 단, Component 제외) 로드 |
단위 테스트를 진행할 계층에 맞는 전용 테스트 어노테이션을 사용하여, 반드시 필요한 빈들만을 로드할 수 있다.
위에서 설명한 스프링부트 지원 어노테이션들은 직접 동작하는 경우에 사용되는 어노테이션들이다.
즉, @DataJpaTest 어노테이션을 사용하여, Repository를 주입했다면 직접 DB에 올라가는 방식이다. 하지만, 이는 리소스적으로 꽤 문제가 될 수도 있다.
예를 들면, auto_increment로 지정한 경우, rollback를 한다 하더라도 해당 값은 되돌려지지 않는다.
예) 1, 2, 3 저장 후, rollback -> 4, 5, 6 ... (다시 1부터 시작되지 않음)
또, DB와 직접 연결을 해야하기 때문에 FIRST 규칙 중 Repeatable 규칙을 위배하게 된다.
(DB가 local에서 돌아간다면 문제 없겠지만, 보통 local에서 돌아가는 경우는 극히 드물기에...)
이 외에도 추가 리소스가 발생하는 등의 이유로 FIRST 규칙 중 Fast 역시 위배될 수도 있다.
이러한 이유로 직접 Bean을 로드하는 방식보다는 Mock에 의존하여, 구현하는 편이 훨 FIRST 규칙을 지키기 수월하다.
JUnit5 기준, 다음과 같은 방식으로 Mockito를 사용할 수 있다.
@ExtendWith(MockitoExtension.class)
@Mock, @MockBean, @Spy, @SpyBean, @InjectMocks
등을 사용하여, 구현이 가능하다.
직접 stub을 만들어 빠르게 테스트가 가능하다.
Mockito에 포함된 메소드들을 활용하여, Bean 주입을 거의 하지 않고 테스트가 가능하다.
@ExtendsWith(MocktioExtension.class)
class MemberServiceTest {
@Mock
private MemberRepository memberRepository;
@InjectMocks
private MemberServiceImpl memberService;
public MemberDto memberDto() {
return new MemberDto("이름", "tester@test.test");
}
public Member toEntity(MemberDto memberDto) {
return new Member(1L, memberDto.getName(), memberDto.getEmail());
}
@DisplayName("회원 추가")
@Test
void addMemberTest() {
// given
MemberDto memberDto = memberDto();
lenient().doReturn(toEntity(memberDto))
.when(memberRepository)
.save(any(Member.class));
// when
MemberDto responseMember = memberService.addMember(memberDto);
// then
assertThat(responseMember).isNotNull();
assertEquals(1L, responseMember.getId());
// verify
verify(memberRepository, times(1))
.save(any(Member.class));
}
@DisplayName("회원 id로 찾기")
@Test
void findMemberByIdTest() {
// given
MemberDto memberDto = memberDto();
Member member = toEntity(memberDto);
lenient().doReturn(member)
.when(memberRepository)
.save(any(Member.class));
long id = member.getId();
lenient().when(memberRepository.findById(id))
.thenReturn(Optional.of(member);
// when
MemberDto responseMember = memberService.findById(id);
// then
assertThat(responseMember).isNotNull();
assertEquals(id, responseMember.getId());
}
}
말그대로 Mock은 가짜이기 때문에 컨트롤러와 컨피그 테스트가 굉장히 불편하다.
관련 Bean을 일일이 주입을 해야하기 때문인데, Spy 혹은 SpyBean 어노테이션으로 구현이 가능이 하긴 하나, 잘못 추가하거나 추가하지 않는 등 여러 문제가 발생할 수 있기때문에 개인적으로 컨트롤러는 @WebMvcTest 어노테이션와 @Mock을 같이 사용하여, 실제 반환 즉, 응답 영역만 확인하고, 나머지는 Mock으로 처리하는 것이 그림이 좋아보인다.
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfiguartionMockMvc
@WebMvcTest(MemberController.class)
@ExtendsWith(MockitoExtension.class)
class MemberControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean(name="MemberServiceImpl")
private MenuService menuService;
private MemberDto memberDto() {
return new MemberDto("이름", "tester@test.test");
}
private Member toEntity(MemberDto memberDto) {
return new Member(1L, memberDto.getName(), memberDto.getEmail());
}
private String getContent(Object value) {
return objectMapper.writeValueAsString(
value
);
}
@DisplayName("회원 추가 테스트 성공")
@Test
void addMemberSuccess() throws Exception {
String content = getContent(memberDto());
ResultActions resultActions = mockMvc.perform(
post("/menu")
.content(content)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
);
resultActions
.andDo(print())
.andExcept(status().isCreated())
.andExcept(jsonPath("$.name", "이름").exists())
.andExcept(jsonPath("$.email", "tester@test.test").exists());
}
}
부족하고, 긴 글 읽어주셔서 감사드리며, 틀린 점이 있다면 댓글로 알려주시면 감사하겠습니다.
이상입니다.