스프링 테스트 작성 1. 도메인과 리포지토리 설계 및 테스트

2024. 3. 28. 00:56테스트코드

 

[ 비즈니스 상황 ]

 

 결과적으로 한 줄 요약하면 학교 주변 식당 추천기이다. 과거에 했었던 프로젝트이고 조금 더 현실적이고 어려운 테스트 상황을 만들기 위해 실시간 옵션과 식당의 시작 시간과 종료 시간이라는 검색 조건을 추가하였다. 이 서비스 상황에 따라 어떻게 설계하고 테스트하며 작성할 지를 Practical Testing: 실용적인 테스트 가이드(인프런 박우빈) 강의를 들으며 배운 내용을 토대로 작성을 해보고자 한다.

 

사용될 기술은 MySQL, H2, Spring boot 2.7.16, Jpa, Querydsl로 과거 프로젝트는 타임리프로 제작했지만 이번엔 Restful하게 제작할 예정이다.


[ 도메인 설계 ]

 단순 추천 시스템이기 때문에 테이블은 딱 1개만 존재한다. 테이블 내부에는 식당의 이름, 검색 조건, 시간, 설명 등의 내용이 들어가 있다. JPA로 작성을 하면 다음처럼 작성할 수 있다.

 

Shop.java

@Entity
@NoArgsConstructor
@Getter
public class Shop {

    @Id @GeneratedValue
    @Column(name = "SHOP_ID")
    private Long Id;

    private String name;

    private int price;

    @Enumerated(EnumType.STRING)
    private ShopType shopType;

    @Enumerated(EnumType.STRING)
    private SchoolType schoolType;

    private String url;

    private String description;
    private String menuDescription;

    private String xPosition;
    private String yPosition;

    private LocalTime openTime;
    private LocalTime closeTime;

    @Builder
    public Shop(String name, int price, ShopType shopType, SchoolType schoolType, 
                String url, String description, String menuDescription,
                String xPosition, String yPosition,
                LocalTime openTime, LocalTime closeTime) {
        this.name = name;
        this.price = price;
        this.shopType = shopType;
        this.schoolType = schoolType;
        this.url = url;
        this.description = description;
        this.menuDescription = menuDescription;
        this.xPosition = xPosition;
        this.yPosition = yPosition;
        this.openTime = openTime;
        this.closeTime = closeTime;
    }
}

 

내부 Enum 클래스로 다음처럼 가게 종류와 학교 종류도 따로 작성을 했다.

@Getter
@RequiredArgsConstructor
public enum ShopType { // 식당 종류
    RESTAURANT("식당"),
    CAFE("카페"),
    PUB("술집");

    private final String text;
}

@Getter
@RequiredArgsConstructor
public enum SchoolType { // 캠퍼스 종류
    NSC("자연과학캠퍼스"),
    HSSC("인문사회캠퍼스");

    private final String text;
}

 

 맨 아래 생성자에 builder를 놓은 이유는 테스트 상황에서 필요한 인자만 순서에 관계 없이 편하게 넣기 위해서이다. 식당 검색 조건에 따라 정확한 값을 가져오는데에 있어서 위의 Shop 객체에서 필요없는 정보는 검색 조건과 객체 구분을 위한 이름을 제외한 정보들이다. 따라서 이들을 비우고 작성하고 테스트 코드 상의 가독성을 높이기 위해서 builder를 사용한다.

 

 예를 들어 builder를 사용하면 아래처럼 테스트에 정말 필요한 정보만 있는 객체를 생성할 수 있다. 

Shop shop = Shop.builder()
        .name("식당1")
        .price(1000)
        .shopType(ShopType.RESTAURANT)
        .schoolType(SchoolType.NSC)
        .openTime(LocalTime.of(10, 0))
        .closeTime(LocalTime.of(22, 0))
        .build();

shopRepository.save(shop);

Shop shop2 = Shop.builder()
        .name("식당2")
        .price(2000)
        .shopType(ShopType.CAFE)
        .schoolType(SchoolType.NSC)
        .openTime(LocalTime.of(9, 0))
        .closeTime(LocalTime.of(17, 0))
        .build();

shopRepository.save(shop2);

 

 이제 여기서 저 builder를 리팩토링해서 따로 메서드로 빼버리고 작성을 하면 아래처럼 더 간단하게 작성할 수도 있다.

// 데이터 삽입 부분
Shop shop1 = createShop("식당1", 1000, RESTAURANT, NSC, LocalTime.of(10,0), LocalTime.of(22, 0));
Shop shop2 = createShop("식당2", 2000, CAFE, NSC, LocalTime.of(9,0), LocalTime.of(17, 0));
shopRepository.saveAll(List.of(shop1, shop2));

// 추출 메서드 
private Shop createShop(String name, int price, ShopType shopType, SchoolType schoolType,
                        LocalTime openTime, LocalTime closeTime) {
    return Shop.builder()
            .name(name)
            .price(price)
            .shopType(shopType)
            .schoolType(schoolType)
            .openTime(openTime)
            .closeTime(openTime)
            .build();
}

[ 리포지토리 설계 ]

 기본적인 CRUD를 위해 ShopRepository를 만들었고 단순하게 JpaRepository를 상속받아 만들었다.

@Repository
public interface ShopRepository extends JpaRepository<Shop, Long> {
}

 

 Querydsl로 쿼리를 만든 부분은 검색 부분으로 식당 종류, 학교 종류, 가격, 운영 시간에 따라 가게를 검색할 수 있게 만들었다. 이 때, 랜덤하게 하나 가져오는 것도 포함되어야 하기에 카운트용 쿼리도 하나 따로 만들었다. 또한 이전에 Querydsl을 통한 리팩토링 과정에서 리포지토리 계층에 서비스 로직까지 넣어버린 것 같아 이를 서비스로 분리하기로 결정하였다.

@Repository
@RequiredArgsConstructor
public class ShopQuerydslRepository {

    private final JPAQueryFactory queryFactory;

    public List<Shop> findAllBySearchCond(ShopSearchCond cond){
        return queryFactory
                .select(shop)
                .from(shop)
                .where(
                        priceBetween(cond),
                        schoolTypesEq(cond),
                        shopTypesIn(cond),
                        timeInOpeningHours(cond)
                )
                .fetch();
    }

    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();
    }

    public Long findShopCount(ShopSearchCond cond) {
        Long shopCount = queryFactory
                .select(shop.count())
                .from(shop)
                .where(
                        priceBetween(cond),
                        schoolTypesEq(cond),
                        shopTypesIn(cond),
                        timeInOpeningHours(cond)
                )
                .fetchOne();
        return shopCount;
    }

    private BooleanExpression shopTypesIn(ShopSearchCond cond) {
        if(cond.getShopTypes() == null)
            return null;
        return shop.shopType.in(cond.getShopTypes());
    }

    private BooleanExpression schoolTypesEq(ShopSearchCond cond) {
        if(cond.getSchoolType() == null){
            return null;
        }
        return shop.schoolType.eq(cond.getSchoolType());
    }

    private BooleanExpression priceBetween(ShopSearchCond cond) {
        if(cond.getPriceMin() == null || cond.getPriceMax() == null)
            return null;
        return shop.price.between(cond.getPriceMin(), cond.getPriceMax());
    }

    private BooleanExpression timeInOpeningHours(ShopSearchCond cond){
        if(cond.getCurrentTime() == null){
            return null;
        }

        BooleanExpression betweenOpenAndCloseTime = shop.openTime.before(cond.getCurrentTime())
                .and(shop.closeTime.after(cond.getCurrentTime()));

        // 오픈 시간은 오후 5시인데 영업 종료시간은 새벽인 경우를 위한 처리
        BooleanExpression afterOpenAndBeforeCloseTime = shop.openTime.after(shop.closeTime)
                .and(shop.openTime.before(cond.getCurrentTime()).or(shop.closeTime.after(cond.getCurrentTime())));

        return betweenOpenAndCloseTime.or(afterOpenAndBeforeCloseTime);
    }
}
@Data
public class ShopSearchCond {

    private SchoolType schoolType;
    private List<ShopType> shopTypes;
    private Integer priceMin;
    private Integer priceMax;
    private LocalTime currentTime;

    @Builder
    public ShopSearchCond(SchoolType schoolType, List<ShopType> shopTypes, Integer priceMin, Integer priceMax, LocalTime currentTime) {
        this.schoolType = schoolType;
        this.shopTypes = shopTypes;
        this.priceMin = priceMin;
        this.priceMax = priceMax;
        this.currentTime = currentTime;
    }
}

 

 바로 위에 보면 timeInOpeningHours라는 약간은 복잡한 조건이 있다. 이렇게 설정한 이유는 영업시간이 다음날을 넘기는 가게들 때문에 넣었다. 저 조건이 null이 아니라면 쿼리가 아래처럼 나가게 되는데 다음날을 넘기는 가게들은 오픈 시간보다 종료 시간이 더 빠르므로 그 경우에만 우선 순위를 고려해서 쿼리를 짜주었다. 

 

*** 참고로 쿼리에선 and가 or보다 우선 순위가 높다. 그래서 괄호들이 생략되어 있어서 조금은 헷갈려 보일 수 있다. 사실 시간 부분만 딱 떼놓고 보면 괄호가 다음처럼 쳐져있다고 생각하면 된다.

(shop_open_time < ? and shop_close_time > ?)
	or (shop_open_time > shop_close_time and (shop_open_time < ? or shop_close_time > ? ))

 

여기서 중요한 부분은 ShopSearchCond라는 이름으로 따로 조건 전체를 담는 객체에서 currentTime을 LocalTime.now()로 놓지 않았다는 점이다. 이렇게 한 이유는 LocalTime.now()는 현재 시간 정보라서 실행 시점에 따라 바뀌는 값이다. 그런데 테스트를 하는데 현재 시간 정보 같이 고정되지 않은 값이 존재한다면, 테스트는 특정 시간에는 통과하고 특정 시간에는 통과하지 못하게 될 것이다. 테스트를 하지 않을 것이라면 상관이 없지만 테스트를 한다면, 이런 경우 반드시 분리해서 외부에서 시간을 받을 수 있게 메서드를 작성해야 한다. 


[ 테스트 과정 ]

 테스트를 하기 전에 테스트 환경과 실제 운영 환경을 분리할 필요가 있다. 왜냐하면 운영 서버에 있는 값으로 검사를 하는 경우, 기존의 데이터가 테스트 과정에 끼어들게 된다. 그러면 코드 상에는 문제가 없는데 데이터가 추가됨에 따라 기존에 만든 테스트가 깨지게 된다. 분리를 하는 방법은 간단하다. 사람마다 취향의 차이일 것 같은데, 개인적으로는 application.properties가 보기 좋다고 생각하고 운영환경과 테스트의 resources에 각각 따로 정의하는 방식을 선호한다.

 

 테스트 환경과 실제 환경을 구분하면 좋은 점은 하나 더 있는데, 운영 환경은 MySQL DB로 하고 테스트 환경은 InMemory로도 돌릴 수 있는 H2 DB를 사용할 수 있다는 점이다. 따라서 이에 맞추어 코드를 작성했다.

// 운영 환경 - main/resources/application.properties
# MySQL 커넥션 설정
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/demo?serverTimezone=UTC&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=1234

# DB 방언 설정
spring.jpa.database=mysql
spring.jpa.database-platform=org.hibernate.dialect.MySQL5Dialect

# Spring Table 생성 설정
spring.jpa.generate-ddl=true
spring.jpa.hibernate.ddl-auto=create

# SQL 로그 출력 설정
spring.jpa.show-spl=true
spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true

// 테스트 환경 - test/resources/application.properties
# SQL 로그 출력 설정
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

 

 또한 테스트 환경에 관한 설정도 아래와 같이 세팅하였다. DB임에도 SpringBootTest로 한 이유는 @Transactional이 추가되어 @AfterEach로 데이터를 지우는 수고로움이 덜고 이에 대한 고민이 줄겠지만 실제 Service 같은 계층에서 제대로 @Transactional 설정이 안되어있는데 테스트는 통과해서 운영과 테스트가 다른 사고를 막을 수 있다. 또한 @AfterEach에서 deleteAllInBatch()를 사용하면 SQL문이 벌크로 한번에 날아가기 때문에 다음처럼 설정을 하였다.

@SpringBootTest
class ShopQuerydslRepositoryTest {

    @Autowired
    ShopQuerydslRepository shopQuerydslRepository;

    @Autowired
    ShopRepository shopRepository;


    @AfterEach
    void tearDown(){
        shopRepository.deleteAllInBatch();
    }

 

 테스트는 각각의 조건들이 정상작동하는지 확인하고 모든 조건을 한번씩 검사했다면, 마지막에 조건을 1개부터 4개까지 늘려가며 다이나믹 테스트를 했다.  테스트 코드가 너무 길어서 아래 마지막에 확인한 코드만 올리도록 하겠다. 다이나믹 테스트를 하면 참 좋은 게 공통 테스트 환경 코드의 반복을 줄여준다는 점이 좋은 것 같다.

 

 그리고 ShopSearchCond를 생성할 떄에 시간은 currentTime.now()가 아닌 값을 넣어주어 시간에 따라 테스트가 꺠지지 않도록 만들어준 걸 확인할 수 있다. 

@DisplayName("식당 종류, 학교 종류, 가격, 시간으로 식당을 검색할 수 있다.")
@TestFactory
Collection<DynamicTest> findByShopSearchCondDynamicTest(){
    // given
    Shop shop1 = createShop("식당1", 1000, RESTAURANT, NSC,
            LocalTime.of(1, 0), LocalTime.of(8, 0));
    Shop shop2 = createShop("식당2", 2000, RESTAURANT, NSC,
            LocalTime.of(8, 0), LocalTime.of(16, 0));
    Shop shop3 = createShop("식당3", 3000, RESTAURANT, NSC,
            LocalTime.of(1, 0), LocalTime.of(8, 0));
    Shop shop4 = createShop("식당4", 3000, RESTAURANT, NSC,
            LocalTime.of(8, 0), LocalTime.of(16, 0));
    Shop shop5 = createShop("식당5", 3000, PUB, NSC,
            LocalTime.of(16, 0), LocalTime.of(23, 0));
    Shop shop6 = createShop("식당6", 3000, PUB, HSSC,
            LocalTime.of(16, 0), LocalTime.of(23, 0));
    shopRepository.saveAll(List.of(shop1, shop2, shop3, shop4, shop5, shop6));


    return List.of(
            DynamicTest.dynamicTest("식당이 속한 학교 종류와 식당 종류로 식당을 검색할 수 있다.", () ->{
                // given
                ShopSearchCond cond = ShopSearchCond.builder()
                        .schoolType(NSC)
                        .shopTypes(List.of(RESTAURANT))
                        .build();
                // when
                List<Shop> shops = shopQuerydslRepository.findAllBySearchCond(cond);
                // then
                assertThat(shops).hasSize(4)
                        .extracting("name", "price")
                        .containsExactlyInAnyOrder(
                                tuple("식당1", 1000),
                                tuple("식당2", 2000),
                                tuple("식당3", 3000),
                                tuple("식당4", 3000)
                        );
            }),
            DynamicTest.dynamicTest("식당이 속한 학교 종류, 식당 종류, 가격으로 식당을 검색할 수 있다. ", () ->{
                // given
                ShopSearchCond cond = ShopSearchCond.builder()
                        .schoolType(NSC)
                        .shopTypes(List.of(RESTAURANT))
                        .priceMin(2500)
                        .priceMax(3500)
                        .build();
                // when
                List<Shop> shops = shopQuerydslRepository.findAllBySearchCond(cond);
                // then
                assertThat(shops).hasSize(2)
                        .extracting("name", "price")
                        .containsExactlyInAnyOrder(
                                tuple("식당3", 3000),
                                tuple("식당4", 3000)
                        );
            }),
            DynamicTest.dynamicTest("식당이 속한 학교 종류, 식당 종류, 가격, 운영시간으로 식당을 검색할 수 있다. ", () ->{
                // given
                ShopSearchCond cond = ShopSearchCond.builder()
                        .schoolType(NSC)
                        .shopTypes(List.of(RESTAURANT))
                        .priceMin(2500)
                        .priceMax(3500)
                        .currentTime(LocalTime.of(13, 0))
                        .build();
                // when
                List<Shop> shops = shopQuerydslRepository.findAllBySearchCond(cond);
                // then
                assertThat(shops).hasSize(1)
                        .extracting("name", "price")
                        .containsExactlyInAnyOrder(
                                tuple("식당4", 3000)
                        );
            })
    );
}

private Shop createShop(String name, int price, ShopType shopType, SchoolType schoolType,
                        LocalTime openTime, LocalTime closeTime) {
    return Shop.builder()
            .name(name)
            .price(price)
            .shopType(shopType)
            .schoolType(schoolType)
            .openTime(openTime)
            .closeTime(closeTime)
            .build();
}

 

[ 참고 자료 ]

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