옛날에 백기선님의 인프런 강의 더 자바, 애플리케이션을 테스트 하는 다양한 방법을 들었었는데...
하.. 옛날의 나는 강의를 따라 듣기만 하고 따로 정리는 하지 않았다. 요즘 정리하는 습관을 만들려고 하다보니 이게... 너무 좋다!! 시간이 지나서 내가 그때 뭐 했었는지 어떤 깨달음을 얻었는지 어떤 삽질을 했는지, 그리고 해당 기술을 어떻게 사용하는지도 바로바로 알 수 있다.
(진작 좀 하지 그랬어..)
간단하게 프로젝트를 만들면서 테스트 코드를 작성해보자.
이번엔 Controller의 단위 테스트!!
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 가 좋은지 좀 헷갈림..)
Spring MVC 구조에서 지정한 Controller만 로딩해서 테스트.
(@Controller, @RestController, @ControllerAdvice 관련 빈만 등록)
Service, Repository, Component 등은 로딩되지 않는다! ➝ 직접 @MockitoBean으로 Mock 등록해야 함!
목적: Controller의 HTTP 요청/응답 로직만 단위 수준에서 검증.
즉,Web Layer 테스트만 집중할 수 있도록 도와주는 애노테이션
MockMvc를 자동으로 설정해주는 역할.
해당 애노테이션을 사용했기 때문에 @Autowired private MockMvc mvc; 이게 가능! (자동으로 MockMvc 빈을 등록했기 때문에 우리가 사용할 때 @Autowired로 간편하게 쓸 수 있다)
MockMvc는 HTTP 요청을 모킹해서 실제 서버 없이도 Controller를 테스트할 수 있게 해준다고 한다. mvc.perform(get("/api/card/1")) 이 코드를 통해서 가짜(Mock) 요청과 응답을 만들고 테스트 할 수 있는 것.
이 녀석은..? 스프링부트 3.4 버전부터 @MockBean 이 완전 Deprecated 되어서 사용할 수 없다.

docs를 보면 MockitoBean 이 나와있어서 이걸로 교체하니까 사용가능!
그럼 이게 뭐냐??
간단간단하게 말해서 Spring Boot 가 실행되면 자동으로 막~~ Bean이 등록이 되잖아요?? 그 Bean을 가짜 빈으로 등록한다~~~ 이 말입니다.
Controller의 단위 테스트라고 해도 앞서 @WebMvcTest 에서 말했듯이
Spring MVC 구조에서 지정한 Controller만 로딩해서 테스트
결국 Spring 을 띄운다는 뜻. 그러면 순수한 자바 코드가 아니네?? 그러면 Bean을 등록 해야하네???
→ 그래서 @MockitoBean 을 쓴다~~~ 이름에서도 알 수 있듯이 가짜(Mock) 빈(Bean)이란 말이다!
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);
뭘까 이 녀석은??
given()준다. 뭘??cardService.getCardById(1L)을.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() 메서드가 호출이 되었나? 라고 묻는 것이다. 정상적으로 호출이 되었다면 테스트 성공!
나으 다양한 엔티티들은
@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 사용
스프링이 돌아간다? → @MockBean 사용
둘 다 가짜 객체를 만들어주는건데 Bean으로 등록하냐 아니냐의 차이 정도인 듯!
자 이렇게 Controller 단위 테스트에 대해서 아주 간단하게 알아봤다. 코드가 길어서 글이 길어지긴 했는데.. ㅠㅠ
또 테스트하는 방법을 까먹으면 와서 봐야징 ㅎㅎ