[Spring] service 단위 테스트 하기 - DB 와 독립된 테스트 환경 구축 (service unit test)
📌 이번 포스팅의 목적은 "Repository 에 의존하지 않는 Service Test" 와 "항상 성공하는 테스트 환경" 을 구축해보는 것입니다!
지금 사내에서는 service : repository = 1:1 구조로 가고있는데 repository 에서 조회하는 데이터 값의 조건에 따라서 다르게 흘러가는 로직을 테스트하고 싶었습니다.
한마디로 Repository 의 의존도를 가지고있는 Service 레이어를 테스트하고싶었는데
유닛테스트로 빠르게, 데이터베이스에 의존하지 않게 테스트하면서 Mocking 보다는 실제환경과 유사하게 테스트환경을 구축하고 싶었습니다.
👏🏻 즉, 조건을 정리하자면
- 유닛테스트
- 테스트에서 Repository 의존도 분리
- mocking 말고 실 데이터 사용
- 항상 성공하는 테스트 작성
→ 2번과 4번이 약간 동일한거 같기는 한데, "항상 성공하는 테스트" 란 실제 데이터베이스가 어떠한 이유로 먹통일떄 (오라클 회사 화재 라든가) 그럼에도 불구하고 테스트는 항상 성공해야한다는 의미를 담았습니다.
처음에는 테스트용 db 를 h2 로 분리해서 할까하면서 작업하다가, 이것또한 통합테스트이며, 오히려 데이터를 세팅해주는 시간때문에 더 느리겠다고 판단해 다른 방안을 고민해야했습니다.
시도한 방법은 매우 간단합니다!
단순하게, JPARepository 와 Reposiroty 인터페이스의 Test 전용 구현체를 만드는 것입니다.
사실 먼 옛날에 존재하던 (6개월 전) 사수님이 언급해주시고 간 방법인데, 이제야 시도해보네요.
📌 1. Service 와 Repository Production 코드 분석하기
언제나 그렇듯 작업해야하는 부분이, 제가 처음보는 코드쪽과 살짝살짝씩 걸쳐있어 분석이 필요했습니다.
Service Layer
- 대충 코드를 살펴보면 이런식으로 조건에 따라 존재하는 데이터를 가져오고 없으면 새로생성 → 있으면 업데이트를 해주는 Service 코드입니다.
- getEntityByCase 메소드는 queryDsl 로 구현해주고있는 커스텀 메소드인 상황입니다.
public void serviceMethod(String Param 1, String Param 2, String Param 3) {
//기존에 있는 데이터인지 찾고
final Optional<Entity> findEntity = entityRepository.getEntityByCase(param 1, param 2,..);
//있으면 데이터를 업데이트
if (findEntity.isPresent()) {
findEntity.get().updateFiled1(updateFilde_data1);
findEntity.get().updateFiled2(updateFilde_data2);
return;
}
//없으면 새로 생성
save(userPet, weight, bodyShape);
}
public void save(String Param 1, String Param 2, String Param 3){
entityRepository.save(
new Entity(param1, param2, param3);
);
}
여기서 제가 하고싶은 것은 아래 2가지를 단위테스트로 작성하고싶었습니다.
- 데이터가 있으면 업데이트 로직을 타는지
- 존재하는 데이터가 없으면 새롭게 데이터가 잘 저장되는지
📌 2. Service Test 에서 Repository 의존성 분리
구현체로 모든 메소드를 Overide 해서 새롭게 정의해줍니다.
사족이 길었지만 방법은 매우 간단합니다.
- 테스트코드 먼저 보자면, 아래처럼 단순하게 Respository 구현체를 커스텀하게 만들어 준 후, Test 에서 선언해 준 Service 클래스에 주입해 주었습니다.
- 이렇게 함으로써, 서버를 띄울 필요도, 데이터베이스가 필요하지도 않게되는거죠
👏🏻 즉, 이제 해야할 일은 데이터베이스의 기능을 직접 구현해주는 일입니다.
ServiceTest Code 예시
class EntityServiceTest {
EntityRepository entityRepository = new EntityRepositoryTestImpl();
EntityService entityService = new EntityService(entityRepository);
@Test
@DisplayName("새로운 데이터 등록 테스트")
void newCreateEntityData(){
//given
String param1 = "이름";
String param2 = "두번째 정보";
String param3 = "세번째 정보";
//when
entityService.serviceMethod(param1, param2, param3);
//then
assertThat(entityRepository.findAll()).hasSize(1);
}
@Test
@DisplayName("정보가 존재할때 등록 시 기존데이터 업데이트")
void createUpdateEntityData(){
//given
String param1 = "이름";
String param2 = "두번째 정보";
String param3 = "세번째 정보";
String updateParam1 = "업데이트 정보1";
String updateParam2 = "업데이트 정보2";
entityService.save(param1, param2, param3);
//when
userPetWeightDiarySeparate.serviceMethod(param1, param2, param3);
//then
assertThat(entityRepository.findAll()).hasSize(1);
//then
Optional<Entity> entity = entityRepository.getEntityByCase(param1, param2, param3);
assertThat(entity).isPresent();
assertThat(entity.get().getParam1()).isEqualTo(updateParam1);
assertThat(entity.get().getParam2()).isEqualTo(updateParam2);
}
}
📌 3. Custom Repository 구현체
되게 거창해보이지만 코드로 보면 정말 간단합니다.
보통 JPA를 사용한다면, Repository 인터페이스가 JPARepository 를 extends 받는 경우가 거의 대부분이기 때문에 기본적으로 하이버네트에서 제공하는 메소드들을 다시 재정의하는 과정입니다.
아래처럼 필요한 부분은 쓱싹 구현해주고, "✨ 마치 DB 인 것처럼 ~" 동작만 가능하도록 구현해줍니다.
public class EntityRepositoryTestImpl implements EntityRepository {
//DB 데이터를 CRUD 해줄 List → 꼭 리스트일 필요는 없다고 생각합니다.
public static final List<Entity> DATA_BASE = new ArrayList<>();
@Override
public List<ResponseDto> customMethod1(Dto dto) {
return null;
}
@Override
public void customDeleteMethod1(Dto dto) {
}
...생략
@Override
public Optional<Entity> getEntityByCase(String param1, String param2) {
return DATA_BASE.stream()
.filter(entity ->
entity.eqIdx(param1) && entity.getParam2().equals(param2))
.findFirst();
}
@Override
..기타 jpa 메소드
@Override
public <S extends 내Entity> S save(S entity) {
long idx;
if (DATA_BASE.isEmpty()) {
idx = 1L;
}
else{
idx = DATA_BASE.get(DATA_BASE.size() - 1).getIdx() + 1;
}
내Entity origin = new 내Entity(idx, entity.getParam1(), entity.getParam2(),
entity.getParam3());
DATA_BASE.add(origin);
return (S) origin;
}
@Override
public <S extends 내Entity> List<S> saveAll(Iterable<S> entities) {
return null;
}
@Override
public Optional<UserPetWeightDiary> findById(Long aLong) {
return Optional.empty();
}
..생략생략
}
막상 직접해보니 굉장히 간단하지만 이렇게 함으로써
- Service mocking 없이 unit test → 통합테스트 보다 월등한 속도
- Repository 의존성 분리
- DB 영향없이 항상성공하는 테스트 케이스 작성
이라는 장점을 얻을 수 있었습니다.
단점이라면
- 마치 DB 처럼 동작하도록 구현해야하는 리소스
- 그걸 위해, DB 동작과정을 내가 다알고 있어야한다는 것
- 요구사항이 변경되면 테스트 유지비용이 2배
이정도 단점이 존재하는 것 같습니다.
단순한 조회 테스트라면 굳이 안해도되는 것 같고, 뭔가 데이터에대한 조건처리가 존재하다면 상황에 따라 좋은 케이스가 될 수 있을 것 같아 기록으로 남깁니다!!
✨오늘도 끝!!✨