Data JPA Repository 테스트 준비
CrudRepository를 구현하는 Repository를 테스트합니다. 저는 예시로 우선 아래와 같이 Schedule
모델을 만들었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Schedule extends BaseEntity {
@Id
@GeneratedValue
@Column(name = "schedule_id")
private Long id;
private LocalDate startDate;
private LocalDate endDate;
private String title;
@Builder
private Schedule(String title, LocalDate startDate, LocalDate endDate) {
this.title = title;
this.startDate = startDate;
this.endDate = endDate;
}
}
Schedule
는 일정 이름, 시작일, 종료일로 되어있습니다. 그리고 아래와 같이 간단한 Repository를 만들어보았습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public interface ScheduleRepository extends JpaRepository<Schedule, Long> {
/**
* 인자로 지정한 시작, 종료일 사이에 걸친 일정들을 모두 가져온다.
**/
@Query("select s from Schedule s " +
"where s.startDate between :startDate and :endDate or " +
"s.endDate between :startDate and :endDate")
List<Schedule> findAllOverlapped(LocalDate startDate, LocalDate endDate);
/**
* 인자로 지정한 시작, 종료일 사이에 걸친 일정들을 모두 삭제한다.
**/
@Modifying
@Query("delete from Schedule s " +
"where s.startDate between :startDate and :endDate or " +
"s.endDate between :startDate and :endDate")
void deleteAllOverlapped(LocalDate startDate, LocalDate endDate);
}
JpaRepository
는 CrudRepository
에 paging, sorting, flush등 여러 jpa 기능들을 포함한 Repository타입입니다.
JpaRepository
에 포함된 모든 기능을 테스트할 필요는 없습니다. 여기에 포함된 메서드들은 이미 검증된 메스드들이기 때문입니다. 커스텀으로 만들거나 override한 메서드들만 테스트하면 됩니다. 위의 예시에서는 findAllOverlapped
, deleteAllOverlapped
만 테스트하면 되겠네요.
Data JPA Repository 테스트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@DataJpaTest
class ScheduleRepositoryTest {
@Autowired
private ScheduleRepository scheduleRepository;
@BeforeEach
void setUp() {
List<Schedule> list = ScheduleMock.createList(10,
LocalDate.of(2022, 2, 10),
Period.ofDays(2));
scheduleRepository.saveAll(list);
}
@Test
@DisplayName("일정을 시작일과 종료일로 조회한다.")
void findAllOverlapped() {
// when
List<Schedule> list1 = scheduleRepository.findAllOverlapped(
LocalDate.of(2022, 2, 1),
LocalDate.of(2022, 2, 12));
List<Schedule> list2 = scheduleRepository.findAllOverlapped(
LocalDate.of(2022, 2, 5),
LocalDate.of(2022, 2, 13));
List<Schedule> list3 = scheduleRepository.findAllOverlapped(
LocalDate.of(2022, 2, 13),
LocalDate.of(2022, 2, 16));
List<Schedule> list4 = scheduleRepository.findAllOverlapped(
LocalDate.of(2022, 2, 16),
LocalDate.of(2022, 3, 8));
List<Schedule> list5 = scheduleRepository.findAllOverlapped(
LocalDate.of(2022, 3, 19),
LocalDate.of(2022, 3, 28));
List<Schedule> list6 = scheduleRepository.findAllOverlapped(
LocalDate.of(2022, 2, 26),
LocalDate.of(2022, 2, 28));
// then
assertThat(list1).hasSize(1);
assertThat(list2).hasSize(1);
assertThat(list3).hasSize(2);
assertThat(list4).hasSize(6);
assertThat(list5).hasSize(1);
assertThat(list6).hasSize(2);
}
@Test
@DisplayName("일정을 시작일과 종료일로 삭제한다.")
void deleteAllOverlapped() {
// when
scheduleRepository.deleteAllOverlapped(
LocalDate.of(2022, 2, 16),
LocalDate.of(2022, 3, 8));
// then
assertThat(scheduleRepository.count()).isEqualTo(4);
}
}
제가 간단하게 작성해본 테스트 코드입니다. Repository 테스트는 Mocking이 아닌 실제 DB로 테스트해보는 개념이라 전반적인 흐름은 마치 통합 테스트와 유사합니다.
@DataJpaTest
란?
이 어노테이션은 @SpringBootTest
와 비슷합니다.
@SpringBootTest
는 모든 빈을 등록하고 테스트를 진행하지만,@DataJpaTest
는 JPA에 관련된 요소들만 등록하여 테스트를 진행합니다. (Service와 Controller도 등록되지 않습니다.)@DataJpaTest
는@Transactional
이 기본적으로 붙습니다. 그래서 각 테스트마다 transaction이 적용됩니다. 테스트가 끝나면 자동으로 롤백됩니다. ()@DataJpaTest
는 내부 메모리 DB로 테스트합니다. gradle에 H2 connector가 있다면, h2를 사용합니다. 물론 다른 in-memory DB를 사용할 수도 있습니다. (DERBY, HSQLDB)
@Autowired
사용
Repository를 mocking하지 않고 직접 의존성 주입을 받아 사용합니다. JPA관련 빈들은 모두 등록되기 때문입니다. 하지만 Service나 Controller 등은 빈에 등록되지 않으니 DI를 사용할 수 없습니다. 이 경우에는 Mockito등을 활용해서 Mocking해서 사용해야합니다.
@BeforeEach
에서 테스트 데이터 미리 삽입
실제 JPA를 테스트하다보니, 매 테스트마다 직접 데이터들을 넣어주어야 합니다. 그러면 코드도 중복되고 보기 힘들어지죠. 그래서 공통 로직인 경우에는 @BeforeEach
를 단 메서드에서 처리해주면 편리합니다.
저는 일정을 2022-02-12부터 시작해서 2일 간격으로 10개를 만들어 넣었습니다. 일정 이름은 간단하게 "title" + i
로 설정해주었어요. 그래서 실제 테스트 데이터는 아래처럼 들어갑니다.
title | startDate | endDate |
---|---|---|
title0 | 2022-02-12 | 2022-02-14 |
title1 | 2022-02-16 | 2022-02-18 |
title2 | 2022-02-20 | 2022-02-22 |
title3 | 2022-02-24 | 2022-02-26 |
title4 | 2022-02-28 | 2022-03-02 |
title5 | 2022-03-04 | 2022-03-06 |
title6 | 2022-03-08 | 2022-03-10 |
title7 | 2022-03-12 | 2022-03-14 |
title8 | 2022-03-16 | 2022-03-18 |
title9 | 2022-03-20 | 2022-03-22 |
findAllOverlapped
테스트
이 메서드에 특정 기간을 넘기면, 그 기간에 포함된 일정들과 걸쳐있는 일정들 모두를 반환합니다. 아래 그림은 그 예시입니다.
조회할 범위 (회색)을 지정하면, 여기에 걸치는 모든 일정들을 가져옵니다. 파란색으로 된 부분들이 조회될 일정들입니다.
그래서 저희는 이 로직이 잘 동작하는지 확인하기 위해서 findAllOverlapped
을 이런 저런 case로 호출합니다. 각 case별로 정확한 길이의 list가 반환되는지 검사하면 됩니다.
안의 내용까지도 정확히 일치하는지 확인하면 좋겠지만, 그러면 테스트 코드의 길이가 좀 길어지기도 하고 보기도 복잡해집니다. 그리고 case가 충분히 많다면 내용까지 검사할 필요는 없다고 생각했습니다. (중요한 비즈니스 로직이라면 하는 게 좋을 것 같아요.)
저는 직접 각 case를 생성하고 size를 검사했는데요. 여러분이 테스트 코드만 보고 직관적으로 이해할 수 있게 단순하게 작성했습니다. 반복되는 로직이 많다보니 실제로 적용할 때는 공통 부분을 함수로 빼시는 것이 좋을 것 같아요.
deleteAllOverlapped
테스트
삭제하는 로직을 테스트 하는 방법도 크게 다르지 않습니다. 직접 만든 메서드로 특정 기간에 걸쳐있는 일정들을 삭제했을 때, 일정들의 개수가 정확한지 테스트합니다.
위에서 여러 case에 대해서 테스트하는걸 보여드려서, 여기서는 단순하게 하기 위해 1개의 case만 테스트했습니다. 실제로 사용하실 때는 여러 case를 준비하시는걸 추천드립니다.
Mocking 객체 생성 유틸리티
위에서 설명한 테스트 방법은 Mocking이 아니라 실제 DB에 테스트합니다. 그래서 데이터 일관성을 지켜주어야 해요. 예를 들면 foreign key나 not-nullable, unique 등이 있습니다.
테이블에 여러 테이블이 연관되어있으면 테스트 데이터 준비하기 조금 어려워집니다. 아래는 그 예시입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@BeforeEach
void setup() {
// User 만드려면 Major 필요
Major major = majorRepository.save(MajorMock.create());
// Post 만드려면 User 필요
user = UserMock.create(major);
user = userRepository.save(user);
// Tag 만드려면 Post필요
List<Post> post = PostMock.createList("news-1-", user, 5);
postRepository.saveAll(post);
// Tag 생성
tag1 = TagMock.create();
tag = tagRepository.save(tag);
}
이런식으로 Tag를 테스트하기 위해서 여러 객체를 만들고 DB에 넣어주었습니다. 직접 모든 객체를 만들면 복잡하니 생성 유틸리티 클래스를 만들면 편합니다.
위의 코드에서 사용한 MajorMock
, UserMock
, PostMock
, TagMock
은 모두 제가 생성한 유틸리티 클래스입니다. 이렇게 따로 생성 로직을 빼면 단 한 줄로 List까지도 편리하게 생성할 수 있습니다. PostMock.createList를 통해 5개의 Post를 한 줄로 만들었죠? 실제 코드는 이렇습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static List<Post> createList(String prefix, User user, int size) {
List<Post> result = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
Post post = Post.builder()
.user(user)
.title(prefix + i)
.body(Integer.toString(i))
.build();
FieldReflector.inject(BaseEntity.class, post, "createdAt", LocalDateTime.of(2022, 3, 3, 3, 3));
result.add(post);
}
return result;
}
내부적으로 createdAt도 필요한 경우 넣어주어야 하고, List를 만드는 것 자체가 번거롭습니다.
그리고 종종 Mocking하는 경우에는 정확한 id가 전달되는지 테스트하기 위해서 객체에 id를 직접 넣어주어야 하는 경우가 있습니다. id는 보통 builder나 생성자에서 제외합니다. @GeneratedValue
를 사용하면 JPA가 알아서 넣어주기 때문입니다. 하지만 JPA의 도움을 받지 않는 mocking 테스트시에는 직접 넣우주어야 하죠. 이때는 reflection을 사용해야할 수도 있습니다.
이런 것들을 매번 생성할때마다 하려고하면 난감합니다. 그래서 공통 생성 로직을 따로 유틸리티 메서드로 만들어두면 편리합니다.
Specification
테스트
Specification도 @DataJpaTest
를 통해 테스트할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
@Test
void findByKeyword() {
// given
Specification<Post> spec = PostSpec.withTitleOrBody("ews-1");
// when
List<Post> all = postRepository.findAll(spec);
// then
assertThat(all.size()).isEqualTo(5);
}
미리 테스트 데이터를 준비해두고, 이런식으로 specification을 통해 findAll을 할 때 정확한 길이의 리스트가 반환되는지 검사해보면 됩니다.