Test 코드를 작성해보자 - Controller

Drumj·2025년 4월 29일

Controller의 단위 테스트를 해보자.

옛날에 백기선님의 인프런 강의 더 자바, 애플리케이션을 테스트 하는 다양한 방법을 들었었는데...

하.. 옛날의 나는 강의를 따라 듣기만 하고 따로 정리는 하지 않았다. 요즘 정리하는 습관을 만들려고 하다보니 이게... 너무 좋다!! 시간이 지나서 내가 그때 뭐 했었는지 어떤 깨달음을 얻었는지 어떤 삽질을 했는지, 그리고 해당 기술을 어떻게 사용하는지도 바로바로 알 수 있다.

(진작 좀 하지 그랬어..)

간단하게 프로젝트를 만들면서 테스트 코드를 작성해보자.

이번엔 Controller의 단위 테스트!!


Test Code

package chimhaha.chimcard.card;

import chimhaha.chimcard.card.controller.CardViewController;
import chimhaha.chimcard.card.service.CardService;
import chimhaha.chimcard.entity.AttackType;
import chimhaha.chimcard.entity.Card;
import chimhaha.chimcard.entity.CardSeason;
import chimhaha.chimcard.entity.Grade;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;

import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.OK;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(CardViewController.class)
@AutoConfigureMockMvc
public class CardControllerUnitTest {

    @Autowired private MockMvc mvc;
    @MockitoBean private CardService cardService;
    @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; // JpaAuditing 사용 중이라 추가해야 됨

    @Test
    @DisplayName("ID: 1 카드 조회 성공")
    void getCardById() throws Exception {
        //given
        Card card = Card.builder()
                .title("test")
                .grade(Grade.SR)
                .attackType(AttackType.ROCK)
                .power(13)
                .cardSeason(new CardSeason("test","imageUrl"))
                .build();

        given(cardService.getCardById(1L)).willReturn(card);

        //when

        //then
        mvc.perform(get("/api/card/{id}", 1L))
                .andDo(print())
                .andExpect(status().isOk())
                //ApiResponse 공통
                .andExpect(jsonPath("status").value(OK.value()))
                .andExpect(jsonPath("message").value(OK.getReasonPhrase()))
                .andExpect(jsonPath("time").exists())
                //T data
                .andExpect(jsonPath("data.title").value(card.getTitle()))
                .andExpect(jsonPath("data.attackType").value(card.getAttackType().getType()))
                .andExpect(jsonPath("data.grade").value(card.getGrade().name()))
                .andExpect(jsonPath("data.power").value(card.getPower()))
                .andExpect(jsonPath("data.cardSeason").value(card.getCardSeason().getSeasonName()));

        verify(cardService).getCardById(1L);
    }

    @Test
    @DisplayName("ID: 2 카드 조회 실패")
    void getCardById_NotFound() throws Exception {
        //given
        IllegalArgumentException e = new IllegalArgumentException("해당 카드를 찾을 수 없습니다.");
        given(cardService.getCardById(2L)).willThrow(e);

        //when

        //then
        mvc.perform(get("/api/card/{id}", 2L))
                .andDo(print())
                .andExpect(status().isNotFound())
                //ApiResponse 공통
                .andExpect(jsonPath("status").value(NOT_FOUND.value()))
                .andExpect(jsonPath("message").value(NOT_FOUND.getReasonPhrase()))
                .andExpect(jsonPath("time").exists())
                //e.message()
                .andExpect(jsonPath("data").value(e.getMessage()));

        verify(cardService).getCardById(2L);
    }

    @Test
    @DisplayName("카드 시즌 전체 조회 성공")
    void getSeasons() throws Exception {
        //given
        List<CardSeason> cardSeasons = List.of(
                new CardSeason("season1", "imageUrl"),
                new CardSeason("season2", "imageUrl")
        );

        given(cardService.getCardSeasons()).willReturn(cardSeasons);
        //when

        //then
        mvc.perform(get("/api/card/seasons"))
                .andDo(print())
                .andExpect(status().isOk())
                //ApiResponse 공통
                .andExpect(jsonPath("status").value(OK.value()))
                .andExpect(jsonPath("message").value(OK.getReasonPhrase()))
                .andExpect(jsonPath("time").exists())
                //List<> data
                .andExpect(jsonPath("data[0].title").value(cardSeasons.getFirst().getSeasonName()))
                .andExpect(jsonPath("data[0].imageUrl").value(cardSeasons.getFirst().getImageUrl()))
                .andExpect(jsonPath("data[1].title").value(cardSeasons.get(1).getSeasonName()))
                .andExpect(jsonPath("data[1].imageUrl").value(cardSeasons.get(1).getImageUrl()));

        verify(cardService).getCardSeasons();
    }
    
    @Test
    @DisplayName("시즌 별 카드 조회")
    void getCardsBySeason() throws Exception {
        //given
        CardSeason cardSeason = new CardSeason("season1", "imageUrl");
        List<Card> cards = makeCardList(cardSeason);

        // Equals And HashCode 미구현으로 인한 seasonName 비교
        // ID값으로 Equals/HashCode 구현하면 되지만 ID값의 생성을 DB에 위임.
        List<Card> findBySeason = cards.stream().filter(
                        card -> card.getCardSeason().getSeasonName().equals(cardSeason.getSeasonName())
                ).toList();
        //이 코드도 테스트 코드 내에서는 통과되지만 실제 운영 상황에서는 실패할 수 있음.
        //card -> card.getCardSeason().equals(cardSeason)).toList();

        given(cardService.getCardsBySeason(1L)).willReturn(findBySeason);

        //when

        //then
        mvc.perform(get("/api/card/season/{id}", 1L))
                .andDo(print())
                .andExpect(status().isOk())
                //ApiResponse 공통
                .andExpect(jsonPath("status").value(OK.value()))
                .andExpect(jsonPath("message").value(OK.getReasonPhrase()))
                .andExpect(jsonPath("time").exists())
                //List<Card> data
                .andExpect(jsonPath("data.length()").value(findBySeason.size()))
                .andExpect(jsonPath("data[0].title").value(findBySeason.getFirst().getTitle()))
                .andExpect(jsonPath("data[0].attackType").value(findBySeason.getFirst().getAttackType().getType()))
                .andExpect(jsonPath("data[0].grade").value(findBySeason.getFirst().getGrade().name()))
                .andExpect(jsonPath("data[0].power").value(findBySeason.getFirst().getPower()))
                .andExpect(jsonPath("data[0].cardSeason").value(cardSeason.getSeasonName()));
    }

    @Test
    @DisplayName("시즌 별 카드 조회 실패")
    void getCardsBySeason_NotFound() throws Exception {
        //given
        IllegalArgumentException e = new IllegalArgumentException("해당 시즌 카드팩은 존재하지 않습니다.");
        given(cardService.getCardsBySeason(2L)).willThrow(e);
        //when

        //then
        mvc.perform(get("/api/card/season/{id}", 2L))
                .andDo(print())
                .andExpect(status().isNotFound())
                // ApiResponse 공통
                .andExpect(jsonPath("status").value(NOT_FOUND.value()))
                .andExpect(jsonPath("message").value(NOT_FOUND.getReasonPhrase()))
                .andExpect(jsonPath("time").exists())
                // T data
                .andExpect(jsonPath("data").value(e.getMessage()));
    }

    private static List<Card> makeCardList(CardSeason cardSeason) {
        Card card1 = Card.builder()
                .title("card1")
                .attackType(AttackType.ROCK)
                .grade(Grade.SR)
                .power(13)
                .cardSeason(cardSeason)
                .build();

        Card card2 = Card.builder()
                .title("card2")
                .attackType(AttackType.SCISSORS)
                .grade(Grade.R)
                .power(9)
                .cardSeason(cardSeason)
                .build();

        // 시즌이 다른 카드
        Card card3 = Card.builder()
                .title("card3")
                .attackType(AttackType.ALL)
                .grade(Grade.SSR)
                .power(15)
                .cardSeason(new CardSeason("season2", "imageUrl"))
                .build();

        return List.of(card1, card2, card3);
    }
}

간단하게 성공하는 케이스, 카드나 시즌을 찾지 못해서 실패하는 케이스를 작성했다.
(작성하고 보니 IllegalArgumentException이 터지는데 데이터를 못 찾았으니 Not_Found가 좋은지 요청이 잘 못 들어온거니 Bad_Request 가 좋은지 좀 헷갈림..)


그래서 이 코드가 뭐를 의미하냐??

@WebMvcTest(CardViewController.class)

Spring MVC 구조에서 지정한 Controller만 로딩해서 테스트.
(@Controller, @RestController, @ControllerAdvice 관련 빈만 등록)

Service, Repository, Component 등은 로딩되지 않는다! ➝ 직접 @MockitoBean으로 Mock 등록해야 함!

목적: Controller의 HTTP 요청/응답 로직만 단위 수준에서 검증.
즉, Web Layer 테스트만 집중할 수 있도록 도와주는 애노테이션


@AutoConfigureMockMvc

MockMvc를 자동으로 설정해주는 역할.
해당 애노테이션을 사용했기 때문에 @Autowired private MockMvc mvc; 이게 가능! (자동으로 MockMvc 빈을 등록했기 때문에 우리가 사용할 때 @Autowired로 간편하게 쓸 수 있다)

MockMvc는 HTTP 요청을 모킹해서 실제 서버 없이도 Controller를 테스트할 수 있게 해준다고 한다. mvc.perform(get("/api/card/1")) 이 코드를 통해서 가짜(Mock) 요청과 응답을 만들고 테스트 할 수 있는 것.


@MockitoBean

이 녀석은..? 스프링부트 3.4 버전부터 @MockBean 이 완전 Deprecated 되어서 사용할 수 없다.

docs를 보면 MockitoBean 이 나와있어서 이걸로 교체하니까 사용가능!

그럼 이게 뭐냐??

간단간단하게 말해서 Spring Boot 가 실행되면 자동으로 막~~ Bean이 등록이 되잖아요?? 그 Bean을 가짜 빈으로 등록한다~~~ 이 말입니다.

Controller의 단위 테스트라고 해도 앞서 @WebMvcTest 에서 말했듯이

Spring MVC 구조에서 지정한 Controller만 로딩해서 테스트

결국 Spring 을 띄운다는 뜻. 그러면 순수한 자바 코드가 아니네?? 그러면 Bean을 등록 해야하네???

→ 그래서 @MockitoBean 을 쓴다~~~ 이름에서도 알 수 있듯이 가짜(Mock) 빈(Bean)이란 말이다!


BDDMockito.given()

Card card = Card.builder()
		.title("test")
        .grade(Grade.SR)
        .attackType(AttackType.ROCK)
        .power(13)
        .cardSeason(new CardSeason("test","imageUrl"))
        .build();

given(cardService.getCardById(1L)).willReturn(card);

뭘까 이 녀석은??

  1. given() 준다. 뭘??
  2. cardService.getCardById(1L) 을.
  3. willReturn() 반환 할 것이다. (card) card 를.

일단 Card를 직접 만든다(내가 만든 Mock(ie)~). 그리고 given() 을 작성한다.

이 이후의 코드에서 cardService.getCardById(1L) 이 호출되면 진짜로 그 코드를 실행하지 말고~ 내가 직접 만든 card 를 반환해서 그걸로 테스트를 해라~~~ 라고 하신다~


그 이후..

mvc.perform(get("/api/card/{id}", 1L))
	.andDo(print())
    .andExpect(status().isOk())
    //ApiResponse 공통
    .andExpect(jsonPath("status").value(OK.value()))
    .andExpect(jsonPath("message").value(OK.getReasonPhrase()))
    .andExpect(jsonPath("time").exists())
    //T data
	.andExpect(jsonPath("data.title").value(card.getTitle()))
    .andExpect(jsonPath("data.attackType").value(card.getAttackType().getType()))
    .andExpect(jsonPath("data.grade").value(card.getGrade().name()))
    .andExpect(jsonPath("data.power").value(card.getPower()))
    .andExpect(jsonPath("data.cardSeason").value(card.getCardSeason().getSeasonName()));
                
verify(cardService).getCardById(1L);

자 여기서 get으로 /api/card/1L 을 호출한다.
그러면 controller 코드에서 cardService.getCardById(1L)을 호출 할 것이다.

그럼 아까 willReturn(card) 한 녀석이 응답으로 올 것이고 그 데이터를 가지고 .andExpect()로 확인할 것이다.

verify(cardService).getCardById(1L); 이 코드는 cardService 의 getCardById() 메서드가 호출이 되었나? 라고 묻는 것이다. 정상적으로 호출이 되었다면 테스트 성공!


아!! JpaMetamodelMappingContext 이거 뭐야?

나으 다양한 엔티티들은

@Getter
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public class TimeStamped {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createAt;

    @LastModifiedDate
    private LocalDateTime updateAt;
}

요 녀석을 extends 하고 있는데 Auditing 을 사용하기 위해

@SpringBootApplication
@EnableJpaAuditing // 이 녀석을 추가!
public class ChimCardApplication {

    public static void main(String[] args) {
        SpringApplication.run(ChimCardApplication.class, args);
    }

}

@EnableJapAuditing 을 Application 코드에 추가해줬다.

여기서 에러가 발생 하는데... 나는 에러 코드만 보고 급하게 @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; 이걸 추가했는데 다시 궁금해서 찾아보니 너무 잘 정리한 글을 발견! (맨 밑에 참고자료를 참고해 주세용 진짜 유익합니다.)

정리 중에 글 하나 더 발견해서 더 추가합니당...
(EnableJapAuditing을 Application 위에 쓰면 안되는 이유라니?! 너무 자극적인 제목이자나~)


테스트 완료!

Controller 단위 테스트 하는 방법에 대해서 여기저기 찾아보면서 작성하고 ChatGPT에게 물어보면서 어떤 기능을 하는지 제대로 작성한 것이 맞는지 몇 번이고 확인 했다.

만! 그래도 잘 못 작성한 부분이 있다면 너그러이 봐주시고.. 언제든지 댓글로 알려주시면 감사하겠습니다~

전체적으로 테스트를 어떻게 하는지 알아보긴 했는데... 그냥 가짜 응답 만들어서 그거 가지고 데이터가 제대로 넘어왔나? 확인하는건데.. 의미가 있는 테스트인지 잘 모르겠다. 테스트 코드를 제대로 작성해보자! 마음 먹은건 처음이라서.. ㅠㅠ

처음에는 단위 테스트니까 Spring 없이 돌아가나? 생각하고 @Mock 이랑 @MockBean 이 뭐야 왜 다른거야 했는데 조금만 생각하면 아주 간단한 문제였다 ㅋㅋ


@Mock vs @MockBean

진짜진짜 간단하게 말해서!

순수 자바 코드로만 테스트를 할거다? → @Mock 사용
스프링이 돌아간다? → @MockBean 사용

둘 다 가짜 객체를 만들어주는건데 Bean으로 등록하냐 아니냐의 차이 정도인 듯!


마무리

자 이렇게 Controller 단위 테스트에 대해서 아주 간단하게 알아봤다. 코드가 길어서 글이 길어지긴 했는데.. ㅠㅠ

또 테스트하는 방법을 까먹으면 와서 봐야징 ㅎㅎ

참고 자료

0개의 댓글