FIRST 규칙이 뭔지만 알지 말고 적용을 해보자

iqpizza6349·2022년 6월 7일
2

Spring

목록 보기
2/6

수박 겉핥기식으로 수박맛을 알지 말자

본 포스트는 작성자의 주관적 생각이 들어간 글임을 먼저 밝힙니다
또, 스프링 부트만을 예시로 다룹니다.

작성하게 된 계기


여러 예제들을 찾으면, 대부분 계층별 테스트로 나누지 않고, @SpringBootTest 어노테이션만을 사용한 예제들을 더러 볼 수 있었다. 깃허브 역시 @SpringBootTest 어노테이션만을 사용하여 계층별 테스트를 진행한 리포지토리 역시 심심치 않게 찾을 수 있었다.

우선 FIRST 규칙이 뭔지 모르는 분들을 위해 간단하게 설명을 하자면

  • Fast: 테스트는 빠르게 동작해야하며, 자주 시연할 수 있어야한다.
  • Indepentdent: 각각의 테스트는 독립적이며, 서로 의존하여서는 안된다.
  • Repeatable: 어느 환경에서도 반복 가능해야한다.
  • Self-Validating: 테스트 bool값으로 결과를 내어 자체적으로 검증해야한다. 즉, 성공 혹은 실패에 따른 로그가 아닌 정확한 값을 내야한다.
  • Timely: 테스트는 적시에, 즉 테스트하려는 실제 코드는 구현하기 직전에 구현해야한다.

위 규칙은 좀 더 안정적인 서비스를 하고, 작성한 코드를 테스트하는 데 드는 비용(시간)을 줄일 수 있습니다. (S에 해당하는 Self-Validating은 JUnit5 등 많은 라이브러리들이 해결해주기때문에 제외하였습니다. 또, T 역시 개발자의 상황에 따라 다르기에 이 역시 제외했습니다.)

@SpringBootTest를 최소화하라

앞서 설명했듯이 많은 예제들이나 깃허브에서 @SpringBootTest 어노테이션만을 사용하여 모든 테스트를 진행한다고 한다. 하지만 @SpringBootTest는 전체 테스트 어노테이션 즉, 통합 테스트를 진행할 때 사용하는 어노테이션이기 때문에 애플리케이션에 주입된 모든 Bean들을 전부 가져옵니다. 이는 단위 테스트를 진행하는 데, 큰 비용을 요구하기 때문에 FIRST 원칙을 위배하게 됩니다.
보통 단위 테스트는 계층(레이어)별로 테스트를 진행하기에 굳이 통합테스트용 어노테이션을 사용할 필요는 없습니다.

계층별 전용 어노테이션을 사용하라

스프링부트에서 지원해주는 테스트 어노테이션은 다음과 같다.

어노테이션요약Bean 요약
@SpringBootTest통합 테스트 어노테이션애플리케이션에 주입된 모든 Bean 로드
@WebMvcTestController 계층 테스트MVC 관련 Bean(Controller, Service) 로드
@DataJpaTestJPA (DB, I/O) 테스트JPA 관련 Bean(EntityManager) 로드
@RestClientTestREST API 테스트Rest Template 등 일부 Bean 로드
@JsonTestJson 데이터 테스트Json 관련 일부 Bean 로드
@WebFluxTestWebFlux 컨트롤러 테스트WebFlux 관련 Bean(Controller, Service, Converter등 단, Component 제외) 로드

단위 테스트를 진행할 계층에 맞는 전용 테스트 어노테이션을 사용하여, 반드시 필요한 빈들만을 로드할 수 있다.

Bean 주입을 최소화하고, Mock에 의존하라

위에서 설명한 스프링부트 지원 어노테이션들은 직접 동작하는 경우에 사용되는 어노테이션들이다.
즉, @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의 취약점

말그대로 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());
    }
}

마무리

  • 스프링부트 계층(레이어)별 테스트 어노테이션을 사용하면 Fast와 Indepentdent 규칙을 지킬 수 있다.
  • Mock를 최대한 사용함으로써 Fast와 Repeatable 규칙을 지킬 수 있다.
  • stub을 사용하여, Mock의 Indepentdent 규칙을 지킬 수 있다.
  • 스프링 부트 계층별 테스트 어노테이션과 Mock을 적절한 배합하여, FIRST 규칙들을 최대한 지킬 수 있다.

부족하고, 긴 글 읽어주셔서 감사드리며, 틀린 점이 있다면 댓글로 알려주시면 감사하겠습니다.


이상입니다.

profile
coffee.drinkUntilEmpty();

0개의 댓글