[JPA/스프링] 스프링의 JPA 테스트

@SpringBootTest를 사용하여 애플리케이션 컨텍스트를 완전하게 생성한 후 애플리케이션의 모든 구성, 레이어 등을 단위 테스트하거나 통합 테스트하는 경우 실제 애플리케이션 구동을 위해 구성된 모든 빈이 스프링 컨테이너에 로드되기 때문에 애플리케이션의 규모가 커질수록 테스트 실행 속도가 느려지는 단점이 있다. 애플리케이션 컨텍스트를 완전하게 구성하는 대신 일부만 구성하여 특정 기능만 부분적으로 테스트할 수 있는데 이러한 테스트를 슬라이스 테스트(slice test)라고 한다. 스프링은 spring-boot-test-autoconfigure을 통해 슬라이스 테스트를 위해 테스트 대상(기능) 별로 관련된 빈만 애플리케이션 컨텍스트에 자동 구성(등록)하는 기능을 지원하는 다양한 어노테이션을 제공한다. 테스트할 기능을 사용할 수 있게 하고 그 외에 @Component이 설정된 다른 빈 정의를 무시한다. 테스트를 위한 자동 구성 설정을 사용자 정의할 수 있는 여러 @AutoConfigure... 어노테이션도 제공한다.

슬라이스 테스트를 통해 레이어드 아키텍처 구조를 따르는 애플리케이션의 레이어 별 동작을 서로 분리하여 테스트할 수 있다. 스프링 부트가 슬라이스 테스트를 위해 제공하는 특정 의존 객체를 주입받아 테스트 시 사용할 수 있다. 예컨대 @WebFluxTest 사용 시 테스트 용 웹 클라이언트인 WebTestClient 빈이 자동으로 구성되며, @DataJpaTest 사용 시 테스트 용 엔티티 매니저인 TestEntityManager 빈이 자동으로 구성된다. 테스트하려는 기능에 해당하는 레이어와 관련 객체의 의존성은 위 어노테이션을 통해 주입하여 테스트할 수 있지만 테스트 대상이 아닌 다른 레이어와 관련된 객체의 의존성은 주입되지 않을 수 있기 때문에 의존 객체의 의존성을 해결하기 위해서는 목 객체를 수동으로 생성하여 주입해야 한다.

JPA를 사용하여 퍼시스턴스 레이어(레포지토리 구현체)의 데이터베이스에 대한 호출을 테스트하기 위해 스프링 부트가 제공하는 슬라이스 테스트 지원 기능을 사용할 수 있다. 스프링은 다양한 슬라이스 테스트 지원 기능을 위한 어노테이션을 제공하며 그 중 @DataJpaTest는 JPA 기능에 대한 슬라이스 테스트 기능을 제공한다. @DataJpaTest를 테스트 클래스에 사용하면 애플리케이션 컨텍스트의 전체 자동 구성이 비활성화되고 대신 JPA 테스트와 관련된 구성만 적용된다. 스프링 데이터 JPA 리포지토리 클래스(단순히 @Repository를 설정한 클래스가 아닌 스프링 데이터 JPA가 제공하는 리포지토리 인터페이스인 JpaRepository의 구현체)와 엔티티 클래스는 스캔 대상이지만 일반 컴포넌트(@Component가 설정된 클래스)는 애플리케이션 컨텍스트에 로드되지 않는다. 또한 테스트 대상 데이터베이스를 직접 호출하는 대신 내장 데이터베이스(인메모리 데이터베이스)를 사용하거나, 테스트 대상 데이터베이스를 직접 호출하는 경우 테스트 시 실행되는 모든 쿼리에 대해 트랜잭션을 적용하여 테스트 종료 시 트랜잭션을 롤백하는 등의 기능들이 제공된다.

@DataJpaTest는 다음과 같은 어노테이션을 사용한다.

@ExtendWith(org.springframework.test.context.junit.jupiter.SpringExtension.class)
@TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
  • @TypeExcludeFilters(DataJpaTypeExcludeFilter.class): DataJpaTypeExcludeFilter 필터를 사용하여 JPA 테스트를 위한 빈(JpaRepository 구현체, EntityManager 구현체 등)을 제외한 특정 빈을 컴포넌트 스캔 대상에서 제외시킨다.
  • @Transactional: 테스트에 대해 트랜잭션을 적용한다. 테스트가 종료되면 트랜잭션을 롤백한다.
  • @AutoConfigureTestDatabase: 애플리케이션에 정의된 또는 자동 구성된 DataSource를 사용하는 대신 테스트 데이터베이스를 구성한다. replace 속성의 기본값은 Replace.Any이다.
  • @AutoConfigureTestEntityManager: 테스트 엔티티 매니저를 구성한다. TestEntityManager 인스턴스를 생성하여 컨테이너에 등록한다.


기본적으로 @DataJpaTest를 설정한 테스트 클래스는 내장된 인메모리 데이터베이스를 사용한다. 따라서 명시적으로 빈 등록하거나 자동 구성된 DataSource를 대체한다. 또한 @DataJpaTest이 적용된 테스트에서 모든 쿼리 실행은 트랜잭션이 적용되어(@Transactional) 테스트가 도중 실패하거나 성공적으로 종료되면 트랜잭션이 롤백된다. @AutoConfigureTestDatabase를 사용하여 이러한 설정을 재정의할 수 있다.

@DataJpaTest@ExtendWith(SpringExtension.class)를 포함하고 있으므로 테스트 클래스 정의 시 생략 가능하다. @ExtendWith(SpringExtension.class)를 테스트 컨텍스트 프레임워크를 JUnit 5와 통합하여 JUnit 5를 사용할 수 있게 해준다.

애플리케이션 컨텍스트를 완전히 불러오지 않으면서 테스트를 위한 내장 데이터베이스만 사용하고 싶다면 @DataJpaTest 대신 @SpringBootTest@AutoConfigureTestDatabase을 함께 사용하면 된다.

@DataJpaTest를 사용하여 JPA 관련 컴포넌트만 선택적으로 빈 등록하여 테스트에 사용하는 것 외에 JPA 기능 테스트에 필요한 다른 컴포넌트의 의존성 주입이 추가적으로 필요할 수 있다. 애플리케이션 컨텍스트를 불러오는 대신 해당 빈만 등록하려면 테스트 클래스에 @Import를 사용한다.

테스트를 수행하는 클래스가 @SpringBootConfiguration이 설정된 구성 클래스와 다른 모듈(또는 패키지)에 위치하는 경우 다음과 같은 오류가 발생할 수 있다.

Unable to find a @SpringBootConfiguration, you need to use @ContextConfiguration or @SpringBootTest(classes=...) with your test
java.lang.IllegalStateException: Unable to find a @SpringBootConfiguration, you need to use @ContextConfiguration or @SpringBootTest(classes=...) with your test


이를 해결하기 위해 테스트 클래스가 위치하는 모듈(또는 패키지)에 @SpringBootConfiguration을 설정한 구성 클래스를 생성해준다.

테스트 클래스가 위치한 테스트 소스셋(src/test/java) 패키지 경로가 구성 클래스가 위치한 그래들 자바 소스셋(src/main/java)의 패키지 경로와 다를 경우 테스트 메서드를 실행할 때 @SpringBootConfiguration(또는 @SpringBootApplication) 어노테이션이 설정된 구성 클래스에 의해 애플리케이션 컨텍스트가 자동으로 구성되지 않는다. 테스트 클래스는 구성 클래스가 위치한 패키지와 동일한 패키지 경로에 위치해야 한다.

테스트 클래스에 @DataJpaTest 어노테이션을 사용하는 경우 애플리케이션 컨텍스트를 완전하게 구성하는 대신 관련된 컴포넌트들로 일부만 구성한다. 이 경우에도 애플리케이션을 초기화하려면 구성 클래스가 필요하다. 스프링은 테스트 클래스가 위치하는 현재 패키지에서 구성 클래스를 검색하고 없을 경우 구성 클래스가 발견될 때까지 상위 패키지 계층 구조를 검색한다. 검색 결과 구성 클래스가 존재하지 않으면 해당 에러가 발생하게 된다.

테스트 클래스가 위치한 패키지가 코드 패키지와 달라야 하는 경우 @SpringBootTest 어노테이션의 classes 파라미터에 애플리케이션 컨텍스트를 로드할 구성 클래스를 지정하여 테스트 실행 시 애플리케이션 컨텍스트를 올바르게 구성할 수 있다.

테스트를 수행하는 클래스가 엔티티 클래스가 위치한 모듈(또는 패키지)과 다른 모듈(또는 패키지)에 위치하는 경우 다음과 같은 오류가 발생할 수 있다.

java.lang.IllegalArgumentException: Unknown entity


이를 해결하기 위해 테스트 클래스와 동일한 모듈(또는 패키지)에 위치하는 구성 클래스에 @EntityScan을 설정하여 엔티티 클래스를 스캔할 베이스 패키지를 지정해준다.

다음은 H2 인메모리 데이터베이스를 사용하고 @DataJpaTest를 통해 스프링 데이터 JPA와 QueryDSL 기능을 슬라이스 테스트하는 코드 예이다. 리포지토리 구현체는 빈 등록하는 대신 직접 인스턴스화하며 이때 @DataJpaTest에 의해 자동으로 생성되는 테스트 엔티티 매니저를 리포지토리 구현체에 수정자(setter) 주입한다.

@Repository
public class CustomRepositoryImpl extends QuerydslRepositorySupport implements CustomRepository {
  
  public CustomRepositoryImpl() {
    super(MyEntity.class);
  }

  @Override
  @PersistenceContext(unitName = "myEntityManager")
  public void setEntityManager(EntityManager entityManager) {
    super.setEntityManager(entityManager);
  }
  
  @Override
  public MyEntity search(String id) {
    JPQLQuery<MyEntity> query = from(myEntity)
      .where(myEntity.id.eq(id));
        
    return query.fetchOne();
  }
}

@SpringBootApplication
@EntityScan(basePackages = {
  "com.jpatest.entity"
})
public class RepositoryTestApplication {

}

@DataJpaTest
@AutoConfigureTestDatabase
public class RepositoryTest {
  @Autowired
  EntityManager em;
  
  CustomRepositoryImpl customItemRepository;
  
  @BeforeEach
  public void init() {
    customItemRepository = new CustomItemRepositoryImpl();
    customItemRepository.setEntityManager(em);
  }
  
  @test
  void 리포지토리_조회_테스트() {
    // given
    MyEntity myEntityA = MyEntity.builder().name("MyEntityA").build();
    em.persist(myEntityA);
    
    // when
    MyEntity myEntityB = customItemRepository.search("1");
    MyEntity myEntityC = customItemRepository.search("2");
    
    // then
    assertEquals(myEntityA, myEntityB);
    assertNotEquals(myEntityA, myEntityC);
  }
}
spring:
  datasource:
    url: jdbc:h2:mem:infra-test-db
    username: username
    password: password
    driverClassName: org.h2.Driver
  jpa:
    database-platform: org.hibernate.dialect.H2Dialect


테스트 컨테이너를 사용하여 JPA 통합 테스트 환경 구성

위와 같이 스프링이 제공하는 슬라이스 테스트 기능을 통해 인메모리 데이터베이스를 사용하여 JPA 기능을 간단하게 테스트할 수 있다. 하지만 이러한 방법은 실제 데이터베이스와 통신하는 것보다는 테스트의 신뢰도가 떨어진다. 단순 연결 및 기능 테스트를 위해 테스트 용 데이터베이스를 별도로 구성함으로써 실제 서비스와 연결하는 과정을 포함시킨다면 보다 신뢰도 높은 통합 테스트가 가능할 것이다.

실제 데이터베이스를 사용한 통합 테스트를 수행하면 데이터베이스 벤더 별 기능 및 SQL 문법 차이로 인해 발생할 수 있는 에러들을 확인하기 위해 네이티브 쿼리 실행 테스트를 해볼 수 있다는 장점도 있다. 물론 스프링 데이터 JPA는 높은 수준의 기능 추상화를 제공하지만 이러한 테스트를 통해 데이터베이스 벤더 및 드라이버 버전 별로 보다 세부적인 기능 검증이 가능하다.

실제 운영 환경과 유사하게 네트워크 상 완전히 분리된 서비스를 구성한 테스트 환경을 구축하기 어렵다면 로컬 환경에 데이터베이스 서비스를 구성하는 방법이 있다. 컨테이너 기술을 사용하여 독립적인 서비스를 격리된 환경에서 실행시키고 개발 중인 애플리케이션과 연결하여 통합 테스트를 매우 용이하게 만들 수 있다. 미니큐브(Minikube)나 k3와 같은 로컬 쿠버네티스 서비스를 사용하면 손쉽게 컨테이너 오케스트레이션 환경에서 여러 서비스들을 통합적으로 테스트해볼 수 있다.

테스트컨테이너(TestContainer)라는 프레임워크를 사용하면 도커 컨테이너 기술을 기반의 통합 테스트 환경 구축을 손쉽게 할 수 있다. 스프링도 이와 관련된 기능을 제공하여 단위 테스트, 슬라이스 테스트 및 통합 테스트를 한 번에 수행할 수 있게 도와준다.

테스트컨테이너는 로컬 도커 호스트(데몬)에 연결하여 테스트 대상 서비스의 도커 이미지를 다운로드한 후 컨테이너를 실행하고 애플리케이션과 통신할 수 있도록 해준다. 테스트가 종료되면 해당 컨테이너도 실행이 종료된다.

스프링 부트에서 JUnit 5와 테스트컨테이너를 통합하여 테스트 클래스를 구성하는 설정은 다음과 같다.

@Testcontainers
@SpringBootTest
class DatabaseTest {
  // 정적 필드로 선언한 테스트 컨테이너는 테스트 메서드 간에 공유된다.
  // 테스트 컨테이너는 테스트 메서드 중 하나가 시작되기 전에 시작되고 마지막 테스트 메서드가 종료되면 종료된다.
  @Container
  private PostgreSQLContainer postgresqlContainer = new PostgreSQLContainer()
    .withDatabaseName("db")
    .withUsername("id")
    .withPassword("pw");

  @Test
  void test() {
    assertThat(postgresqlContainer.isRunning()).isTrue();
  }
}


이때 테스트 시 사용하려는 데이터베이스 기반으로 데이터소스 빈을 직접 구성 및 등록해야 한다. 그렇지 않으면 기본적으로 구성된 인메모리 데이터베이스에 대한 연결 정보로 데이터소스 빈이 구성 및 등록되어 데이터베이스 연결 시 사용된다. 데이터소스 빈을 구성하는 방법은 다음과 같다.

  1. 테스트컨테이너에 의해 자동 생성되는 JDBC URL에 데이터베이스명을 지정한 값을 애플리케이션 구성 파일(application.yaml)의 데이터소스 관련 속성에 설정
  2. 구성 클래스에서 테스트컨테이너의 데이터베이스 연결 정보를 조회한 후 데이터소스 빈을 수동 등록


이때 두 번째 방법을 사용하는 경우 테스트컨테이너 빈이 먼저 구성된 후 데이터소스 빈이 구성되도록 하는 것이 좋다.

@Testcontainers를 통해 테스트컨테이너와 Junit 5을 통합(테스트 컨테이너 확장 기능을 사용)할 수 있다. @Testcontainers@Container가 설정된 모든 필드를 찾아 해당 컨테이너의 수명 주기 메서드(Startable 인터페이스의 메서드)를 호출한다. 정적 필드로 선언된 컨테이너는 테스트 메서드 간에 공유된다. 컨테이너는 테스트 메서드가 실행되기 전에 한 번만 시작되고 마지막 테스트 메서드가 실행된 후에 종료된다. 인스턴스 필드로 선언된 컨테이너는 모든 테스트 메서드에 대해 시작 및 종료된다.

테스트컨테이너에 의해 구동되는 애플리케이션 컨테이너가 시작되기 전에 테스트 메서드가 해당 컨테이너에 접근하려는 경우 다음 에러가 발생하게 된다.

java.lang.IllegalStateException: Mapped port can only be obtained after the container is started


예를 들어 @TestContainers를 적용한 클래스를 테스트 클래스와 별도로 정의하고 @Container를 사용하여 컨테이너의 정적 필드를 정의하는 경우 테스트 클래스의 메서드에서 해당 필드 접근하려고 하면 위 에러가 발생 가능하다. 이러한 에러 발생을 막기 위해서는 컨테이너가 정상적으로 실행된 후에 테스트 메서드에서 컨테이너에 접근하도록 해야한다. @TestContainers를 테스트 클래스에 적용하고, @Container로 컨테이너 인스턴스를 구성하여 테스트컨테이너 확장 기능을 통해 테스트 시 컨테이너가 시작된 후에 첫 테스트 메서드가 실행되도록 하기 위해서는 자동으로 컨테이너의 생명주기가 제어되도록 하는 것이 권장된다. 테스트컨테이너의 생명주기를 직접 제어하고자 하는 경우 테스트 클래스 생명주기 메서드 중 초기화 메서드 내에서 컨테이너를 시작하는 것이 좋다.


Troubleshooting

Driver org.testcontainers.jdbc.ContainerDatabaseDriver claims to not accept jdbcUrl, jdbc:sqlserver://localhost:51950;encrypt=false


참고

Comments