@MockBean 사용 시 NullPointerException 이슈 해결

Glen·2023년 4월 27일
0

TroubleShooting

목록 보기
1/6

Given

Controller 테스트 중 불필요한 계층 간 의존성을 없애기 위해 Mockito를 사용하였다.

Controller가 의존하는 Service의 로직 중 다음과 같은 로직이 있다.

public void deleteById(Long id) {  
    validateId(id);  
    productRepository.deleteById(id);  
}

private void validateId(Long id) {  
    if (!productRepository.existsById(id)) {  
        throw new ProductNotFoundException("존재하지 않는 상품의 ID 입니다.");  
    }  
}

상품을 삭제하기 전, 상품의 ID가 존재하는지 검증하고 상품을 지우는 로직이다.

이때 테스트 코드는 다음과 같다.

@WebMvcTest(ProductApiController.class)  
class ProductApiControllerTest {
    @Autowired  
    MockMvc mockMvc;  
      
    @Autowired  
    ObjectMapper objectMapper;  
      
    @MockBean  
    ProductService productService;
    
    ...
}

테스트 코드에선 @MockBean으로 Controller가 의존하는 Service만 Mock으로 만들어 주었다.

삭제할 상품이 없을 때 400번 오류와 예외 메시지를 검증하는 테스트가 필요했는데, Service가 Mock으로 정의되어 있어서 다음과 같이 테스트 코드를 작성해야 했다.

@Test  
void deleteProduct_invalidProductId() throws Exception {  
    // given  
    willThrow(new ProductNotFoundException("존재하지 않는 상품의 ID 입니다."))  
            .given(productService)  
            .deleteById(anyLong());  
  
    // expect  
    mockMvc.perform(delete("/products/" + 1)  
                    .contentType(MediaType.APPLICATION_JSON))  
            .andExpect(status().isBadRequest())  
            .andExpect(jsonPath("$.code").value("400"))  
            .andExpect(jsonPath("$.message").value("존재하지 않는 상품의 ID 입니다."));  
}

given에서 예외를 던지도록 한 다음 직접 예외 메시지를 적어주고 있었다.

하지만 예외 메시지는 Service에서 지정된 예외 메시지를 사용하므로, 직접 예외 메시지를 검증하는 것은 옳지 않았다.

이렇게 잘못된 값의 예외 메시지를 적어도 테스트가 통과한다.

...
willThrow(new ProductNotFoundException("존재하지 않는 상품의 ID 입니다.ㅋㅋ"))
...
.andExpect(jsonPath("$.message").value("존재하지 않는 상품의 ID 입니다.ㅋㅋ"));

따라서 Service에서 사용하는 예외 메시지를 적용할 필요가 있었다.

지금 생각해 보면 ProductNotFoundException의 생성자로 메시지를 받는 게 아닌, 기본값으로 메시지를 두거나, 예외 메세지를 관리하는 클래스를 사용해도 좋았을 것 같다.

또한 Service에서 예외를 던지는 로직은 Repository에 의존하고 있으므로 Repository도 MockBean으로 등록했다.

@MockBean  
ProductService productService;  
  
@MockBean  
ProductRepository productRepository;

그리고 Service에서 Repository의 로직을 호출하려면 Stub 된 Service의 메소드 말고 실제 Service의 메서드를 호출해야 하므로 willCallRealMethod()를 사용하였다.

willCallRealMethod().given(productService)
        .deleteById(anyLong());

given(productRepository.existsById(anyLong()))  
        .willReturn(false);

When

하지만 테스트를 돌려보니 NullPointerException이 발생했다.

예외의 StackTrace를 따라가보니 Service의 해당 부분에서 예외가 발생했다.

...
if (!productRepository.existsById(id)) {
...

Repository의 existsById() 메서드는 Stub을 해주었고,

Service와 Repository는 @MockBean으로 스프링 빈으로 등록했다.

따라서 Service에 Repository가 의존성 주입이 되지 않았다고 판단하여 우선 디버깅을 해보았다.

예상대로 Service가 의존하는 Repository가 null 값이었다.

무엇이 문제였을까?

Then

@MockBean을 사용하면 가짜 객체를 생성하고 스프링 빈으로 등록한다.

하지만 @MockBean으로 등록하는 객체는 의존성 주입이 되지 않는다.

즉, 생성 시 가짜 객체를 만들기 때문에 진짜 객체가 가지고 있는 의존성을 주입해 주지 않는다.

따라서, @MockBean말고 @SpyBean을 사용하여 진짜 객체를 기반으로 Mock을 만들어야 한다.

@SpyBean을 사용하면 실제 객체를 생성하기 때문에 의존성 주입이 가능하다.

@SpyBean은 의존성 주입이 끝난 실제 객체를 프록시로 사용하기 때문에 Repository가 null 값이 아니다.

@SpyBean  
ProductService productService;  
  
@MockBean  
ProductRepository productRepository;

또한 실제 객체의 메서드를 호출하므로 willCallRealMethod()를 사용할 필요도 없다.

@Test
void deleteProduct_invalidProductId() throws Exception {  
    // given  
    long productId = 1;  
    given(productRepository.existsById(anyLong()))  
            .willReturn(false); // stub을 해주지 않아도 원시 타입이라 false를 반환하지만 명시적으로 적어주었다.
  
    // expect  
    mockMvc.perform(delete("/products/" + productId)  
                    .contentType(MediaType.APPLICATION_JSON))  
            .andExpect(status().isBadRequest())  
            .andExpect(jsonPath("$.code").value("400"))  
            .andExpect(jsonPath("$.message").value("존재하지 않는 상품의 ID 입니다."));  
}

이렇게 하여 Controller에서 Repository에 의존하는 기능에 대한 테스트 코드를 작성할 수 있었다.

의존성 주입이 필요한 객체를 모킹할 때는 @SpyBean을 사용하여 의존성 주입이 되게 하자.

profile
꾸준히 성장하고 싶은 사람

0개의 댓글