테스트코드

스프링 테스트 작성 2. 서비스 계층 테스트

Recfli 2024. 3. 29. 21:58

 

 

[ 서비스 설계 ]

 리포지토리 설계 당시 설명을 덜하고 테스트를 하지 않은 쿼리가 하나 있었다. 바로 아래의 쿼리이다. 우선 이렇게 만든 이유는 가게 하나를 가져오는데 조건에 맞는 모든 가게를 한번에 퍼올려서 메모리에서 가져오기 싫어서이다.

public Shop findShopBySearchCondAndOffset(ShopSearchCond cond, int offset){
    return queryFactory
            .select(shop)
            .from(shop)
            .where(
                    priceBetween(cond),
                    schoolTypesEq(cond),
                    shopTypesIn(cond),
                    timeInOpeningHours(cond)
            )
            .offset(offset)
            .limit(1L)
            .fetchOne();
}

 

 어차피 페이징을 빼면 통과한 쿼리와 동일하니 문제가 없을 것이라고 생각하며 서비스를 작성했다. 우선 서비스를 생각하기 전에 서비스 계층에서도 앞에서의 시간과 비슷한 문제가 또 발생한다. 바로 비즈니스 조건 중 하나인 랜덤한 식당 중 하나를 가져와야 하는 내용이 시간 문제와 비슷하다. 그래서 아래와 같이 offset을 외부에서 파라미터로 받는 메서드를 만들어주고 Count 쿼리로 전체 크기를 가져와서 실제 비즈니스 상황에 쓸 메서드도 하나 만들어주었다. 어떻게 보면 이전에 통제할 수 없었던 현재 시간을 처리하는 것과 같은 맥락으로 보면 될 것 같다.

 

  그래서 처리를 했고 결과가 없는 경우에는 일단을 에러를 내보내게 만들었고 Controller 단에서 추가적인 로직을 구현해주는 것으로 일단 생각을 했다.

 

 추가적으로 왜 @Transcactional에 readOnly=true인지를 물어볼 수 있을 것 같다. 해당 서비스는 조회만 해온다. 값을 수정할 일이 없다. JPA를 사용하면 DB에서 데이터를 퍼올릴 때 기본적으로 변경 감지를 위해 스냅샷을 떠놓는다.  그런데 수정할 일이 없으니 그 자원을 아끼기 위해서 다음과 같이 작성을 한 것이다.

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ShopService {

    private final ShopQuerydslRepository shopQuerydslRepository;
    Random random = new Random();

    public Shop findRandomShop(ShopSearchServiceRequest request, int offset){

        ShopSearchCond cond = createShopSearchCondWithoutTime(request);
        Shop findShop = shopQuerydslRepository.findShopBySearchCondAndOffset(cond, offset);

        if(findShop == null){
            throw new IllegalArgumentException("조건에 맞는 식당이 존재하지 않습니다.");
        }

        return findShop;
    }

    public Shop findRandomShop(ShopSearchServiceRequest request){

        ShopSearchCond cond = createShopSearchCondWithoutTime(request);
        Long shopCount = shopQuerydslRepository.findShopCount(cond);
        if(shopCount == 0){
            throw new IllegalArgumentException("조건에 맞는 식당이 존재하지 않습니다.");
        }

        int offset = random.nextInt(Math.toIntExact(shopCount));
        return findRandomShop(request, offset);
    }


    private ShopSearchCond createShopSearchCondWithoutTime(ShopSearchServiceRequest request) {
        return ShopSearchCond.builder()
                .shopTypes(request.getShopTypes())
                .schoolType(request.getSchoolType())
                .priceMin(request.getPriceMin())
                .priceMax(request.getPriceMax())
                .currentTime(request.getCurrentTime())
                .build();
    }
}

[ 테스트 ]

 리포지토리 계층에서 데이터가 잘 가져와진다는 것은 확인했으니 서비스 계층에서는 count 쿼리로 offset을 만드는데 그 offset부분이 값에 따라 잘 동작하는지만 확인해주고 검색 결과가 없을 때 예외가 발생하는 부분만 체크를 해주면 된다고 생각했다. 

 

 아래 부분은 다이나믹 테스트의 일부로 offset 경계값으로 시작, 중간, 마지막이 잘 동작하는지까지만 확인을 했다.

@DisplayName("선택한 옵션에 따른 식당은 단 1개만 선택할 수 있다.")
@TestFactory
Collection<DynamicTest> findShopTest(){
    // given
    Shop shop1 = createShop("식당1", 1000, RESTAURANT, NSC,
            LocalTime.of(17, 0), LocalTime.of(20, 0));
    Shop shop2 = createShop("식당2", 2000, RESTAURANT, NSC,
            LocalTime.of(17, 0), LocalTime.of(23, 0));
    Shop shop3 = createShop("식당3", 3000, RESTAURANT, NSC,
            LocalTime.of(17, 0), LocalTime.of(0, 0));
    Shop shop4 = createShop("식당4", 3000, RESTAURANT, NSC,
            LocalTime.of(17, 0), LocalTime.of(8, 0));
    Shop shop5 = createShop("식당5", 3000, PUB, HSSC,
            LocalTime.of(17, 0), LocalTime.of(23, 0));
    Shop shop6 = createShop("식당6", 3000, PUB, HSSC,
            LocalTime.of(17, 0), LocalTime.of(5, 0));
    shopRepository.saveAll(List.of(shop1, shop2, shop3, shop4, shop5, shop6));

    return List.of(
            DynamicTest.dynamicTest("원하는 조건에 따른 식당을 1개 가지고 온다.", () ->{
                ShopSearchServiceRequest request = createFindShopRequest(List.of(RESTAURANT), NSC, LocalTime.of(22,0));

                // when
                Shop findShop = shopService.findRandomShop(request, 0);
                // then
                assertThat(findShop)
                        .extracting("name", "price")
                        .containsExactly(
                                "식당2", 2000
                        );
            })

 

  에러가 발생하는 부분은 간단하게 AssertThatThrownBy로 확인하고 내가 설정한 예외랑 같은 에러인지 확인하였다. 이 에러 테스트를 하면서 Service부분에서 random값을 만들 때 count가 0이면 다른 에러가 나는 것을 확인할 수 있었다. 그래서 count 쿼리있는 부분에서 값이 0이면 내가 설정한 예외를 던지게 만든 것이다. 

@DisplayName("검색 조건에 해당하는 식당이 없는 경우, 예외를 내보낸다.")
@Test
void findShopNoSearchResult(){
    // given
    Shop shop1 = createShop("식당1", 1000, RESTAURANT, NSC,
            LocalTime.of(17, 0), LocalTime.of(20, 0));
    Shop shop2 = createShop("식당2", 2000, RESTAURANT, NSC,
            LocalTime.of(17, 0), LocalTime.of(23, 0));
    shopRepository.saveAll(List.of(shop1, shop2));

    ShopSearchServiceRequest request = createFindShopRequest(List.of(RESTAURANT), NSC, LocalTime.of(1,0));

    // when // then
    assertThatThrownBy(()->shopService.findRandomShop(request))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("조건에 맞는 식당이 존재하지 않습니다.");
}

 

[ 참고 자료 ]

 Practical Testing: 실용적인 테스트 가이드(박우빈 강의)