보통 Srping JUnit 단위 테스트를 검색하게 되면 Mock 또는 Fake 객체를 활용해 테스트하는 것을 보게 됩니다.
특히 Service Layer에서 상당수 의존하는 객체인 Repository를 제외하고 독립적인 테스트를 위해 런타임에서 사용되는 실제 객체가 아닌 가짜 객체를 활용하는 것을 많이 볼 수 있습니다.
하지만 이러한 Test Double 방식은 테스트 대상 객체에 동작 방식을 파악하고 의존하는 로직들을 일일히 구현해야 하는 번거로움이 있습니다. 또한 중간에 의존하는 로직이 추가 되게 되면 기존에 테스트들이 다 깨지게 되는 상황이 발생됩니다.
저는 이러한 부담감을 덜기 위해 빠르게 실행하는 테스트를 포기하고 Spring에서 제공하는 Test 어노테이션을 활용해 안정감 있고 최대한 테스트 시간을 최소화한 부분 통합 테스트 구현해 보았습니다.
Jpa 단위 테스트
- RepositoryTestCommon
@DataJpaTest(showSql = false)
@ImportAutoConfiguration(DataSourceDecoratorAutoConfiguration.class)
@Import({P6spyLogMessageFormatConfig.class, QuerydslConfig.class})
public class RepositoryTestCommon {
public static StopWatch stopWatch = new StopWatch();
}
@DataJpaTest 어노테이션을 활용해 테스트하였고, Querydsl 사용하였기에 이에 대한 설정과 log 데이터 바인딩 확인을 위해 p6spy 설정을 @Import 어노테이션으로 설정해 주었습니다.
또한 테스트 시간을 측정하기 위해 StopWatch 만들어 주었고 이러한 설정들은 공통화 시켜 각 Test 클래스가 상속하여 사용할 수 있도록 했습니다.
- BookInfoRepositoryTest
@DisplayName("도서 Jpa 단위 테스트")
class BookInfoRepositoryTest extends RepositoryTestCommon {
@Autowired
private BookInfoRepository bookInfoRepository;
@BeforeAll
static void testStart() {
stopWatch.start();
}
@AfterAll
static void testEnd() {
stopWatch.stop();
System.out.println("TotalTimeMillis - " + stopWatch.getTotalTimeMillis());
}
@BeforeEach
void init() {
// given
String[] titleArr = {"Hello Java", "JUnit test", "Real MySQL"};
String[] writerArr = {"홍길동", "이목룡", "임꺽정"};
for (int i = 0; i < titleArr.length; i++) {
BookInfoEntity bookInfoEntity = BookInfoEntity.builder()
.title(titleArr[i])
.type("T00" + (i + 1))
.supPrice(1000)
.fixPrice(2000)
.quantity(100)
.writer(writerArr[i])
.discount(5)
.createdAt(LocalDate.now())
.build();
bookInfoRepository.save(bookInfoEntity);
}
}
@Nested
@DisplayName("도서 검색")
class SearchBookInfo {
@Test
@DisplayName("type이 일치하고 keyword가 title에 포함되는 도서 조회")
void searchBookInfo() {
// given
String keyword = "Mysql";
String type = "T003";
SearchBookInfoReq searchBookInfoReq = SearchBookInfoReq.builder()
.keyword(keyword)
.type(type)
.offset(1)
.limit(10)
.build();
Pageable pageable = PageRequest.of(searchBookInfoReq.getOffset() -1, searchBookInfoReq.getLimit());
// when
// List<BookInfoEntity> reuslt = bookInfoRepository.findByTitleContaining(title);
Page<BookInfoRes> reuslt = bookInfoRepository.searchBookInfo(searchBookInfoReq, pageable);
pagingLog(reuslt);
// then
assertThat(reuslt.getContent()).isNotEmpty();
assertThat(reuslt.getContent().get(0).getTitle()).containsIgnoringCase(keyword);
assertThat(reuslt.getContent().get(0).getType()).isEqualTo(type);
}
...
}
}
Service to Jpa 부분 통합 테스트
- ServiceTestCommon
@DataJpaTest(showSql = false)
@ImportAutoConfiguration(DataSourceDecoratorAutoConfiguration.class)
@Import({P6spyLogMessageFormatConfig.class, QuerydslConfig.class})
public class ServiceTestCommon {
public static StopWatch stopWatch = new StopWatch();
}
Service 테스트가 주 목적이지만 의존하는 Repository를 생성하기 위해 @DataJpaTest 어노테이션을 사용하였고,
상속받는 Test 클래스는 @Import를 활용해 테스트를 원하는 Service 클래스만 생성해 조금이라도 테스트 시간을 최소화 시키고자 하였습니다.
- BookInfoServiceTest
@DisplayName("도서 Service to Jpa 테스트")
@Import(BookInfoService.class) // 테스트 시간을 최소화 하기 위해 @DataJpaTest로 의존하는 repo를 생성하고 테스트할 Service만 생성.
class BookInfoServiceTest extends ServiceTestCommon {
@Autowired
private BookInfoService bookInfoService;
...
@Nested
@DisplayName("도서 검색")
class SearchBookInfo {
@Test
@DisplayName("type이 일치하고 keyword가 title에 포함되는 도서 조회")
void searchBookInfo() {
// given
String keyword = "java";
String type = "T001";
SearchBookInfoReq searchBookInfoReq = SearchBookInfoReq.builder()
.keyword(keyword)
.type(type)
.offset(1)
.limit(10)
.build();
// when
Page<BookInfoRes> reuslt = bookInfoService.searchBookInfo(searchBookInfoReq);
// then
assertThat(reuslt.getContent()).isNotNull();
assertThat(reuslt.getTotalElements()).isEqualTo(1);
assertThat(reuslt.getContent().get(0).getTitle()).containsIgnoringCase(keyword);
assertThat(reuslt.getContent().get(0).getType()).isEqualTo(type);
}
...
}
}
전체 통합 테스트
- ControllerTestCommon
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public class ControllerTestCommon {
public static StopWatch stopWatch = new StopWatch();
}
- BookInfoControllerTest
@DisplayName("도서 통합 테스트")
class BookInfoControllerTest extends ControllerTestCommon {
@Autowired
private MockMvc mvc;
...
@Nested
@DisplayName("도서 검색")
class SearchBookInfo {
@Test
@DisplayName("type이 일치하고 keyword가 title에 포함되는 도서 조회")
void searchBookInfo() throws Exception {
// given
String keyword = "java";
String type = "T001";
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("keyword", keyword);
params.add("type", type);
params.add("offset", "1");
params.add("limit", "10");
// when
ResultActions resultActions = mvc.perform(get("/book")
.contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.params(params)
.accept(MediaType.APPLICATION_JSON))
.andDo(print());
// then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()", equalTo(1)))
.andExpect(jsonPath("$.content[0].title", containsStringIgnoringCase(keyword)))
.andExpect(jsonPath("$.content[0].type", equalTo(type)));
}
...
}
}
위와 같이 @SpringBootTest, @DataJpaTest 어노테이션으로 각 Layer별 단위, 부분 통합, 전체 통합 테스트를 구현해 보았습니다. 확실히 테스트 더블을 이용해 구현하는 것보다 많은 테스트 시간을 소요하였지만 보다 안정감 있고 테스트 코드를 구현하는데 있어 부담감이 없으며, 테스트하는 코드가 실제 사용하는 객체를 검증하며 진행되기에 외부 연동이 필요한 로직이 아니라면 이게 자연스러운 테스트 주도 개발이 아닌가? 생각해 보게 되었습니다.
댓글