Spring 심화에서는 이전 Spring 숙련에서 배운 내용 이상으로 JPA, QueryDSL에 조금 더 최적화된 테스트를 다루는 방법에 대해 소개해주셨다. 테스트가 중요한 건 매번 알지만 기능 개발에서 뒷전이 되는 건 항상 문제가 조금씩 있다고 느껴지긴 한다.
다음 프로젝트는 TDD까진 아니어도 하나의 기능 개발 Issue에서 테스트 코드까지 작성해야만 마무리하는 걸로 해볼까 싶기도 하다.
Repository 자체 코드를 테스트할 수 있지만 Repository의 모든 메소드를 테스트할 필요는 없다.
예를 들어 findById, findAll, findByEmail 처럼 JPA 정의 메소드의 작동은 JPA를 믿고 사용해도 충분하다고 얘기해주셨다.
이를 테스트해보는 것 자체가 나쁘다는 얘기가 아니라 효율적인 테스트를 위해선 우리의 리소스를 줄이고 퀄리티 높은 테스트 코드에 치중하는게 좋다고 하셨다. (우선순위의 문제)
이것도 우선순위가 높은 편은 아니지만 JPQL, NativeQuery을 사용했다면 복잡한 로직이 필요했다는 뜻이므로 테스트 코드가 작성되는 게 좋다. 런타임 에러에 대한 이슈도 조금은 안전해질 수 있다.
QueryDSL을 사용한 경우는 우선순위가 더 높아지게 되어 테스트 작성을 더욱 권장한다. JPQL보다 문법이 좀 더 복잡해지고 작성한 사람이 쿼리 의도를 알고있기 때문에 빠르게 작성해줌이 좋다.
Repository 테스트의 의의는 단순히 Repository만의 문제가 아니라 클라이언트 객체인 Service의 테스트 코드에서 Repository를 모킹하려고 할 때 Repository의 신뢰성을 주는 의미도 크다.
위에서 다룬 내용처럼 Repository를 믿음직하게 테스트 코드를 작성했으면 이제 Client 객체인 Service까지 포함하는 통합 테스트에 시도해볼 수 있다.
강의에선 오히려 Repository 유닛 테스트보다 이 통합 테스트를 더 중요하게 여긴다고 하셨다.
다루는 내용은 강의용으로 임의로 만들어진 Member
라는 도메인에 대한 테스트였지만 이번엔 내가 진행중인 과제에 대한 테스트라 이걸 공부하고 끼워맞추는데 정말 쉽지 않았던 것 같다.
데이터베이스와 JPA 관련된 설정들을 자동 추가해줘서 JPA, QueryDSL 테스트 코드 작성에 필요한 작업들을 처리해준다.
더 큰 설정으로 @SpringBootTest
가 존재하는데 불필요한 초기화 작업 + Bean 생성으로 테스트가 무거워질 수 있어 우선 @DataJpaTest
를 써보고 통합 테스트의 범위가 커지면 바꾸면 된다.
@DataJpaTest
가 해주는 일은 다음과 같다:
show-sql
이 활성화 되어있다면 SQL 쿼리문을 볼 수 있게 해줌@Transactional
을 붙여 트랜잭션을 걸어줌 (해제할 수도 있으니 테스트가 이상하면 propagation = Propagation.NOT_SUPPORTED
를 사용할 수 있음)@AutoConfigureTestDatabase
어노테이션이 해주는 일 replace = AutoConfigureTestDatabase.Replace.NONE
으로 비활성화 해주고 테스트 코드용 H2 DB 및 MySQL로 테스트 하는 것이 일반적TestEntityManager
를 만들어줌@DataJpaTest
class MemberServiceTest @Autowired constructor(
private val entityManager: TestEntityManager
) { }
위 내용중에서 가장 중요한 것은 @Transactional
인데 save()
를 진행해도 INSERT 쿼리문이 쓰기지연 저장소에 저장만 되고 실행은 되지 않기 때문에 발생시키려면 별도의 flush()
또는 saveAndFlush()
작업이 필요하다.
그리고 테스트 결과는 마지막에 항상 Rollback이 되므로 Rollback을 원치 않는다면 @Rollback(false)
어노테이션이 필요하다.
마지막으로 @DataJpaTest
외에 필요한 것이 조금만 더 있으면 @Import
어노테이션으로 포함시킬 수 있다. 많이 필요하면 @SpringBootTest
를 고려해봄이 좋다.
searchAll
이라는 기능으로 Pagination을 적용하고 검색 키워드도 적용됐으면 한다는 시나리오로 현재 과제로 작성중인 Post의 QueryDSL과 시나리오가 비슷한 측면이 있었다.
우선 스니펫을 보자마자 수정할만한게 보여서 그것부터 바꿨다. 중복되는 Pagination을 Util로 정리해서 빼는 방법이 있었다.
중복되는 Pagination에 대한 처리
fun <T> JPAQueryFactory.basePaging( pageable: Pageable, entityPathBase: EntityPathBase<T>, whereClause: BooleanBuilder? = null ): Pair<List<T>, Long> { val result = this.select(entityPathBase) .from(entityPathBase) .offset(pageable.offset) .limit(pageable.pageSize.toLong()) .where(whereClause) .fetch() if (result.isEmpty()) { return Pair(emptyList(), 0L) } val totalCount = this.select(entityPathBase.count()) .from(entityPathBase) .where(whereClause) .fetchOne() ?: 0L return Pair(result, totalCount) }
여기선 강의 스니펫이 있지만 내가 작성중인 Post 시나리오로 한번 해보기로 했다.
아마 강의 없이 스스로 작성했다면 Pagination에 대한 생각은 못하고 1, 2번에서 그쳤을 것 같은데 다음과 같이 분리하니 꽤 구체적인 테스트 시나리오가 된 것 같다.
// test/resources/application.yml
// 이 config은 test profile일 때만 사용할 수 있게 된다
spring:
config:
activate:
on-profile: test
datasource:
url: jdbc:h2:mem:test;MODE=MySQL;
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
@Test
fun `SearchType 이 NONE 일 경우 전체 데이터 조회되는지 확인`() {
// GIVEN
// WHEN
val result1 = postRepository.searchByKeyword(PostSearchType.NONE, "", Pageable.ofSize(10))
val result2 = postRepository.searchByKeyword(PostSearchType.NONE, "", Pageable.ofSize(6))
val result3 = postRepository.searchByKeyword(PostSearchType.NONE, "", Pageable.ofSize(15))
// THEN
result1.content.size shouldBe 10
result2.content.size shouldBe 6
result3.content.size shouldBe 10
}
/*...*/
@Test
fun `조회된 결과가 10개, PageSize 6일 때 1Page 결과 확인`() {
// GIVEN
// WHEN
postRepository.searchByKeyword(PostSearchType.TITLE_CONTENT, "sample", pageable)
// THEN
result.content.size shouldBe 4
result.isLast shouldBe true
result.totalPages shouldBe 2
result.number shouldBe 1
result.totalElements shouldBe 10
}
현재 Post의 구조는 다음과 같다.
@Entity
@Table(name = "post")
class Post(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
val title: String,
val content: String,
@ManyToOne
@JoinColumn(name = "user_id")
var user: User,
@Enumerated(EnumType.STRING)
var status: PostStatus = PostStatus.RECOMMEND,
@ManyToOne
@JoinColumn(name = "category_id")
var category: Category,
@OneToMany(mappedBy = "post", cascade = [CascadeType.ALL], orphanRemoval = true)
var postTags: Set<PostTag> = hashSetOf()
)
연관 관계가 User, Category, PostTag로 많은데 PostRepository의 테스트를 위해 얼만큼 다른 의존성이 필요한지가 애매했다. Mocking으로 처리하자니 Post의 생성이 마냥 쉽지가 않았고 ApplicationRunner로 구현했던 것보다 어려웠다.
Could not set value of type [org.hibernate.collection.spi.PersistentSet]: 'sparta.nbcamp.reviewchapter5.domain.post.model.tag.Tag.postTags' (setter)
org.springframework.orm.jpa.JpaSystemException: Could not set value of type [org.hibernate.collection.spi.PersistentSet]: 'sparta.nbcamp.reviewchapter5.domain.post.model.tag.Tag.postTags' (setter)
우선 hastSetOf()
가 문제였다. Hibernate가 postTags
를 인식할 때 타입 불일치가 발생한다고 되어있는데 에러 메시지에 나타나던 PersistentSet
로 프록시 객체를 래핑하는 과정에서 HashSet
으로 설정할 때 타입 불일치가 발생한다고 한다. Entity는 래핑이 필요하니 구체적인 구현체 말고 Interface를 설정하라고 되어있다.
이 사실을 조금 들은 적은 있었는데 명시된 Type은 Set, Default 내용은 HashSet 구현체를 넣어놓으니 문제가 발생할 줄은 몰랐고 MutableSet<PostTag> = mutableSetOf()
으로 수정하니 해결이 됐다.
나는 추상 클래스인 QueryDslSupport
의 Bean 주입 문제나 Repository의 주입 문제를 계속 의심하고 있었는데 Entity 자체의 문제인 걸 발견하는 데에는 몇 시간이 걸리고 말았다.
private val postService = PostServiceImpl(postRepository, userRepository)
강의에서는 다음과 같이 가장 가벼운 방식을 선택했다. Spring boot의 지원이 없는 환경에서도 생성자를 통해 의존성을 자연스럽게 주입 가능하기 때문에 필드 주입 방식보다 생성자 주입 방식을 쓰면 테스트 코드 작성에 용이한게 눈에 띈다.
이후 GIVEN
에 해당하는 부분의 코드도 실제 데이터가 되는 느낌이라 생각하면 된다. 통합 테스트라고 별도의 의존성이나 모듈을 추가로 요구하는게 아니라 통합 시나리오를 작성하는 흐름을 이해하고 있으면 될 것 같다.
// 강의에서 진행한 테스트 코드
@Test
fun `이미 회원가입되어있는 이메일이라면 예외가 발생하는지 확인`() {
// GIVEN
val 기존_회원 = Member(email = "slolee@naver.com", password = "1234", nickname = "박찬준")
memberRepository.saveAndFlush(기존_회원)
val req = MemberRegisterRequest(email = "slolee@naver.com", password = "4321", nickname = "WIZ")
// WHEN & THEN
shouldThrow<RuntimeException> {
memberService.register(req)
}.let {
it.message shouldBe "이미 존재하는 이메일입니다."
}
// 추가로 데이터베이스에 저장된 값까지 검증할 수 있다.
memberRepository.findAll()
.filter { it.email == "slolee@naver.com" }
.let {
it.size shouldBe 1
it[0].nickname shouldBe "박찬준"
}
}