JUnit5과 Spring boot 테스트 코드 작성-Repository Layer(2)

taehee kim·2023년 3월 27일
1

2. Repository Layer Unit testing

2-0. Repository Layer 단위 테스트의 목적과 환경

2-0-1. 목적

  • Repository Layer가 필요한 이유는 RDBMS, Cache, Message Queue등의 데이터 저장 컴포넌트의 구체적인 내용과 상관없이 저장, 삭제, 조회등을 일관된 형태로 접근할 수 있게하고 Repository Layer에 접근하는 Service Layer등에서 구체적인 데이터 접근 방식을 모르게 하여 의존성을 줄이게 만드는 것에 있다.
  • 즉, 특정 DataSource를 사용하고 어떻게 접근할 지에 대한 구체적인 접근 방식을 정의하는 Layer이다.
  • 따라서, Repository Layer의 테스트는 데이터 저장, 삭제, 조회가 잘 이루어지는지만 테스트하면 되고 구체적인 구현에 대해서는 테스트 할 필요가 없다.

2-0-2.환경

  • Repository Layer를 테스트 할때에는 통합 테스트와는 달리 Application Context에 Repository를 테스트 하기 위한 Bean들만 최소화하여 로딩해야한다.
  • 또한 테스트를 위한 데이터베이스가 필요하다.
  • 이를 위해서 @DataJpaTest라는 Annotation을 활용한다.
  • Repository Layer에서는 Mock객체를 활용할 일은 거의 없다.

2-0-3.@DataJpaTest를 사용했을 때 Repository Layer 단위 테스트의 특징.

  • @DataJpaTest를 사용하게 되면 모든 테스트 메서드에 @Transactional이 적용되고 @Test 환경에서는 default로 commit 된다.(각각의 테스트 메서드는 다른 메서드에 영향을 주어서는 안되기 때문에 매 테스트 마다 데이터를 초기화 해준다.)
  • @BeforeEach와 각각의 메서드가 하나의 트랜잭션으로 묶이게 된다.
  • EmbeddedDB 인 H2 DB를 내장으로 AutoConfigure하여 등록해주기 때문에 이를 활용해도 좋고 DB종류에 종속적인 테스트 내용이 있다면 Custom 으로 DB를 연결할 수 있다.(이 경우 @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)로 설정한 후 application.yml에 db설정 정보 추가.)

2-1. @DataJpaTest

  • @DataJpaTest는 주로 다음 3가지 역할을 수행해준다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(DataJpaTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@ImportAutoConfiguration
public @interface DataJpaTest {
  1. EntityManager, Jpa관련 Repository 객체등을 Auto Configure해준다. 즉, Jpa Repository와 관련된 객체를 빈으로 등록해준다.(@SpringBootApplication annotation을 찾고 ApplicationContext를 로딩하는 점은 @SpringBootTest와 동일하다.)
  2. test database를 Auto Configure해준다. 디폴트로 H2 DB를 인메모리 형태로 실행하여 테스트할 수 있게 해준다. 만약 custom DB를 사용하고 싶을 경우 database 자동설정 어노테이션을 오버라이딩하고 application.yml 에 datasource정보를 등록하여 사용할 수 있다.
    @DataJpaTest
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    class ArticleRepositoryCustomImplTest {
  3. transaction을 걸어주고 기본 옵션으로 rollback을 수행한다. rollback을 원하지 않을 경우 각 테스트케이스 메서드에 Rollback 어노테이션을 써야한다.
@Test
@Rollback(false)
void test(){}

2-2.테스트 시 주의 해야하는 경우.

2-2-1. @DataJpaTest어노테이션으로 빈으로 등록되지 않는 객체들 때문에 테스트가 진행되지 않는 경우.

  • QueryDsl, JpaAuditing, Custom으로 만든 객체등은 @Component, @Configuration으로 설정해도 빈으로 등록되지 않기 때문에 수동 등록해야한다.
  • @Import를 활용하여 명시적으로 등록 해준다.
  • 대표적으로 QueryDsl을 사용 하는 경우 @DataJpaTest 어노테이션을 사용했을 때 JPAQueryFactory는 빈으로 자동 등록 되지 않기 때문에 다음과 같이 config 클래스를 만들어 JPAQueryFactory를 수동으로 빈으로 등록해야한다. @Import 어노테이션을 활용하여 등록해준다.
  • 마찬가지로 createdAt, updatedAt등을 자동적으로 채워 주는 AuditorAware 또한 명시적으로 추가해준다.
@SpringBootApplication
@Import({QuerydslConfig.class, Auditor.class})
class ModuleCommonApplicationTests {

	@Test
	void contextLoads() {
	}

}
@Configuration
public class QuerydslConfig {

    @PersistenceContext
    private EntityManager em;
    @Bean
    JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }
}
@EnableJpaAuditing
@Component
public class Auditor  implements AuditorAware<String> {
    private static final String NOT_USER = "system";

    @Override
    public Optional<String> getCurrentAuditor() {

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (null == authentication || !authentication.isAuthenticated()) {
            return Optional.of(NOT_USER);
        }
        String username = ((UserDetails)authentication.getPrincipal()).getUsername();
        return Optional.of(username);
    }
}

2-2-2.Bootstrap 데이터가 필요한 경우

  • 가장 단순한 방법은 BeforeEach에서 매번 초기 데이터를 설정하고 롤백을 진행하여 테스트간의 독립성을 유지하는 것이다.
  • 하지만 다음과 같이 TestConfiguration을 활용하여 Bean 생성 이후에 테스트 시 단 한번만 초기 데이터를 설정하도록 하면 테스트 속도가 더 빨라질 수 있다.
@TestConfiguration
@Slf4j
@RequiredArgsConstructor
public class TestBootstrapConfig {
    @Value("${spring.jpa.hibernate.data-loader}")
    private Integer dataLoader;
    private final BootstrapDataLoader bootstrapDataLoader;
    @Transactional
    @PostConstruct
    public void initDB() {
        if (dataLoader.equals(2)){
            bootstrapDataLoader.createDefaultUsers();
            bootstrapDataLoader.createMatchCondition();
        }
    }
}
  • @Import({TestBootstrapConfig.class, BootstrapDataLoader.class}) 와 같이 설정해주면 초기 데이터가 한번만 로딩된다.

2-3. 모든 Repository 메서드를 단위 테스트 해야할까?

  • 내용적으로 너무 단순한 경우(lombok으로 생성한 Getter, Spring Data JPA가 메서드 명으로 유추하여 자동생성해주는 메서드 중 단순한 경우)는 테스트하지 않는 것이 좋다고 생각한다.
  • JPQL, QueryDsl등으로 직접 작성한 쿼리문의 경우 필수적으로 테스트를 작성한다.

2-4. 테스트 예시

2-4-1. 설정

  • 기본 설정
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class AlarmRepositoryCustomImplTest {
  • Bootstrap data가 필요한 경우
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import()
class AlarmRepositoryCustomImplTest {
  • createdAt, createdBy등을 @EnableAuditing통해 적용하고 싶은 경우
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(Auditor.class)
class AlarmRepositoryCustomImplTest {
@EnableJpaAuditing
@Component
public class Auditor  implements AuditorAware<String> {
    private static final String NOT_USER = "system";

    @Override
    public Optional<String> getCurrentAuditor() {

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (null == authentication || !authentication.isAuthenticated()) {
            return Optional.of(NOT_USER);
        }
        String username = ((UserDetails)authentication.getPrincipal()).getUsername();
        return Optional.of(username);
    }
}
  • 원하는 객체를 추가적으로 Import하여 사용하면 된다.
  • EmbeddedDB가 아닐 경우 test 경로의 application.yml에서 ddl-auto 옵션을 create 혹은 create-drop으로 하여 DB상태에 따라 의존성이 생기지 않도록 관리하자.
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/partner_test
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: test
    password: Test1234!

  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        data-loader: 2

2-4-2. 조회

  • Repository코드는 대부분 조회를 테스트하는 경우가 많다.
  • update의 경우 bulk성 update를 제외하면 보통 영속성 컨텍스트의 Dirty Checking을 통해서 이루어지고 Delete, save등은 SpringDataJpa에서 메서드 명만으로 생성할 수 있기 때문이다.
  • given 절에서 데이터를 설정할 때 BeforeEach를 활용하여 모든 메서드에서 공동으로 사용하는 데이터가 있을 경우 중복을 제거할 수 있다.
  • 하지만 그 데이터가 다른 메서드를 테스트할 때는 부적합 할 경우 코드 변경이 불가피 하기 때문에 각 메서드마다 given절에서 객체들을 따로 생성하는 것이 좋은 경우가 훨씬 많다.
  • 또한 BeforeEach에서 객체를 생성할 경우 생성한 객체를 변수에 담아 사용하기 위해 테스트 클래스의 인스턴스 변수에 할당하는 경우가 있는데 이 경우테스트 메서드가 많아지면 멤버변수를 다시 찾아보기 위해서 위쪽으로 코드를 다시 올려봐야하기 때문에 시간이 들기 때문에 개인적으로는 잘 사용하지 않습니다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({QuerydslConfig.class, Auditor.class})
class ArticleRepositoryCustomImplTest {

    @Autowired
    ArticleRepository articleRepository;
    @BeforeEach
    void setUp() {
        Article articleFirstCreated = Article.of(LocalDate.now().plusDays(1L), "articleFirstCreated", "content", false,
            3, ContentCategory.MEAL);
        articleRepository.save(articleFirstCreated);

        Article articleIsCompleteTrue = Article.of(LocalDate.now().plusDays(1L), "articleIsCompleteTrue", "content", false,
            3, ContentCategory.MEAL);
        articleIsCompleteTrue.completeArticleWhenMatchDecided();
        articleRepository.save(articleIsCompleteTrue);


        Article articleDeleted = Article.of(LocalDate.now().plusDays(1L), "articleDeleted", "content", false,
            3, ContentCategory.MEAL);
        articleDeleted.recoverableDelete();
        articleRepository.save(articleDeleted);

        Article articleAnonymity = Article.of(LocalDate.now().plusDays(1L), "articleAnonymity", "content", true,
            3, ContentCategory.MEAL);
        articleRepository.save(articleAnonymity);


        Article articleStudy = Article.of(LocalDate.now().plusDays(1L), "articleStudy", "content", false,
            3, ContentCategory.STUDY);
        articleRepository.save(articleStudy);

    }
    @Test
    void findSliceByCondition_givenArticleWithDifferentArticleSearchProperty_whenArticleSearchDiverse_thenFilterByArticleSearch() {
        //given
        Pageable pageable = PageRequest.of(0, 10, Sort.by(List.of(new Order(
            Direction.ASC, "createdAt"))));

        ArticleSearch articleSearchNoProperty = new ArticleSearch();

        ArticleSearch articleSearchAnonymityTrue = new ArticleSearch();
        articleSearchAnonymityTrue.setAnonymity(true);


        ArticleSearch articleSearchIsCompleteTrue = new ArticleSearch();
        articleSearchIsCompleteTrue.setIsComplete(true);

        ArticleSearch articleSearchContentCategoryMeal = new ArticleSearch();
        articleSearchContentCategoryMeal.setContentCategory(ContentCategory.MEAL);
        //when

        Slice<Article> articleSliceNoProperty = articleRepository.findSliceByCondition(
            pageable, articleSearchNoProperty);
        Slice<Article> articleSliceAnonymityTrue = articleRepository.findSliceByCondition(
            pageable, articleSearchAnonymityTrue);
        Slice<Article> articleSliceIsCompleteTrue = articleRepository.findSliceByCondition(
            pageable, articleSearchIsCompleteTrue);
        Slice<Article> articleSliceContentCategoryMeal = articleRepository.findSliceByCondition(
            pageable, articleSearchContentCategoryMeal);
        //then
        assertThat(articleSliceNoProperty.getContent()).hasSize(4);
        assertThat(articleSliceAnonymityTrue.getContent()).hasSize(1);
        assertThat(articleSliceIsCompleteTrue.getContent()).hasSize(1);
        assertThat(articleSliceContentCategoryMeal.getContent()).hasSize(3);

    }

    @Test
    void findSliceByCondition_givenArticleWithDifferentCreatedAt_whenSortByCreatedAtASC_thenExactlyExpectedOrder() {
        //given
        Sort sort = Sort.by(List.of(new Order(
            Direction.ASC, "createdAt")));
        Pageable pageableSortByCreatedAtAsc = PageRequest.of(0, 10, sort);
        ArticleSearch articleSearchNoProperty = new ArticleSearch();
        //when

        List<Article> articleListSortByCreatedAtAsc = articleRepository.findSliceByCondition(
            pageableSortByCreatedAtAsc, articleSearchNoProperty).getContent();

        //then
        assertThat(articleListSortByCreatedAtAsc)
            .extracting(Article::getTitle)
            .containsExactly("articleFirstCreated", "articleIsCompleteTrue", "articleAnonymity", "articleStudy");
    }

    @Test
    void findSliceByCondition_givenArticles_whenPageSizeNearEntireSize_thenNextFlag() {
        //given
        Sort sort = Sort.by(List.of(new Order(
            Direction.ASC, "createdAt")));
        Pageable pageLower = PageRequest.of(0, 3, sort);
        Pageable pageEqual = PageRequest.of(0, 4, sort);
        Pageable pageHigher = PageRequest.of(0, 5, sort);

        ArticleSearch articleSearchNoProperty = new ArticleSearch();
        //when

        Slice<Article> articleSliceWithLowerPageSize = articleRepository.findSliceByCondition(
            pageLower, articleSearchNoProperty);

        Slice<Article> articleSliceWithEqualPageSize = articleRepository.findSliceByCondition(
            pageEqual, articleSearchNoProperty);

        Slice<Article> articleSliceWithHigherPageSize = articleRepository.findSliceByCondition(
            pageHigher, articleSearchNoProperty);

        //then
        assertThat(articleSliceWithLowerPageSize.hasNext()).isTrue();
        assertThat(articleSliceWithEqualPageSize.hasNext()).isFalse();
        assertThat(articleSliceWithHigherPageSize.hasNext()).isFalse();
    }

}

2-4-3. 조회에서 fetch join, EntityGraph 적용되는지 테스트하는 방법.

  • EntityMagerFactor.getPersistenceUnitUtil().isLoaded를 활용하면 true의 경우 프록시 객체가 아니라 실제 데이터가 로딩되었음을 의미한다.
@Test
    void findByCreatedAtAfterAndIsExpiredAndMemberIdAndContentCategory_whenFetchJoinNotWithDistinct_thenMemberEntityDoesNotReplicatedAndMemberFetchedEagerly() {
        //given
        Member member1 = memberRepository.save(Member.of("member1"));
        Member member2 = memberRepository.save(Member.of("member2"));

        RandomMatch randomMatchStudyMember1 = RandomMatch.of(
            RandomMatchCondition.of(Place.GAEPO, TypeOfStudy.INNER_CIRCLE), member1);

        RandomMatch randomMatchMealMember1 = RandomMatch.of(
            RandomMatchCondition.of(Place.GAEPO, WayOfEating.DELIVERY), member1);

        RandomMatch randomMatchMealExpiredMember1 = RandomMatch.of(
            RandomMatchCondition.of(Place.SEOCHO, WayOfEating.DELIVERY), member1);
        randomMatchMealExpiredMember1.expire();
        RandomMatch randomMatchStudyMember2 = RandomMatch.of(
            RandomMatchCondition.of(Place.GAEPO, TypeOfStudy.INNER_CIRCLE), member2);

        randomMatchRepository.saveAll(
            List.of(randomMatchStudyMember1, randomMatchMealMember1, randomMatchMealExpiredMember1,
                randomMatchStudyMember2));
        //when
        List<RandomMatch> randomMatches = randomMatchRepository.findByCreatedAtAfterAndIsExpiredAndMemberIdAndContentCategory(
            RandomMatchSearch.builder()
                .build());
        //then
        assertThat(randomMatches).hasSize(4);
        assertThat(em.getEntityManagerFactory().getPersistenceUnitUtil()
            .isLoaded(randomMatches.get(0).getMember())).isTrue();
    }

2-4-4. BulkUpdate쿼리 테스트

  • BulkUpdate쿼리는 영속성 컨테스트와 무관하게 DB에 적용 되기 때문에 영속성 켄텍스트 flush, clear를 해주어야한다.
  • 테스트 하려는 쿼리는 LostUpdate가 발생하면 안되므로 update전에 먼저 조회하여 비관적 록을 획득한 후 update해준다.
@Test
    void bulkUpdateOptimisticLockIsExpiredToTrueByIds_givenRandomMatches_whenBulkUpdate_thenUpdatedAndVersionChanged() {

        //given
        Member member1 = memberRepository.save(Member.of("member1"));
        Member member2 = memberRepository.save(Member.of("member2"));

        RandomMatch randomMatchStudyMember1 = RandomMatch.of(
            RandomMatchCondition.of(Place.GAEPO, TypeOfStudy.INNER_CIRCLE), member1);

        RandomMatch randomMatchMealMember1 = RandomMatch.of(
            RandomMatchCondition.of(Place.GAEPO, WayOfEating.DELIVERY), member1);

        RandomMatch randomMatchMealExpiredMember1 = RandomMatch.of(
            RandomMatchCondition.of(Place.SEOCHO, WayOfEating.DELIVERY), member1);
        randomMatchMealExpiredMember1.expire();
        RandomMatch randomMatchStudyMember2 = RandomMatch.of(
            RandomMatchCondition.of(Place.GAEPO, TypeOfStudy.INNER_CIRCLE), member2);

        randomMatchRepository.saveAll(
            List.of(randomMatchStudyMember1, randomMatchMealMember1, randomMatchMealExpiredMember1,
                randomMatchStudyMember2));
        //when

        randomMatchRepository.bulkUpdateOptimisticLockIsExpiredToTrueByIds(
            Set.of(RandomMatchBulkUpdateDto.builder()
                    .id(randomMatchStudyMember1.getId())
                    .version(randomMatchStudyMember1.getVersion())
                    .build(),
                RandomMatchBulkUpdateDto.builder()
                    .id(randomMatchMealMember1.getId())
                    .version(randomMatchMealMember1.getVersion())
                    .build()));
        RandomMatch randomMatchStudy = randomMatchRepository.findById(randomMatchStudyMember1.getId())
            .get();
        RandomMatch randomMatchMeal = randomMatchRepository.findById(randomMatchMealMember1.getId())
            .get();

        //then
        assertThat(randomMatchStudy.getIsExpired()).isTrue();
        assertThat(randomMatchStudy.getVersion()).isEqualTo(randomMatchStudyMember1.getVersion() + 1);
        assertThat(randomMatchMeal.getIsExpired()).isTrue();
        assertThat(randomMatchMeal.getVersion()).isEqualTo(randomMatchMealMember1.getVersion() + 1);
    }
/**
     * 1. OptimisticLock을 통해 Lost Update가 발생하지 않도록함.
     * 2. 이것을 구현하기 위해 영속성 컨텍스트를 비워 준 후(최신 DB상태를 조회 하기 위해) Write_Lock과 함께 해당 Id의  Update이전 마지막 조회 시점의 version을 가져와서 Write_Lock을 건 후 트랜잭션 내에서 조회 했을 때version이 바뀌었거나 엔티티가 삭제되었는지 확인하고
     *   문제가 있는 경우 OptimisticLockException발생.
     * 3. 정상 Update가 가능한 경우 version을 1 증가 시키고 isExpired를 true로 변경.
     * 4. 벌크성 수정 쿼리는 영속성 컨텍스트를 무시하고 실행되므로, 영속성 컨텍스트를 초기화함.
     * @param randomMatchBulkUpdateDtos
     */
    @Override
    public void bulkUpdateOptimisticLockIsExpiredToTrueByIds(
        Set<RandomMatchBulkUpdateDto> randomMatchBulkUpdateDtos){
        // 영속성 컨텍스트를 초기화 하여 새로 조회한다.
        em.flush();
        em.clear();
        List<Long> idList = randomMatchBulkUpdateDtos.stream().map(RandomMatchBulkUpdateDto::getId)
            .collect(
                Collectors.toList());
        List<RandomMatch> randomMatches = findWithPessimisticLockByIds(
            idList);

        verifyVersion(randomMatches, randomMatchBulkUpdateDtos);

        queryFactory.update(randomMatch)
            .set(randomMatch.isExpired, true)
            .set(randomMatch.version, randomMatch.version.add(1))
            .where(isRandomMatchIdsIn(idList))
            .execute();

        em.flush();
        em.clear();
    }

    private void verifyVersion(List<RandomMatch> randomMatches,
        Set<RandomMatchBulkUpdateDto> randomMatchBulkUpdateDtos) {
        if (randomMatches.size() != randomMatchBulkUpdateDtos.size()) {
            throw new OptimisticLockException("Optimistic Lock Exception");
        }
        Map<Long, Long> idVersionMap = randomMatchBulkUpdateDtos.stream()
            .collect(Collectors.toMap(RandomMatchBulkUpdateDto::getId,
                RandomMatchBulkUpdateDto::getVersion));
        randomMatches.forEach(rm -> {
            if (!idVersionMap.get(rm.getId()).equals(rm.getVersion())) {
                throw new OptimisticLockException("Optimistic Lock Exception");
            }
        });

    }

    private List<RandomMatch> findWithPessimisticLockByIds(List<Long> ids){
        return queryFactory.select(randomMatch)
            .from(randomMatch)
            .join(randomMatch.member, member)
            .where(isRandomMatchIdsIn(ids))
            .setLockMode(LockModeType.PESSIMISTIC_WRITE)
            .fetch();
    }

    private BooleanExpression isRandomMatchIdsIn(Collection<Long> randomMatchIds) {
        return randomMatchIds == null ? null : randomMatch.id.in(randomMatchIds);
    }


    private BooleanExpression isMemberId(Long memberId) {
        return memberId == null ? null : member.id.eq(memberId);
    }

    private BooleanExpression isExpired(Boolean isExpired) {
        return isExpired == null ? null: randomMatch.isExpired.eq(isExpired);
    }

    private BooleanExpression isCreatedAtAfter(LocalDateTime createdAt) {
        return createdAt == null ? null: randomMatch.createdAt.after(createdAt);
    }

    private BooleanExpression isContentCategory(ContentCategory contentCategory) {
        return contentCategory == null ? null : randomMatch.randomMatchCondition.contentCategory.eq(contentCategory);
    }

2-5. 정리

  • Repository Layer테스트 시 가장 중요한 점은 테스트를 여러번 실행하거나 각각의 테스트 메서드가 서로 영향을 주지 않아야 하고 이를 DB가진 영속성이 방해하기 때문에 잘 관리해주어야 한다는 점이다.
profile
Fail Fast

0개의 댓글