테스트 DB 초기화가 필요하다!
테스트를 진행하다보면 사전/사후 처리가 상당히 빈번하게 활용됩니다.
@BeforeEach
, @BeforeAll
, @AfterEach
, @AfterAll
등과 같이 말입니다.
오늘 다루는 내용 또한 사전에 테스트를 위한 더미데이터를 생성하는 과정에서 발생했던 문제와 그에 대한 해결 과정을 소개해드리고자 합니다.
🚨 문제 상황
유저 도메인에 대한 Entity와 JPA Repository를 구현한 뒤, Repository에 대한 테스트를 진행하고 있을 때였습니다.
Test의 경우, 아래와 같은 구조로 @Nested
를 통해 테스트 클래스에 대한 독립성을 분리하여 관리하고 했습니다.
class UserRepositoryTest {
@Nested
@TestInstance(Lifecycle.PER_CLASS)
class SingleUserSelectTest {
...
}
@Nested
@TestInstance(Lifecycle.PER_CLASS)
class MultiUserSelectTest {
...
}
}
@Nested
를 활용하여 관심사와 시나리오에 따라 클래스로 분리하여 테스트를 구현하고 있던 중,@BeforeAll
을 통해 각 Test Class마다 필요한 Expected Dummy Data를 미리 Insert 준비해놓은 후, 각 Test 메서드에서 조회해오는 과정에서 한 가지 문제점을 발견했습니다.
바로 ID를 통한 조회에 실패한다는 것입니다.
@DataJpaTest
의 경우, 내부적으로 @Transactional
어노테이션이 부착되어 있어
각 Test 메서드 실행 후, 롤백(Rollback)이 되기 때문에 데이터 저장과 삭제는 문제 없었지만 ID의 Sequence, Index 등은 지속적으로 누계됩니다.
단일 사용자에 대한 조회 테스트의 경우,
아래와 같이 @BeforeAll
을 통해 더미 데이터 객체를 미리 저장해놓고 given 절에서 더미 객체로부터 ID를 가져와 조회하기 때문에 문제가 발생하지 않았지만
class UserRepositoryTest {
...
@Nested
@DisplayName("[TEST] 단일 유저 대상 단일 ID로 조회하는 시나리오")
@TestInstance(Lifecycle.PER_CLASS)
class SingleUserSelectTest {
private User user;
@BeforeAll
void setUpSingleUser() {
User userEntity = User.builder()
....
.build();
user = userRepository.save(userEntity);
}
@Test
@DisplayName("Case. 단일 유저에 대한 조회 성공")
void getSuccessTest() {
// given
val userId = user.getId();
// when
val result = userRepository.findUserById(userId);
...
}
...
}
다중 사용자에 대한 조회 테스트의 경우,
ID의 시퀀스가 초기화되지 않아 의도했던 given 절에 주어진 ID 리스트 기반으로 객체 리스트를 조회해오는 테스트를 통과하지 못하는 문제가 발생했습니다.
class MultiUserSelectTest {
...
@BeforeAll
void setUpMultiUser() {
User userA = User.builder()
...
.build();
User userB = User.builder()
...
.build();
User userC = User.builder()
...
.build();
userRepository.saveAll(List.of(userA, userB, userC));
}
...
@Test
@DisplayName("Case. 복수 유저 조회 성공")
void getSuccessTest() {
// given
val ids = List.of(1L, 2L, 3L);
// when
val result = userRepository.findAllUsersById(ids);
// then
assertThat(users).usingRecursiveComparison()
.isEqualTo(result);
}
}
(단일 유저 조회 테스트가 수행된 이후, 다중 유저 조회 테스트가 수행될 경우, ID Sequence가 초기화되지 않아 다중 유저 조회 테스트 Nested Class에서의 @BeforeAll
에서 주입된 데이터의 ID는 3부터 시작되는 것을 알 수 있습니다.)
물론 @TestClassOrder(ClassOrderer.OrderAnnotation.class)
를 부착하고
다중 유저 테스트 Nested Class가 먼저 실행되도록 보장시켜준다면 급하게 해결이 가능하지만
테스트는 독립되어 있어야 하기 때문에 억지로 Order을 지정하여 순서를 보장하는 것은 바람직하지 못하다고 생각하여 해결 방안을 찾게 되었습니다.
💡 해결 방안 도출
1. DirtiesContext 활용
[장점]
테스트간 환경을 명확하게 구분하여 격리 환경 관리가 가능하다. (컨텍스트를 다시 로드하는 방식)
[단점]
매 테스트마다 Context 자체를 다시 로드하기에 속도가 느려진다.
@Nested
내부에 정의된 Test에 대해서는 적용되지 않는다. (Spring 공식 문서 참고)물론 가능하기는 하나 내부에 DirtiesContext 전파를 위해선 추가적인 설정이 필요하다.
(Spring 공식 문서 참고 - @NestedTestConfiguration)
2. @SQL & 정적 SQL 리소스 파일 활용
Test Class에 @SQL
어노테이션을 부착하여
테스트가 실행될 때마다 실행 전에 SQL Script가 실행되도록 하는 방식입니다.
[장점]
여러 Query & 여러 Script를 직접 설정하여 수행시킬 수 있다.
초기화뿐만 아니라 초기 Dummy Data까지 함께 넣을 수 있다.
[단점]
테스트에서 활용하는 DB System 환경이 변경되었을 때, Dialect 등과 같은 설정값까지 일괄적으로 반영해주어야 한다.
쿼리를 정적 데이터로 관리하다보니 컴파일 타임에 오류를 발견하기 어렵다.
연관관계, 제약조건 등이 복잡하게 설정되어 있는 경우, 쿼리문 작성에 부담이 늘어난다.
3. EntityManager 활용 & DB 초기화 실행하는 InitializingBean 구현
DB 초기화 관련 작업에 대해 InitializingBean
로 구현하여
Spring Bean이 초기화, 소멸 될 때 관련 행위 로직을 추가하여 Component로서 활용하는 방법입니다.
[장점]
테스트 코드가 쿼리에 의존적이지 않다. (EntityManager 활용)
설정을 분리하여 관리할 수 있다.
(@ActiveProfiles
&@Profile
등과 같이 적용 설정 등을 유연하게 관리 가능 )
[단점]
JPA에 종속적이다.
Bean으로 생성하여 관리되기 때문에 해당 컴포넌트 클래스를 프로덕션에서도 활용 가능하다는 특징이 있다.
💻 실제 적용
해당 API에 대한 구현 Due date를 고려했을 때 가장 간단하게 구현할 수 있으면서도 Entity 구현 상황에 따라 자동으로 반영할 수 있고
직접 .sql
정적 파일과 같은 리소스들을 직접 관리하지 않을 수 있다는 점을 미루어
" EntityManager
를 활용하여 Table 들을 초기화 하는 방법 "을 채택하게 되었습니다.
우선 @DataJpaTest
에서 DB 초기화 객체를 주입받아 사용하기 위해 @Component
를 부착한 후,InitializingBean
을 Implements 하여 자동으로 BeanFactory에 세팅되도록 해줍니다.
Interface to be implemented by beans that need to react once all their properties have been set by a BeanFactory: e.g. to perform custom initialization, or merely to check that all mandatory properties have been set.
<
InitializingBean
JavaDocs 중>
이 때 유의할 점은 테스트 내에서 DB에 있는 Table 정보를 파악해서 모든 Table에 TRUNCATE
(초기화)해주기 위해서는
인스턴스 생성(초기화) 시점에 Table에 대한 정보를 파악하는 과정이 필요한데
이에 InitializingBean
을 Implements 하여 Table 정보 수집 로직을 커스텀 초기화 로직으로 구현했습니다.
public class DatabaseCleaner implements InitializingBean {
@PersistenceContext
private EntityManager entityManager;
private final List<String> tableNames = new ArrayList<>();
@Override
public void afterPropertiesSet() {
findAllTableNames();
}
private void findAllTableNames() {
List<String> names = entityManager.getMetamodel().getEntities().stream()
.filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
.map(e -> {
String snakeName = convertToSnake(e.getName());
if (snakeName.equals("user")) {
return "users";
}
return snakeName;
})
.toList();
tableNames.addAll(names);
}
// Camel Case인 Entity이름을 Snake Case로 변환하는 메서드입니다.
private String convertToSnake(String camel) {
StringBuilder builder = new StringBuilder();
int index = 0;
for (char alphabet : camel.toCharArray()) {
if (Character.isUpperCase(alphabet)) {
if (index == 0) {
builder.append(Character.toLowerCase(alphabet));
} else {
builder.append("_").append(Character.toLowerCase(alphabet));
}
} else {
builder.append(alphabet);
}
index++;
}
return builder.toString();
}
}
이를 통해 해당 클래스가 Bean으로 등록되었을 때, 자동으로 Table 이름을 수집하여 저장하고 있게 됩니다.
그 다음, DB Table들을 TRUNCATE 해주는 메서드를 구현해주면 끝입니다.
public class DatabaseCleaner implements InitializingBean {
private static final String TRUNCATE_TABLE_QUERY_FORMAT = "TRUNCATE TABLE %s";
private static final String ALTER_TABLE_ID_COLUMN_TO_START_FROM_ONE_QUERY_FORMAT = "ALTER TABLE %s ALTER COLUMN id RESTART WITH 1";
@PersistenceContext
private EntityManager entityManager;
private final List<String> tableNames = new ArrayList<>();
@Override
public void afterPropertiesSet() {
findAllTableNames();
}
@Transactional
public void execute() {
entityManager.clear();
truncate();
}
private void truncate() {
for (String name : tableNames) {
entityManager.createNativeQuery(
String.format(TRUNCATE_TABLE_QUERY_FORMAT, name)
).executeUpdate();
entityManager.createNativeQuery(
String.format(ALTER_TABLE_ID_COLUMN_TO_START_FROM_ONE_QUERY_FORMAT, name)
).executeUpdate();
}
}
}
DELETE
나 DROP
을 쓰지 않고 TRUNCATE
를 사용하는 이유는 아래와 같습니다.
DELETE
: 단순 행이 삭제되는 것 뿐 의도했던 ID 초기화는 이루어지지 않는다DROP
: 테이블 자체를 삭제하는 것은 의도한 처리가 아니다.
위와 같이 구현한 DatabaseCleaner 객체를 Test에 직접 사용해봅시다.
@DataJpaTest
@EntityScan(basePackages = "...")
@ContextConfiguration(classes = {
...,
DatabaseCleaner.class,
...
})
@EnableAutoConfigurationclass UserRepositoryTest {
@Nested
@TestInstance(Lifecycle.PER_CLASS)
class MultiUserSelectTest {
@BeforeAll
void setUpMultiUser() {
cleaner.execute();
User userA = User.builder()
...
.build();
User userB = User.builder()
...
.build();
User userC = User.builder()
...
.build();
userRepository.saveAll(List.of(userA, userB, userC));
}
@Test
void getSuccessTest() {
// given
val ids = List.of(1L, 2L, 3L);
// when
val result = userRepository.findAllUsersById(ids);
// then
assertThat(users).usingRecursiveComparison()
.isEqualTo(result);
}
}
}
그 결과, 성공적으로 테스트를 수행하는 것을 확인해볼 수 있습니다.
❓ @DataJpaTest 에서 새로 정의한 @Component 활용
기본적으로 @DataJpaTest
는 JPA 구성 요소에만 초점을 맞춘 어노테이션이기 때문에
전체 Auto Configuration이 비활성화된다는 특징이 있습니다.
Annotation for a JPA test that focuses only on JPA components.
Using this annotation will disable full auto-configuration and instead apply only configuration relevant to JPA tests....
<@DataJpaTest JavaDoc>
이에 @ContextConfiguration
을 통해 @Component
로 등록한 DatabaseCleaner.class
를 불러와 사용할 수 있게 해야 합니다.
The term component class can refer to any of the following.
A class annotated with @Configuration
A component (i.e., a class annotated with @Component, @Service, @Repository, etc.)
A JSR-330 compliant class that is annotated with jakarta.inject annotations
Any class that contains @Bean-methods
Any other class that is intended to be registered as a Spring component (i.e., a Spring bean in the ApplicationContext), potentially taking advantage of automatic autowiring of a single constructor without the use of Spring annotations
...
<@ContextConfiguration JavaDoc>
물론 위 방법도 분명히 단점이 존재하는 방법이며 추후 사이드 이펙트가 충분히 발생할 수 있지만
팀의 상황과 구현 환경, 러닝 커브 등 여러 환경을 고려하여 현재 상황에 가장 적합한 선택지였기에 채택하게 되었습니다.
꾸준히 테스트 독립성(Test Isolation)을 위해 더 고민하고
조금 더 유연하고 확장성 있는 방법이 무엇인지 더 탐구해볼 필요가 있을 것 같습니다.