사용하려는 데이터베이스에 액세스가 가능한지, CRUD 기능이 정상적으로 동작하기 위해 테스트 코드에 중첩 클래스를 사용하면, 비즈니스 로직과 별도로 저장소 인터페이스의 작동과 관련된 기능에 집중해 테스트가 가능합니다.
테스트 계층 코드는 아래와 같습니다.
이 테스트 코드는 세 가지 내부 클래스를 가지고 있습니다. 인메모리 DB인 H2를 사용하는 테스트, 실제 DB인 SQL을 사용하는 테스트, 그리고 DBTest라는 추상 클래스입니다. 실제 DB에서도 제대로 작동하고 있는지 눈으로 확인할 수 있도록 테스트를 두 가지로 나눴지만, 두 테스트가 정확히 동일한 로직으로 테스트를 실행해야 한다는 의도를 명확히 하기 위해 추상 클래스를 추가로 두었고, 두 테스트가 이 추상 클래스를 확장해서 사용하도록 구현된 모습입니다.
@DisplayName("[DB] JPA 연결 테스트")
class JpaRepositoryTest {
@Nested
@DisplayName("인-메모리 DB 테스트")
class InMemoryDBTest extends DBTest {
public InMemoryDBTest(
@Autowired FirstRepository firstRepository,
@Autowired SecondRepository secondRepository
) {
super(firstRepository, secondRepository);
}
}
@Nested
@DisplayName("실제 DB 테스트")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ActualDBTest extends DBTest {
public ActualDBTest(
@Autowired FirtstRepository firstRepository,
@Autowired SecondRepository secondRepository
) {
super(firstRepository, secondRepository);
}
}
@Import(IfConfigNecessary.class)
@DataJpaTest
private abstract class DBTest {
private final FirstRepository firstRepository;
private final SecondRepository secondRepository;
public DBTest(
FirstRepository firstRepository,
SecondRepository secondRepository
) {
this.firstRepository = firstRepository;
this.secondRepository = secondRepository;
}
@DisplayName("select 테스트")
@Test
void givenTestData_whenSelecting_thenWorksFine() {
// Given & When & Then logic...
}
@DisplayName("insert 테스트")
@Test
void givenTestData_whenInserting_thenWorksFine() {
// Given & When & Then logic...
}
@DisplayName("update 테스트")
@Test
void givenTestData_whenUpdating_thenWorksFine() {
// Given & When & Then logic...
}
@DisplayName("delete 테스트")
@Test
void givenTestData_whenDeleting_thenWorksFine() {
// Given & When & Then logic...
}
}
}
이 구조에서 눈여겨 볼 기능은 JUnit5
가 제공하는 @Test와 @Nested인데요. 특히, @Neseted
로 중첩 클래스를 설정하는 기능 덕분에 두 테스트가 공통 테스트 로직을 공유할 수 있게 되어서, 이 테스트의 의도가 코드에 잘 반영되었습니다. Nested 어노테이션의 내부 코드를 확인하면, 이 어노테이션을 사용한 비정적 중첩 내부 클래스는 상위 테스트 클래스와 설정과 상태를 공유할 수 있다는 것을 알 수 있습니다.
위 코드에서는 Neseted 기능을 사용한 두 중첩 테스트가 상속하는 추상 클래스에 CRUD 테스트 메소드가 작성되었습니다.
save와 saveAndFlush를 사용할 때, 두 메서드는 저장하는 동작을 하지만 트랜잭션 커밋 시점에 차이가 있습니다.
아래 코드는 게시판 서비스에서 게시글의 해시태그 수정을 테스트하는 예시입니다. 업데이트 테스트의 실행(when) 부분에서 데이터를 중간 저장하며 saveAndFlush를 사용하고 있습니다.
@DisplayName("update 테스트")
@Test
void givenTestData_whenUpdating_thenWorksFine() {
// Given
Article article = articleRepository.findById(1L).orElseThrow();
article.setHashtag(updatedHashtag);
// When
Article savedArticle = articleRepository.saveAndFlush(article);
// Then
assertThat(savedArticle).hasFieldOrPropertyWithValue("hashtag", updatedHashtag);
}
save와 saveAndFlush는 데이터가 언제 데이터베이스에 반영되어야 하는지에 따라 사용합니다. 즉시 데이터베이스에 반영이 필요하지 않고 트랜잭션이 끝났을 때 자동으로 반영되어도 괜찮다면 save로 충분하고, 즉시 데이터베이스에 변경이 필요하다면 flush가 추가로 필요합니다.
데이터베이스에 변경이 일어나는 시점은 트랜잭션이 끝난 후입니다. 이 코드에서는 업데이트 테스트 메소드가 트랜잭션으로 실행되고 있습니다. 예제 코드에서 볼 수 있듯이 @DataJpaTest
어노테이션이 설정되어 있어 아래와 같은 내용이 자동으로 실행됩니다.
예시 코드에서는 두 방식 중 어느 것을 택하더라도 결과적으로 테스트가 성공합니다.
Hibernate는 영속성 컨텍스트로 엔티티를 관리합니다. save 호출 시 엔티티가 영속성 컨텍스트에 저장되기 때문에, Hibernate는 DB 대신 영속성 컨텍스트에 캐싱된 내용을 참고하게 됩니다. 따라서 save만 사용하고 flush를 호출하지 않아도, 테스트 코드의 then 절에서 결과를 검증할 수 있습니다. 하지만, 트랜잭션이 끝나야 자동 커밋이 실행되기 때문에 실제 데이터베이스에는 반영되지 않은 채 비교됐다는 것을 알 수 있습니다.
// given: 엔티티 저장
entityRepository.save(entity);
// then: flush가 호출되지 않았지만, 영속성 컨텍스트에 의해 엔티티 상태는 업데이트됨
assertThat(entity.getSomeField()).isEqualTo(expectedValue);
한편, saveAndFlush를 사용하면 hibernate가 변경 사항을 데이터베이스에 즉시 반영합니다. 콘솔에서 로그를 확인해보면 Hibernate가 객체 변경사항을 감지해서 SQL UPDATE 쿼리를 실행하는 것을 확인할 수 있습니다. 이 경우 테스트 코드에서 결과를 검증할 때 DB 상태를 기준으로 로직이 실행됩니다. (콘솔에 SQL문이 보이지 않는다면 spring.jpa.show-sql: true 설정을 추가해 주세요)
테스트 코드의 의도에 따라 변경 사항을 데이터베이스에 바로 반영해야 하는 경우인지를 고려해서 save와 saveAndFlush를 사용하는 것이 중요하겠습니다.
참고
https://jojoldu.tistory.com/415 (dirty checking이란)