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 {
- EntityManager, Jpa관련 Repository 객체등을 Auto Configure해준다. 즉, Jpa Repository와 관련된 객체를 빈으로 등록해준다.(@SpringBootApplication annotation을 찾고 ApplicationContext를 로딩하는 점은 @SpringBootTest와 동일하다.)
- test database를 Auto Configure해준다. 디폴트로 H2 DB를 인메모리 형태로 실행하여 테스트할 수 있게 해준다. 만약 custom DB를 사용하고 싶을 경우 database 자동설정 어노테이션을 오버라이딩하고 application.yml 에 datasource정보를 등록하여 사용할 수 있다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ArticleRepositoryCustomImplTest {
- 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 {
@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() {
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);
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);
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() {
Sort sort = Sort.by(List.of(new Order(
Direction.ASC, "createdAt")));
Pageable pageableSortByCreatedAtAsc = PageRequest.of(0, 10, sort);
ArticleSearch articleSearchNoProperty = new ArticleSearch();
List<Article> articleListSortByCreatedAtAsc = articleRepository.findSliceByCondition(
pageableSortByCreatedAtAsc, articleSearchNoProperty).getContent();
assertThat(articleListSortByCreatedAtAsc)
.extracting(Article::getTitle)
.containsExactly("articleFirstCreated", "articleIsCompleteTrue", "articleAnonymity", "articleStudy");
}
@Test
void findSliceByCondition_givenArticles_whenPageSizeNearEntireSize_thenNextFlag() {
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();
Slice<Article> articleSliceWithLowerPageSize = articleRepository.findSliceByCondition(
pageLower, articleSearchNoProperty);
Slice<Article> articleSliceWithEqualPageSize = articleRepository.findSliceByCondition(
pageEqual, articleSearchNoProperty);
Slice<Article> articleSliceWithHigherPageSize = articleRepository.findSliceByCondition(
pageHigher, articleSearchNoProperty);
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() {
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));
List<RandomMatch> randomMatches = randomMatchRepository.findByCreatedAtAfterAndIsExpiredAndMemberIdAndContentCategory(
RandomMatchSearch.builder()
.build());
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() {
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));
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();
assertThat(randomMatchStudy.getIsExpired()).isTrue();
assertThat(randomMatchStudy.getVersion()).isEqualTo(randomMatchStudyMember1.getVersion() + 1);
assertThat(randomMatchMeal.getIsExpired()).isTrue();
assertThat(randomMatchMeal.getVersion()).isEqualTo(randomMatchMealMember1.getVersion() + 1);
}
@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가진 영속성이 방해하기 때문에 잘 관리해주어야 한다는 점이다.