본문 바로가기
Java/Spring

[Spring Boot] 테스트 코드 작성

by Dev_Green 2023. 3. 31.

1. 테스트 코드를 작성하는 이유

  • 개발 과정에서 문제를 미리 발견할 수 있다.
    • 일부러 오류를 발생시켜 의도한대로 예외 처리가 수행되는지 확인
    • 의도한 비즈니스 로직대로 결과값이 잘 나오는지 확인
  • 리팩토링의 리스크가 줄어든다.
    • 서비스 업데이트 과정에서 코드를 수정하면 그와 연관된 다른 코드에 영향을 주기 마련이다. 이때 작성되어 있는 테스트 코드를 실행해봄으로써 일련의 과정을 보다 수월하게 수행해볼 수 있다.
  • 명세 문서로서 기능할 수 있다.
    • 잘 작성된 테스트 코드를 보면 특정 로직에 어떤 값을 입력하면 어떤 값이 반환되는지를 이해할 수 있다. 이를 통해 코드 작성자의 의도를 다른 개발자가 이해하기 수월하게 만들 수 있다.

2. 단위 테스트와 통합 테스트

2. 1. 단위 테스트

  일반적으로 메서드 단위의 테스트이다. 테스트 대상 이외의 외부 요인들을 제외한 채 수행한다.

2. 2. 통합 테스트

  외부요인들을 포함하고 테스트를 진행하여 애플리케이션이 온전히 동작하는지를 테스트한다.

3. 테스트 코드 작성 전략

3. 1. Given - When - Then 패턴

  Given-When-Then 패턴은 코드를 단계에 따라 나눔으로써 테스트의 의도를 명확히 하는 데 효과적인 방식이다.

  1. Given
    • 테스트에 필요한 환경을 설정하는 단계
    • 테스트에 필요한 변수를 정의하거나 Mock 객체를 통해 특정 상황에 대한 행동을 정의한다.
  2. When
    • 테스트의 목적을 보여주는 단계.
    • 테스트하고자 하는 메서드가 포함되며 이를 통해 결괏값을 도출한다.
  3. Then
    • 테스트의 결과를 검증하는 단계. 
    • When 절에서 얻어낸 결괏값에 대한 검증을 수행한다.

3. 2. 단위 테스트 코드 작성에 필요한 5가지 속성 (F.I.R.S.T)

  • Fast 
    • 테스트는 빠르게 수행되어야 한다
    • 테스트 목적을 단순화하거나 외부 환경의 사용을 최소화한다
  • Isolated
    • 관리할 수 없는 외부 소스는 차단한다
  • Repeatable
    • 테스트는 어떤 환경에서도 반복 가능하도록 작성해야 한다
    • 예를 들어, DB에 레코드를 저장하는 로직을 테스트할 때 반복적으로 같은 id를 가진 레코드를 저장한다면 값이 중복되어 반복할 수 없는 테스트 코드가 된다
  • Self-Validating
    • 테스트의 성패 여부를 개발자가 직접 확인하는 것이 아니라 Assert문 등을 통해 테스트가 자체적으로 확인할 수 있는 코드까지 포함해야 한다
  • Timely
    • 테스트 코드를 작성하는 목적이 문제를 미리 발견하기 위함이라면 테스트 코드는 적시에 작성되어야 한다

 

4. 애플리케이션 레이어 별 테스트 코드

4. 1. Controller 레이어

  컨트롤러는 애플리케이션을 구성하는 레이어 중 웹과 맞닿아 있는 모듈로서 웹에 대한 요청 및 응답을 처리하는 레이어이다. 따라서 테스트 또한 웹에 대한 요청에 대한 부분이 주를 이루게 된다.

@WebMvcTest(테스트대상클래스.class)

  • @SpringBootTest는 모든 빈을 로드하는 반면 @WebMvcTest는 Controller 레이어만(혹은 명시한 특정 컨트롤러만) 로드하여 @SpringBootTest보다 가볍게 테스트하기 위해 사용된다.

@MockBean

  • 실제 Bean 객체가 아닌 가짜(mock) 객체를 만들어 컨테이너가 주입하는 역할은 한다.
  • 가짜 객체이기 때문에 스스로 실제 행위를 수행하지 않아 개발자가 Mockito의 given() 메서드를 통해 동작을 정의해야 한다.

@MockMvc

  • HTTP 요청(GET, POST, PUT, DELETE 등)으로 수행될 컨트롤러의 API를 테스트하는 데 사용된다. 가상의 MVC 환경에서 모의 HTTP 서블릿을 요청하는 유틸리티 클래스이다.
  • 포함된 주요 메서드
    • perform() : HTTP 요청을 할 수 있다.
    • andExpect() : perform() 메서드의 결괏값으로 반환된 ResultActions 객체에 대한 검증을 수행한다.
    • andDo() : 요청과 응답의 전체 내용을 확인한다.
    • verifty() : 지정된 메서드가 실행됐는지 검증한다.

예제) 회원가입 컨트롤러에 대한 테스트 코드

@WebMvcTest(SignUpController.class)  // 웹에서 사용되는 요청과 응답에 대한 테스트를 수행할 수 있음
class SignUpControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    SignUpApplication signUpApplication;


    @Test
    @DisplayName("고객-회원가입-성공")
    void customerSignUp_success() throws Exception {
        SignUpForm form = SignUpForm.builder()
                .email("test@naver.com")
                .name("danny")
                .password("password11!!")
                .birth(LocalDate.now())
                .phone("010-0000-1111")
                .build();

        mockMvc.perform(
                post("/signUp/customer")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(form)))
                .andExpect(status().isOk())
                .andDo(print());
    }

    @Test
    @DisplayName("고객-회원가입-실패")
    void customerSignUp_fail() throws Exception {
        SignUpForm form = SignUpForm.builder()
                .email("testnaver.com")  // 유효성 검사 실패할 부분!
                .name("danny")
                .password("password11!!")
                .birth(LocalDate.now())
                .phone("010-0000-1111")
                .build();

        mockMvc.perform(
                        post("/signUp/customer")
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(objectMapper.writeValueAsString(form)))
                .andExpect(jsonPath("$.errorCode").value("INVALID_VALUE"))
                .andDo(print());
    }
}

 

4. 2. Service 레이어

  서비스 레이어는 외부 요인을 모두 배제한 채 독립적인 단위 테스트를 작성한다. 하지만 연관된 외부 클래스를 아예 사용하지 않을 수는 없기에 Mock(가짜) 객체를 사용하여 그 역할을 대체한다. 

  When 절에서 테스트하고자 하는 메서드를 호출해서 동작을 테스트하고 그 결과로서 도출된 응답값을 Then 절에서 Assertion을 통해 검증한다.

예제) 회원 가입 서비스에 대한 테스트 코드

@ExtendWith(MockitoExtension.class)
class SignupCustomerServiceTest {

    @Mock
    private CustomerRepository customerRepository;

    @InjectMocks
    private SignUpCustomerService signupCustomerService;

    @Test
    @DisplayName("고객_회원가입_성공")
    void signUp_success() {
        // given
        SignUpForm form = SignUpForm.builder()
                .email("test@naver.com")
                .name("danny")
                .password("password11!!")
                .birth(LocalDate.now())
                .phone("010-0000-1111")
                .build();

        given(customerRepository.save(any()))
                .willReturn(
                        Customer.builder()
                                .id(123L)
                                .email("test@naver.com")
                                .name("danny")
                                .password("password11!!")
                                .birth(LocalDate.now())
                                .phone("010-0000-1111")
                                .build());

        ArgumentCaptor<Customer> captor = ArgumentCaptor.forClass(Customer.class);

        // when
        Customer returnedCustomer = signupCustomerService.signUp(form);

        // then
        verify(customerRepository, times(1)).save(captor.capture());
        assertEquals(123, returnedCustomer.getId());
        assertEquals("010-0000-1111", captor.getValue().getPhone());
    }

    @Test
    @DisplayName("이메일 중복 확인")
    void isEmailExist() {
        //given
        Customer customer = Customer.builder()
                .id(123L)
                .email("test@naver.com")
                .name("danny")
                .password("password11!!")
                .birth(LocalDate.now())
                .phone("010-0000-1111")
                .build();
        String email = customer.getEmail();

        given(customerRepository.findByEmail(customer.getEmail()))
                .willReturn(Optional.of(customer));

        //when
        //then
        assertTrue(signupCustomerService.isEmailExist(email));
    }

    @Test
    @DisplayName("고객_인증코드 정보 입력_실패")
    void changeCustomerValidateEmail_fail() {
        //given
        given(customerRepository.findById(anyLong()))
                .willReturn(Optional.empty());

        //when
        CustomException exception = assertThrows(CustomException.class,
                () -> signupCustomerService.changeCustomerValidateEmail(1L, "test@naver.com"));

        //then
        assertEquals(ErrorCode.NOT_FOUND_USER, exception.getErrorCode());
    }

    @Test
    @DisplayName("고객_인증코드 확인_성공")
    void verifyEmail() {
        //given
        Customer customer = Customer.builder()
                .id(123L)
                .email("test@naver.com")
                .verifyExpiredAt(LocalDateTime.now().plusDays(1))
                .verificationCode("code")
                .verify(false)
                .build();

        given(customerRepository.findByEmail(anyString()))
                .willReturn(Optional.of(customer));

        //when
        signupCustomerService.verifyEmail(customer.getEmail(), "code");

        //then
        assertTrue(customer.isVerify());
    }

    @Test
    @DisplayName("고객_인증코드 확인_실패_이미 인증된 회원")
    void verifyEmail_alreadyVerified() {
        //given
        Customer customer = Customer.builder()
                .id(123L)
                .email("test@naver.com")
                .verifyExpiredAt(LocalDateTime.now().plusDays(1))
                .verificationCode("code")
                .verify(true)  // 이미 인증됨
                .build();

        given(customerRepository.findByEmail(anyString()))
                .willReturn(Optional.of(customer));

        //when
        CustomException exception = assertThrows(CustomException.class,
                () -> signupCustomerService.verifyEmail(customer.getEmail(), "code"));

        //then
        assertEquals(ErrorCode.ALREADY_VERIFIED, exception.getErrorCode());
    }
}

 

[참고 자료] 스프링 부트 핵심 가이드 장정우 지음