코드의 개선 사항이 필요해보인다면 언제든 댓글로 지적해주세요 :)
일반적으로 테스트 케이스를 작성한다고 하는건 거의 단위 테스트를 의미하는 것이다.
통합 테스트는 여러 컴포넌트들 간의 상호작용과 호환성을 검증하는 테스트이기 때문에 테스트를 위한 비용이 많이 소모된다.
Java를 테스트 할 때 가장 흔히 사용되는 프레임워크인 JUnit을 사용해서 단위 테스트를 진행할 예정이다.
- 단위 테스트 : 하나의 모듈을 기준으로 독립적으로 검증하는 테스트 (controller, service, repository, ...)
- 테스트 코드 실행 시간이 빠름
- 하나의 테스트가 곧 하나의 문서로 역할
- 독립적인 검증이기 때문에 다른 객체와의 상호작용 필요시 가짜 객체를 정의해 주어야 함
- 가짜 객체의 답변을 직접 작성해주는 것이기 때문에 실제 환경에서의 대답과 다를 가능성 존재
- 통합 테스트 : 모듈을 통합하면서 호환성을 검증하는 테스트
- 실제 객체들 간의 상호작용을 검증하는 것이기 때문에 가짜 객체 정의 필요없음
- 실제 환경과 같은 대답 도출 가능
- 테스트 하나에 많은 비용이 들어가며 속도가 느림
- 어느 계층에서 문제가 발생하였는지 파악하기 힘듦
Spring boot Test 구조
Spring Initialize를 통해서 프로젝트를 생성하면 자동으로 테스트 관련된 의존성을 주입해주기 때문에 추가적으로 뭔가를 하지는 않았다.
일반적으로 테스트 코드는 /src/test/java/ 하위 디렉토리에 위치한다. 해당 위치에 원하는 구조로 패키지를 생성하여서 코드를 작성하면 된다. 코드 작성을 원하는 클래스에 가서 command+shift+T를 하면 자동으로 테스트 코드의 골격을 작성해준다.
Service 계층 단위 테스트
단위 테스트를 진행할 때 테스트의 주체와 협력체를 잘 구분지어야 한다. 서비스 계층에서는 비즈니스 로직이 요구 사항대로 동작하는지를 검증하면 된다.
Service 계층은 Repository 객체를 주입받아서 구동이 되기 때문에 테스트 환경에서도 가짜 Repository 객체를 생성하고 응답을 설정해주어야 한다.
- @ExtendWith(SpringExtension.class) : MockBean 어노테이션 사용을 위해 작성
- @MockBean : 가짜 객체를 생성해준다.
- @BeforeEach : 각각의 테스트 실행 전에 매번 실행되는 코드
- @DisplayName : 테스트 명을 지정해준다. 메서드명을 한글로 작성해도 된다.
- Asserj : AssertJ 라이브러리가 제공하는 메서드가 좀 더 직관적이고 많아서 해당 라이브러리의 비교로직을 사용했다.
@ExtendWith(SpringExtension.class)
class LottoServiceTest {
@InjectMocks
LottoService lottoService;
@Mock
LottoRepository lottoRepository;
private LottoDocument lotto1;
private LottoDocument lotto2;
@BeforeEach
void setUp() {
lotto1 = new LottoDocument();
lotto1.setRound(1);
lotto2 = new LottoDocument();
lotto2.setRound(2);
}
@Test
@DisplayName("전체 로또 리스트 조회 성공")
void getLottoList_success() {
Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "round"));
when(lottoRepository.findAll(pageable)).thenReturn(new PageImpl<>(List.of(lotto1,lotto2)));
Page<LottoDto> result = lottoService.getLottoList(0, 10, "round");
assertThat(result.getTotalElements()).isEqualTo(2);
assertThat(result.getContent()).hasSize(2);
verify(lottoRepository, times(1)).findAll(pageable);
}
@Test
@DisplayName("특정 회차 로또 조회 성공")
void getLottoByRound_success() {
when(lottoRepository.findByRound(1)).thenReturn(Optional.of(lotto1));
LottoDto result = lottoService.getLottoByRound(1);
assertThat(result).isNotNull();
assertThat(result.getRound()).isEqualTo(1);
verify(lottoRepository, times(1)).findByRound(1);
}
@Test
@DisplayName("특정 회차 로또 조회 실패")
void getLottoByRound_notFound() {
when(lottoRepository.findByRound(3)).thenReturn(Optional.empty());
AppException exception = assertThrows(AppException.class, () -> lottoService.getLottoByRound(3));
assertThat(exception).isNotNull();
assertThat(exception.getExceptionCode()).isEqualTo(ExceptionCode.NON_EXISTENT_LOTTO);
verify(lottoRepository, times(1)).findByRound(3);
}
}
필요한 로직과 관련된 테스트 코드를 작성해주었다. 값이 없을 경우에 대한 예외 처리도 해놨었기 때문에 해당 로직에 대한 검증도 추가했다.
로또 관련 데이터들이 entity가 아닌 document로 정의되어 있어서 그런지 모르겠지만 일반적인 값 지정 방식으로 하면 에러가 발생해서 .set() 메서드로 객체를 생성해 값을 넣어주었다.
테스트 코드를 먼저 작성하고 코드를 작성한 것이 아니었기 때문에 실제 코드에 맞춰 테스트 코드를 작성할 수 밖에 없었다. 그렇다보니 테스트 코드에서도 pageable 방식으로 객체를 생성해 비교하게 되었다.
when().then() 메서드는 Mockito에서 제공하는 것으로 상황과 응답을 지정해주는 코드라고 이해하면 된다.
Mockito는 단위 테스트에서 외부 의존성을 Mock 객체로 대체하여 테스트를 진행할 수 있게 해주는 프레임워크이다.
assertThat()는 기대값과 실제값이 일치하는지 검증해주는 메서드이다.
verify()는 특정 메서드가 정해진 횟수만큼 실행이 됐는지 검증해주는 메서드이다.
Controller 계층 단위 테스트
컨트롤러 계층에서는 요청이 잘 처리되서 올바른 값을 반환해주는지를 검증하면 된다.
JPA빈 관련 에러와 Filter 관련 에러 때문에 꽤나 골머리를 썩인 테스트였다.
- @WebMvcTest(LottoController.class) : 컨트롤러 계층 테스트를 위해 스프링에서 제공하는 어노테이션
- @MockBean(JpaMetamodelMappingContext.class) : @EnableJpaAuditing 어노테이션이 application 위에 위치하면 실행될 때마다 Jpa 관련 빈을 요구한다. 하지만 WebMvcTest는 mvc와 관련한 빈만 올려서 에러가 발생하게 된다. 따라서 MockBean으로 가짜 Jpa context를 생성해주어야 한다.
- @AutoConfigureMockMvc(addFilters = false) : Spring Security 자동 구성을 비활성화 해준다. 해당 어노테이션을 사용하면 모든 필터를 비활성화하기 때문에 인증이 필요한 로직이 있다면 다른 방법을 사용해야 한다. 로또 기능은 인증 없이 사용 가능한 기능이기 때문에 해당 방식을 사용하였다. 인증이 필요한 다른 로직에서 다른 방법을 시도해볼 예정!
@WebMvcTest(LottoController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureMockMvc(addFilters = false)
class LottoControllerTest {
@Autowired
MockMvc mvc;
@MockBean
LottoService lottoService;
@MockBean
JwtAuthenticationFilter jwtAuthenticationFilter;
LottoDto dto1;
LottoDto dto2;
@BeforeEach
void setUp() {
dto1 = new LottoDto("1", 1000000L, "2025-01-01", 1153, 1, 5000000L, "1,2,3,4,5,6");
dto2 = new LottoDto("2", 1000000L, "2025-01-01", 1154, 2, 5000000L, "1,2,3,4,5,6");
}
@Test
@DisplayName("로또 리스트 반환 받기")
void getLottoList_success() throws Exception {
Page<LottoDto> page = new PageImpl<>(List.of(dto1, dto2));
when(lottoService.getLottoList(anyInt(), anyInt(), anyString())).thenReturn(page);
mvc.perform(get("/api/lotto/list")
.param("page", "0")
.param("size", "10")
.param("sortBy", "round"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].round").value(1153))
.andExpect(jsonPath("$.content[1].round").value(1154));
}
@Test
@DisplayName("회차별 로또 반환 받기")
void getLottoByRound_success() throws Exception {
when(lottoService.getLottoByRound(1154)).thenReturn(dto2);
mvc.perform(get("/api/lotto/1154"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.round").value(1154));
}
@Test
@DisplayName("유효하지 않은 페이지 번호 넘기기")
public void testInvalidPageNumber() throws Exception {
mvc.perform(get("/api/lotto/list")
.param("page", "-1")
.param("size", "10"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.resultMessage").value("getAllLotto.page: 페이지 번호는 0 이상이어야 합니다."));
}
@Test
@DisplayName("유효하지 않은 페이지 사이즈 넘기기")
public void testInvalidPageSize() throws Exception {
mvc.perform(get("/api/lotto/list")
.param("page", "0")
.param("size", "0"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.resultMessage").value("getAllLotto.size: 페이지 크기는 1 이상이어야 합니다."));
}
@Test
@DisplayName("유효하지 않은 회차 정보 넘기기")
public void testInvalidRoundNumber() throws Exception {
mvc.perform(get("/api/lotto/1152")
.param("round", "1"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.resultMessage").value("getLottoByRound.round: 회차 번호는 1153 이상이어야 합니다."));
}
}
컨트롤러 계층에서도 입력값에 대한 예외처리를 해놓았었기 때문에 이를 검증하는 로직도 추가하여 작성했다.
MockMvc가 테스트 도중에 예외를 던질 가능성이 있기 때문에 메서드에 throws Exception 처리를 해주어야 한다.
가짜 JwtAuthenticationFilter : 필터를 모두 비활성화 해서 필요 없는줄 알았으나, 의존성 주입을 위한 객체는 필요하다해서 가짜 객체를 생성해주었다.
mvc.perform()은 컨트롤러에 보낼 요처을 생성해주는 메서드이다. 문법이 직관적이라 이해하는데 쉬워서 좋다.
레포지토리 계층에 대해서도 단위 테스트를 진행하려 계속 노력했지만 반복되는 에러가 날 너무 괴롭게해서 일단 두가지의 단위 테스트만 진행하였다. 레포지토리 단위 테스트에서는 CRUD가 잘되는지 검증하면 된다.
'Dev Tool > Spring boot' 카테고리의 다른 글
[Spring] @Scheduled와 JavaMailSender를 이용한 자동 메일 발송 기능 (0) | 2025.01.31 |
---|---|
[Spring] 장바구니 기능 구현 (0) | 2025.01.25 |
[Spring] 테스트 코드 기본 개념 (0) | 2025.01.22 |
[Spring] 로또 API 구현(2) & 예외 처리(@validate) (0) | 2025.01.22 |
[Spring] Swagger 연동 (1) | 2025.01.22 |