개발자로 후회없는 삶 살기

[테스트] 컨트롤러 테스트 하는 방법 (with Mockito) 본문

[백엔드]/[spring | 학습기록]

[테스트] 컨트롤러 테스트 하는 방법 (with Mockito)

몽이장쥰 2025. 1. 8. 16:14

서론

※ 아래 내용을 다룹니다.

  • 프레젠테이션 레이어를 테스트하는 방법
  • Stubbing
  • Test Double
  • Test Fixture 클렌징

 

본론

- 프레젠테이션 레이어의 역할

외부 세계의 요청을 가장 먼저 받는 레이어로 응답 결과와 요청 필드 검증을 주로 테스트해야 한다.

 

🚨 어떻게 테스트해야 할까?

Mocking : 가짜 객체로 대신하는 것

프레젠테이션 레이어를 테스트할 땐 그에 의존되는 하위의 레이어를 모킹한다. 프레젠테이션 레이어 테스트만 집중하여 단위 테스트 느낌이 가능하도록 하는 것이다.

 

- Mock

List mockList = Mock(List.class)

의존 관계로 인해 테스트가 어려울 때, 잘 동작하는 것을 가정하고, 가짜 객체로 처리할 때 사용한다. Mockito 라이브러리의 Mock 인터페이스는 가짜 객체를 생성할 수 있다.

 

-> MockMvc

Mock이라는 가짜 객체를 사용하여 스프링 MVC 동작을 재현할 수 있는 테스트 프레임워크이다.

 

✅ 컨트롤러 테스트 예제로 알아보기

컨트롤러를 테스트하기 위해서, 모킹한 객체들로 MVC 동작을 재현할 mockMvc를 주입 받는다. mockMvc를 사용하기 위해선 WebMvcTest가 필요하다. ComponentScan된 모든 빈을 띄우는 SpringBootTest와 달리, WebMvcTest는 프레젠테이션 레이어만 딱 떼어 테스트를 하기 위해서 controllers 필드에 명시된 컨트롤러와 관련된 빈만 등록하여, 가볍고 빠르다. (물론, SpringBootTest를 해도 @MockitoBean을 사용하여 원본을 가짜로 대체할 수 있다.)

 

-> MockitoBean

서비스 하위로는 전부 다 목킹 처리를 해보자. MockitoBean은 Mockito로 만든 모킹한 객체를 IOC 컨테이너에 넣어주는 역할을 한다. 실제 서비스 객체 대신에 먹객체를 컨테이너에 넣어준다.

 

MockitoBean을 하지 않으면

 

테스트하고자 하는 컨트롤러에서 의존하는 Service 객체를 찾을 수 없다는 예외가 발생한다.

 

그 이유는 컨트롤러에서 서비스를 의존하고 있는데, 생성자 주입을 받을 수 없기 때문이다. WebMvcTest는 컨트롤러와 관련된 빈만 띄우는데 즉, 실제 서비스도 빈 등록이 되지 않는다. 따라서 WebMvcTest로 테스트할 땐, MockitoBean을 통해 필요한 가짜 객체를 컨테이너에 넣어주어야 한다.

 

-> 상품 등록 API 메서드 테스트

호출 : Request 메시지를 가지고 API를 호출한다.
응답 : 응답 결과물 (상태, 응답 바디 등)로 응답한다.

BDD 방식으로 컨트롤러 호출부터 응답 전체 시나리오를 테스트한다. 컨트롤러 메서드를 호출하려면 API 호출을 해야 하는데 그것을 mockMvc가 재현해 준다.

 

@Test
@DisplayName("신규 상품을 등록한다.")
void test() throws Exception {
    // given
    ProductCreateRequest request = new ProductCreateRequest(HANDMADE, SELLING, "카푸치노", 5000);

    // when // then
    mockMvc.perform(
                post("/api/v1/products/new")
                    .content(objectMapper.writeValueAsString(request))
                    .contentType(APPLICATION_JSON)
            )
            .andDo(print())
            .andExpect(status().isOk());
}

mockMvc는 목킹된 객체를 사용하여 MVC를 동작할 수 있는 프레임워크이다. perform으로 API를 호출하고 andExpect로 응답 결과물을 검증할 수 있다. HTTP 방식으로 통신하기 위해서 요청 바디에 Application/JSON 형태로 데이터를 넣어줄 수 있다.

 

[요청 / 응답]

andDo에 print를 하면 MVC 동작 과정을 로깅하여 테스트가 정상적으로 동작하는지 눈으로 확인할 수 있다.

 

- 스프링 빈 검증 테스트

앞에서 프레젠테이션 레이어의 역할 중 하나인 요청과 응답을 테스트했다. 이제는 나머지 하나인 빈 검증을 테스트해 보자

 

-> 빈 검증 테스트 시나리오

1) 스프링 빈에 Validated에 실패하는 요청을 전송
2) 예외가 발생하여, Controller Advice에서 공통 처리
3) Controller Advice에서 응답한 데이터를 테스트

 

-> 빈 검증 Controller Advice

@RestControllerAdvice
public class ApiControllerAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiResponse<Object> bindException(BindingResult bindingResult) {
        return ApiResponse.of(
                HttpStatus.BAD_REQUEST,
                bindingResult.getAllErrors().get(0).getDefaultMessage(),
                null
        );
    }
}

빈 검증 시, 데이터가 바인딩되긴 전 validated를 통해 발생하는 예외는 MethodArgumentNotValidException이다. 이를 처리하는 공통 모듈을 만들었고, 예외가 발생하면 ApiResponse 공통 응답 객체를 반환한다.

 

-> 검증 테스트

@Test
@DisplayName("타입을 없이 요청을 했을 때 빈에 걸리는 지 테스트")
void withOutType() throws Exception {
    // given
    ProductCreateRequest request = ProductCreateRequest.builder()
            .sellingStatus(SELLING)
            .name("카푸치노")
            .price(5000)
            .build();

    // when // then
    mockMvc.perform(
                    post("/api/v1/products/new")
                            .content(objectMapper.writeValueAsString(request))
                            .contentType(APPLICATION_JSON)
            )
            .andDo(print())
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("400"))
            .andExpect(jsonPath("$.httpStatus").value("BAD_REQUEST"))
            .andExpect(jsonPath("$.message").value("필수"))
            ;
}

jsonpath로 응답 responseDto 필드 값을 검증할 수 있다.

 

🚨 빈 검증의 책임은 어디까지일까?

String 필드에 Blank 외에 range를 검증하는 것이 컨트롤러의 책임일까?

아니다. 컨트롤러에서는 Not Blank만 하고 글자 수 제한은 도메인 객체에서 정책을 정하는 것이 레이어드 아키텍처 관점에서 맞다.

 

- 컨트롤러 HTTP GET 메서드 조회 테스트

지금까지는 POST 메서드를 테스트하여 응답 DTO의 상태만 체크하고 데이터는 확인하지 않았다. 하지만 데이터를 조회하는 GET 메서드 테스트라면 응답 데이터를 확인해야 할 것 같다는 생각이 든다.

 

@GetMapping("/api/v1/products/selling") // 판매중인 상품 반환
public ApiResponse<List<ProductResponse>> getSellingProducts() {
    return ApiResponse.ok(productService.getSellingProducts());
}

위와 같은 GET 메서드를 테스트하면 productService의 get 메서드를 통해 데이터를 가져올 것이라고 예상된다.

 

🚨 목킹된 의존관계 객체의 동작을 사용해야 할 때

하지만, 결과는 빈 껍데기로 날아온다. 왜 그런 것일까?

 

@WebMvcTest(controllers = ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private ProductService productService;

그 이유는, 우리가 Product 서비스를 목킹해서 가짜 객체로 대신하고 있기 때문이다. Mockito의 Mock 인터페이스는 해당 타입의 객체를 만들어서 컨테이너에 넣어주고, 이로 인해 우리는 컨트롤러 테스트에서 의존관계를 신경 쓰지 않을 수 있었다. 하지만 그로 인해서 실제 시나리오 테스트를 할 수 없는 상황이 됐다.

 

✅ Mockito의 When 절

// given
List<ProductResponse> result = List.of();
Mockito.when(productService.getSellingProducts())
                .thenReturn(result);

// when // then
mockMvc.perform(
            get("/api/v1/products/selling")
        )
        .andDo(print())
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.code").value("200"))
        .andExpect(jsonPath("$.httpStatus").value("OK"))
        .andExpect(jsonPath("$.message").value("OK"))
        .andExpect(jsonPath("$.data").isArray())
        ;

하지만 Mockito 라이브러리는 목킹을 통해 가짜 객체를 만들어주는 것과 동시에 가짜 객체의 동작을 제어하는 기능을 제공한다. 우리는 목킹된 객체의 동작을 When 절로 명시할 수 있다.

 

✅ Mockito의 쓰임 중간 점검

1) Mock 인터페이스로 목킹하여 의존관계로 인한 테스트의 불편함을 제거하고 컨트롤러를 단위 테스트 형태로 테스트할 수 있음
2) 목킹된 객체로 테스트를 해야 할 때 동작을 명시할 수 있음

 

-> 컨트롤러 테스트 VS 인수 테스트

이 주제를 알아야 하는 이유는 레이어드 아키텍처의 책임을 확실히 이해하기 위해서이다.

 

컨트롤러 테스트 : 컨트롤러의 요청과 응답, 빈 검증을 주로 테스트
인수 테스트 : 전체 시나리오 테스트

위 GET 테스트에서 data의 값을 검증하지 않고 Array 형태인지만 검사한 이유는 데이터를 불러오는 것이 컨트롤러의 책임이 아니기 때문이다. 데이터를 불러오고 패키징 하는 것은 펄시스턴스와 서비스 레이어의 책임이고 이는 각 레이어 테스트에서 이미 검증이 끝났다. 컨트롤러는 요청과 응답 상태 검증, 빈 검증만 테스트해야 하는 것이다. 또한, 인수 테스트를 통해서 응답 데이터까지 검증할 것이므로, 컨트롤러의 책임만 테스트하는 것이 중복 테스트를 막는 길이기도 하다.

 

- Mockito Stubbing

목킹한 가짜 객체에게 원하는 행위를 명세하는 것

목킹한 객체로 인해 테스트를 진행할 수 없는 상황을 해결할 수 있다.

 

@MockitoBean
private MailSendClient mailSendClient;

when(mailSendClient.sendEmail(any(String.class), any(String.class), any(String.class), any(String.class)))
    .thenReturn(true);

메일 서비스를 테스트할 때마다 실제로 메일을 보내는 것을 올바르지 않으므로, 실제 메일을 전송하는 MailSendClient를 목킹을 한 상황에서 스터빙으로 원하는 동작을 명세하여 테스트를 진행할 수 있다. 이후 나오는 용어 정리에서 Stub과 Mock이 구분되는데 Stubbing은 행위를 명세하는 것을 의미하며 Stub과 다르고 Mock도 Stubbing을 한다고 표현할 수 있다.

 

- Test Double 용어 정리

1. Dummy

아무것도 하지 않는 깡통 객체

 

2. Fake

ex) FakeRepo는 메모리 Map(심플하게 수행)을 두고 레포의 기능을 하도록 한 것이다.
단순한 형태로 동일한 기능을 수행하나 심플하게 수행하고, 프로덕션에서 쓰기에는 부족한 객체

 

3. Stub

테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체로 정의하지 않는 요청에 대해선 응답하지 않는다.

 

4. Spy

Stub이면서 실제 객체를 기반으로 만들어진다. 호출된 내용을 몇 번, 타임아웃이 어떻게 됐는지 등을 기록을 해서 내부적으로 가지고 제공한다. 호출 등을 트래킹 하여 가지고 있다가 제공하는 객체이다. 실제 객체와 동일하게 동작할 수 있어서, 일부만 스터빙하고 일부는 실제 객체의 동작대로 수행할 수 있다.

 

5. Mock

행위에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체

 

-> 스텁과 목의 차이

가짜 객체이고, 행위를 명시하는 것은 비슷하여, 둘이 사전적으로는 헷갈릴 수 있는데, 둘은 검증의 목적에서 차이가 있다.

 

1) 스텁

class Stub {
	static int flag = 0;
	public static void call() {
    	flag += 1;
    }
}

Stub.call()
assert(1, Stub.flag)

스텁 호출 후, 스텁의 상태 검증을 목적으로 상태에 초점이 있다.

 

2) 목

어떠한 행위로 인한 결과 검증을 목적으로 행위에 초점이 있다.

 

- 순수 목으로 검증해 보기

@MockitoBean은 스프링을 띄우지 않으면 사용할 수 없는데, 단위 테스트를 할 때 목킹을 할 일이 있다. 이땐 스프링을 사용하지 않고 순수 Mock을 사용하면 된다.

 

@Test
@DisplayName("")
void sendMail() {
    // given
    MailSendClient mailSendClient = Mockito.mock(MailSendClient.class);
    MailSendHistoryRepository mailSendHistoryRepository = Mockito.mock(MailSendHistoryRepository.class);
    MailService mailService = new MailService(mailSendClient, mailSendHistoryRepository);

    Mockito.when(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
            .thenReturn(true);

    // when // then
    assertThat(mailService.sendMail(" ", "", "", ""))
            .isTrue();
    Mockito.verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
}

MailService에 필요한 2개의 의존 객체를 mock() 으로 2개의 목 객체를 생성했다. verify는 목 객체가 몇 번 불렸는지 검증할 수 있다.

 

@ExtendWith(MockitoExtension.class)
class MailServiceTest {

    @Spy
    private MailSendClient mailSendClient;

    @Mock
    private MailSendHistoryRepository mailSendHistoryRepository;

이를 어노테이션으로 대체할 수 있는데, ExtendWith를 해야, 목을 사용해서 목객체를 만들 것이라는 것을 인지시키고, 어노테이션을 사용할 수 있다. MockitoExtension 없이 RestDocumentationExtension를 넣고 @Mock을 하면 목 객체가 생성되지 않는다.

 

@InjectMocks
private MailService mailService;

InjectMock은 MailService의 생성자에 있는 코드를 기반으로 목 객체를 생성하여 주입해 준다. 스프링 DI와 동일한 기능이다.

 

-> Spy의 일부 스터빙

@Slf4j
@Component
public class MailSendClient {
    public boolean sendEmail(String fromEmail, String toEmail, String subject, String content) {
        log.info("메일 전송");
        throw new IllegalArgumentException("메일 전송");
    }

    public void a() {
        log.info("a");
    }
}

MailSendClient가 기능이 a, b, c로 많을 때, 메일 서비스에서 sendMail, a, b, c를 다 호출하면 spy는 일부만 스터빙 할 수 있으며 sendMail은 스터빙하고 a, b, c는 원본처럼 동작하도록 할 수 있다.

 

doReturn(true)
    .when(mailSendClient)
    .sendEmail(anyString(), anyString(), anyString(), anyString());

실제 객체를 기반으로 생성되어 do 문을 사용하여 sendEmail만 스터빙을 하면, 

 

a () 는 실제 객체가 호출되는 것을 볼 수 있다. @Mock을 하면 명세를 하지 않은 기능에 대해서는 동작을 하지 않아, a가 동작하지 않는다. 이렇게 스프링을 사용하지 않고 순수 모키토로 단위 테스트를 해보았다.

 

- Test Fixture 클렌징

given 절에서 생성한 모든 객체를 Test Fixture라고 한다. 어떤 테스트를 위해서 원하는 상태로 고정을 시킨 것이다. Mock을 이용한 테스트를 진행하면서 다양한 Test Fixture를 만들어왔는데 테스트 독립성을 위해서 클렌징하는 법을 얘기하면 포스팅을 마무리한다.

 

deleteAll과 deleteAllInBatch의 차이를 보자

 

1. deleteAll

다대다 매핑에서 제약 조건으로 인해 다측 테이블의 fk를 먼저 지우고 1측 로우를 지우는 것이 수반된다. 즉, 1측만 테스트하고 싶은데 매핑 테이블인 다측도 가져와야 하는 것이다. 이를 deleteAll을 사용하면 어느 정도 해소가 된다.

 

먼저 지우려고 하는 테이블을 조회를 해서 로우가 몇 개인지 확인을 하고 delete where 로 로우를 하나씩 제거를 한다.

 

-> 장점

1측을 제거를 할 때 다측도 같이 제거를 해준다. fk를 맺고 있는 것을 다 가져와서 하나씩 로우를 제거한다.

 

-> 단점

1) given절이 많아지면 모든 연관관계를 찾아서 하나씩 지워야 해서 테스트 성능 차이가 발생할 수 있다.
2) 연관관계가 다대다에서 왼쪽 1측에만 있고 오른쪽 1측은 단방향이면 오른쪽을 지울 때 fk를 몰라서 다측을 못 지워주고 fk 예외가 발생한다. All도 순서를 고려를 하긴 해야 한다.

All은 매핑 정보를 알면 전부 간편하게 지워주지만, 성능 차이가 발생하고 순서를 배제할 순 없다.

 

2. deleteAllInBatch

단순히 그냥 다 로우를 지우는 것으로 제약 조건에 따라서 다측이 필요하다. 하지만 건 당 제거가 아니므로 시간적으로 비용이 적게 발생하여 All보다 선호된다.

 

✅ 결론

테스트도 비용이다. 평소엔 트랜잭션 롤백을 사용하고 어려운 경우엔 AllInBatch를 혼용해서 사용하자
Comments