Spring/Test-Driven Develop

[Spring] service 단위 테스트 하기 - DB 와 독립된 테스트 환경 구축 (service unit test)

민돌v 2023. 1. 13. 22:27

 

 

📌 이번 포스팅의 목적은 "Repository 에 의존하지 않는 Service Test" 와 "항상 성공하는 테스트 환경" 을 구축해보는 것입니다!


 

지금 사내에서는 service : repository = 1:1 구조로 가고있는데 repository 에서 조회하는 데이터 값의 조건에 따라서 다르게 흘러가는 로직을 테스트하고 싶었습니다.

 

한마디로 Repository 의 의존도를 가지고있는 Service 레이어를 테스트하고싶었는데

유닛테스트로 빠르게, 데이터베이스에 의존하지 않게 테스트하면서 Mocking 보다는 실제환경과 유사하게 테스트환경을 구축하고 싶었습니다.

 

👏🏻 즉, 조건을 정리하자면

  1. 유닛테스트
  2. 테스트에서 Repository 의존도 분리
  3. mocking 말고 실 데이터 사용
  4. 항상 성공하는 테스트 작성

→ 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가지를 단위테스트로 작성하고싶었습니다.

  1. 데이터가 있으면 업데이트 로직을 타는지
  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();
    }

    ..생략생략
}

 

 

막상 직접해보니 굉장히 간단하지만 이렇게 함으로써

  1. Service mocking 없이 unit test → 통합테스트 보다 월등한 속도
  2. Repository 의존성 분리
  3. DB 영향없이 항상성공하는 테스트 케이스 작성

이라는 장점을 얻을 수 있었습니다.

 

단점이라면

  1. 마치 DB 처럼 동작하도록 구현해야하는 리소스
  2. 그걸 위해, DB 동작과정을 내가 다알고 있어야한다는 것
  3. 요구사항이 변경되면 테스트 유지비용이 2배

 

이정도 단점이 존재하는 것 같습니다.

 

단순한 조회 테스트라면 굳이 안해도되는 것 같고, 뭔가 데이터에대한 조건처리가 존재하다면 상황에 따라 좋은 케이스가 될 수 있을 것 같아 기록으로 남깁니다!!

 


 

✨오늘도 끝!!✨