영호

[JPA] service 테스트 코드 개선하기 본문

JPA

[JPA] service 테스트 코드 개선하기

0h0 2023. 8. 13. 12:25

들어가면서,

우아한테크코스 레벨3를 진행하면서 모든 layer에 대해 테스트코드를 작성했다. 그 중에서도 service레이어 테스트는 @SpringBootTest, @Transactional을 이용한 테스트를 진행했다. 그 이유는 우리의 비즈니스 로직 수행 후 DB에 알맞게 데이터가 들어갔는지 확인하고 싶었기 때문이다.

 

하지만, RestAssured를 통한 인수테스트를 작성하는 상황에서 service레이어의 통합테스트가 필요한지에 대해 고민하다가 service레이어의 테스트 코드를 수정한 과정에 대해 기록하겠습니다.

 

개선 이유1

service레이어의 관심사가 무엇인지 생각해봤습니다. service레이어는 entity의 메서드, repository의 호출을 통해 요구사항에 맞는 비즈니스 로직을 수행하는 것이 관심사라고 생각했습니다.

즉, service레이어 단위 테스트는 우리가 원하는 데이터가 실제로 영속화되는지 보다는 우리의 의도대로 비즈니스 로직이 수행되는지에 초점이 맞춰져야 한다고 생각합니다. 하지만 현재 @SpringBootTest를 통한 테스트는 service를 통해 실제 데이터의 영속화까지 검증하기 때문에 service레이어의 단위테스트 초점에 벗어난다고 생각했습니다.

 

개선 이유2

실행 속도가 오래 걸린다. 기본적으로 @SpringBootTest는 테스트를 위한 context를 새롭게 띄웁니다. 이 과정은 테스트의 실행속도를 오래걸리게 합니다. 물론 Spring의 Test context caching의 이점을 받을 순 있습니다. 

제가 사용하고 있는 노트북이 오래된 문제도 있지만, service레이어를 @SpringBootTest로 진행하다보니 테스트 실행 시간이 오래 걸려 테스트를 돌리고 물을 떠오곤 했습니다.

개선 이유3

테스트를 위한 더미데이터 생성 작업이 너무 복잡해진다. 다른 엔티티와 연관관계가 복잡한 엔티티에 대한 service테스트를 할 때면 더미 데이터를 생성하기 위해 연관된 엔티티에 대한 데이터를 모두 생성함으로써 테스트코드가 상당히 복잡해졌습니다.


Mock테스트로 개선하기

이러한 이유로 service레이어의 테스트는 팀 회의를 통해 mocking을 사용하기로 결정했습니다.

예시 service 로직

쿠폰에 스탬프를 적립하는 요구사항이 있다고 가정하면 스탬프 적립 시 발생할 수 있는 상황은 크게 3가지가 있습니다. 쿠폰에서 보상을 위한 스탬프 개수를 maxStamp라고 부르겠습니다.

1. 새롭게 스탬프를 찍어도 쿠폰의 스탬프 개수가 maxStamp보다 적은 경우

2. 새롭게 스탬프를 찍으면 쿠폰의 스탬프 개수가 maxStamp개수와 같은 경우

3. 새롭게 스탬프를 찍으면 쿠폰의 스탬프 개수가 maxStamp를 초과하는 경우

 

1번의 경우 단순히 Stamp데이터만 추가하면 끝납니다. 2번은 스탬프를 찍고 이에 따른 쿠폰에 대한 보상 데이터가 추가되어야 합니다. 3번은 쿠폰에 대한 보상, 초과된 스탬프를 찍을 새로운 쿠폰 데이터도 추가되어야 합니다.

기존 service테스트 코드

@Test
void 기존에_적립된_스탬프가_있을_때_추가_적립한_스탬프로_리워드를_받기_위한_스탬프_개수를_초과하면_리워드를_생성하고_새로운_쿠폰에_나머지_스탬프가_찍힌다() {
    // given, when
    coupon6.accumulate(4);

    managerCouponCommandService.createStamp(new StampCreateDto(owner1.getId(), registerCustomer2.getId(), coupon6.getId(), 9));
    List<Coupon> usingCoupons = couponRepository.findByCafeAndCustomerAndStatus(cafe1, registerCustomer2, CouponStatus.ACCUMULATING);
    Coupon usingCoupon = usingCoupons.stream().findAny().get();

    SoftAssertions softAssertions = new SoftAssertions();
    softAssertions.assertThat(coupon6.getStatus()).isEqualTo(CouponStatus.REWARDED);
    softAssertions.assertThat(coupon6.getStampCount()).isEqualTo(10);
    softAssertions.assertThat(rewardRepository.findAllByCustomerIdAndCafeIdAndUsed(registerCustomer2.getId(), cafe1.getId(), false).size()).isEqualTo(1);
    softAssertions.assertThat(usingCoupons.size()).isEqualTo(1);
    softAssertions.assertThat(usingCoupon.getStampCount()).isEqualTo(3);
    softAssertions.assertAll();
}

테스트 코드만 보면 괜찮을진 몰라도 위에서 언급한 service단위 테스트에 어울리지 않는 코드가 있습니다.

첫 번째로, persistence레이어의 관심사까지 테스트하고 있습니다. 저희는 이미 repository에 대한 단위테스트를 작성했고, service는 이를 호출하는지만 확인하면 service레이어는 요구사항에 맞게 동작하고 있음을 보장할 수 있습니다.

하지만, service로직을 단위 테스트하면서 service비즈니스 로직으로 인해 생성된 실제 데이터를 테스트함으로써 불필요하게 persistence레이어 관심사 까지 테스트하고 있습니다.

 

두 번째로, 더미데이터 생성 작업이 매우 복잡합니다(약 130줄). 그 이유는 스탬프를 적립하기 위한 사전 데이터가 많이 필요했기 때문입니다.

  • 카페의 사장 회원 데이터
  • 카페 데이터
  • 카페의 쿠폰 보상 정책 데이터
  • 고객 회원 데이터
  • 고객의 쿠폰 데이터
  • 등등

그래서 이러한 더미 데이터를 복잡하게 하나하나 생성해야 했기 때문에 더미데이터의 상황을 파악하는 것도 힘들었습니다.

아래 코드는 매우 긴 더미데이터 생성 코드이기 때문에 쭉 넘어가도 상관없습니다.

@BeforeEach
void setUp() {
    temporaryCustomer1 = temporaryCustomerRepository.save(TEMPORARY_CUSTOMER_1);
    temporaryCustomer2 = temporaryCustomerRepository.save(TEMPORARY_CUSTOMER_2);
    temporaryCustomer3 = temporaryCustomerRepository.save(TEMPORARY_CUSTOMER_3);
    registerCustomer1 = registerCustomerRepository.save(REGISTER_CUSTOMER_1);
    registerCustomer2 = registerCustomerRepository.save(REGISTER_CUSTOMER_2);

    owner1 = ownerRepository.save(OWNER1);
    owner2 = ownerRepository.save(OWNER2);

    cafe1 = cafeRepository.save(
            new Cafe(
                    "하디까페",
                    LocalTime.of(12, 30),
                    LocalTime.of(18, 30),
                    "0211111111",
                    "http://www.cafeImage.com",
                    "안녕하세요",
                    "잠실동12길",
                    "14층",
                    "11111111",
                    owner1
            )
    );
    cafe2 = cafeRepository.save(
            new Cafe(
                    "하디까페",
                    LocalTime.of(12, 30),
                    LocalTime.of(18, 30),
                    "0211111111",
                    "http://www.cafeImage.com",
                    "안녕하세요",
                    "잠실동12길",
                    "14층",
                    "11111111",
                    owner2
            )
    );

    cafeCouponDesign1 = cafeCouponDesignRepository.save(
            new CafeCouponDesign(
                    "past_#",
                    "past_#",
                    "past_#",
                    true,
                    cafe1
            )
    );

    cafeCouponDesign2 = cafeCouponDesignRepository.save(
            new CafeCouponDesign(
                    "cur_#",
                    "cur_#",
                    "cur_#",
                    false,
                    cafe1
            )
    );

    cafePolicy1 = cafePolicyRepository.save(
            new CafePolicy(
                    2,
                    "아메리카노",
                    12,
                    true,
                    cafe1
            )
    );

    cafePolicy2 = cafePolicyRepository.save(
            new CafePolicy(
                    10,
                    "아메리카노",
                    12,
                    false,
                    cafe1
            )
    );

    couponDesign1 = couponDesignRepository.save(COUPON_DESIGN_1);
    couponDesign2 = couponDesignRepository.save(COUPON_DESIGN_2);
    couponDesign3 = couponDesignRepository.save(COUPON_DESIGN_3);
    couponDesign4 = couponDesignRepository.save(COUPON_DESIGN_4);
    couponDesign5 = couponDesignRepository.save(COUPON_DESIGN_5);
    couponDesign6 = couponDesignRepository.save(COUPON_DESIGN_6);

    couponPolicy1 = couponPolicyRepository.save(COUPON_POLICY_1);
    couponPolicy2 = couponPolicyRepository.save(COUPON_POLICY_2);
    couponPolicy3 = couponPolicyRepository.save(COUPON_POLICY_3);
    couponPolicy4 = couponPolicyRepository.save(COUPON_POLICY_4);
    couponPolicy5 = couponPolicyRepository.save(COUPON_POLICY_5);
    couponPolicy6 = couponPolicyRepository.save(COUPON_POLICY_6);

    coupon1 = new Coupon(LocalDate.EPOCH, temporaryCustomer1, cafe1, couponDesign1, couponPolicy1);
    Stamp stamp1 = new Stamp();
    stamp1.registerCoupon(coupon1);

    Stamp stamp2 = new Stamp();
    stamp2.registerCoupon(coupon1);
    Coupon save = couponRepository.save(coupon1);
    save.reward();

    coupon2 = new Coupon(LocalDate.EPOCH, registerCustomer1, cafe1, couponDesign2, couponPolicy2);
    Stamp stamp3 = new Stamp();
    stamp3.registerCoupon(coupon2);
    couponRepository.save(coupon2);

    coupon3 = new Coupon(LocalDate.EPOCH, temporaryCustomer2, cafe2, couponDesign3, couponPolicy3);
    Stamp stamp4 = new Stamp();
    stamp4.registerCoupon(coupon3);
    couponRepository.save(coupon3);

    coupon4 = new Coupon(LocalDate.EPOCH, temporaryCustomer3, cafe2, couponDesign4, couponPolicy4);
    couponRepository.save(coupon4);

    coupon5 = new Coupon(LocalDate.EPOCH, registerCustomer2, cafe2, couponDesign5, couponPolicy5);
    couponRepository.save(coupon5);

    coupon6 = new Coupon(LocalDate.EPOCH, registerCustomer2, cafe1, couponDesign6, couponPolicy6);
    couponRepository.save(coupon6);
}

 

mocking으로 수정하기

@BeforeAll
static void setUp() {
    cafe = new Cafe(1L, "name", "road", "detailAddress", "phone", null);
    customer = new TemporaryCustomer(1L, "name", "phone");
    couponPolicy = new CouponPolicy(10, "reward", 6);
}

@Test
void 기존에_스탬프가_있는_쿠폰에_추가_적립한_스탬프로_리워드를_받기_위한_스탬프_개수를_초과하면_리워드를_생성하고_새로운_쿠폰에_나머지_스탬프가_찍힌다() {
    // given
    Coupon currentCoupon = new Coupon(LocalDate.EPOCH, customer, cafe, null, couponPolicy);
    currentCoupon.accumulate(4);
    int maxStampCount = 10;
    스탬프_적립을_위해_필요한_엔티티를_조회한다(maxStampCount, currentCoupon);

    // when
    int earningStamp = 17;
    StampCreateDto stampCreateDto = new StampCreateDto(1L, 1L, 1L, earningStamp);
    managerCouponCommandService.createStamp(stampCreateDto);

    // then
    then(rewardRepository).should(times(2)).save(any());
    then(couponRepository).should(times(2)).save(any());
}

mocking으로 대체하면서 더미데이터 setUp은 단 3줄의 코드로 대체 가능했습니다. 그리고, persistence레이어의 관심사를 테스트하지 않고 service레이어의 코드가 의도한 대로 동작하는지 BDDMockito의 then(), should(times())를 통해 쉽게 확인할 수 있었습니다. 

 

이를 통해 위에서 언급한 개선이유 3가지를 모두 개선시킬 수 있었습니다.

 

생각해볼 점

mocking을 활용한 테스트는 text context caching의 이점을 챙길 순 없습니다. 그래서 단순히 실행속도 관점에서는 @SpringBootTest가 많을 경우 @SpringBootTest가 더 빠를 수도 있다는 생각이 들어, 이 부분은 프로젝트를 더 진행하면서 비교를 해봐야 할 것 같습니다.


그리고, service레이어가 별다른 비즈니스 로직없이 단순히 repository를 조회해서 이를 그대로 반환하거나 혹은, dto로 변경해서 반환하는 경우 정상적인 상황에 대한 테스트가 필요한지에 대해서는 의문점이 듭니다.

public List<MemerDto> findMembers(Long teamId) {
    Team team = teamRepository.findById(teamId)
            .orElseThrow(IllegalArgumentException::new);

    List<Member> members = MemberRepository.findByTeam(team);
    return members.stream()
            .map(MemberDto::new)
            .toList();
}

이 코드에서 service메서드가 수행하는 것은 크게 3가지 입니다.

  1. 존재하는 team인지 확인
  2. team에 존재하는 member조회
  3. member -> dto 변환

여기서, 조회된 Member의 데이터를 검증하는 것은 우리가 mocking한 repository의 반환값을 우리가 또 검증하기 때문에 의미가 있는 테스트인지 의문이 들었고. 실제 반환값을 검증하는 것은 repository의 영역이란 생각을 했다. 

그래서 현재는 이와 같은 경우 예외테스트를 진행하고 정상테스트 역시 repository의 findByTeam메서드가 호출되는지 정도만 호출하면 될 것 같습니다.

Comments