들어가며
@SpringBootTest를 이용하여 테스트 코드 작성 시 기본적으로 효과적인 테스트를 위해 최초에 생성한 ApplicationContext를 캐싱하여 재사용한다. 이렇게 하지 않으면 매번 새로운 ApplicationContext를 만들고, Bean들을 생성해야 하기 때문에 속도가 매우 느려진다.
하지만 이러한 Context의 재활용은 테스트들 간에 서로 영향을 주는 원인이 되기도 한다. 테스트가 진행되면서 DB의 상태는 계속해서 변경되는데, Context를 재활용하게 되면 변경된 DB의 상태가 다른 테스트에도 반영되게 되므로, 모든 테스트를 한꺼번에 실행할 때 테스트가 실패할 수 있다.
즉, 나중에 수행된 테스트가 먼저 수행된 테스트의 영향을 받게 되는, 테스트들이 완벽하게 격리되지 않은 상황이 발생하게 된다.
테스트들은 순서에 상관없이 독립적으로 수행되어야 한다.
그렇다면 어떻게 각 테스트들이 독립적으로 수행될 수 있도록 격리시킬 수 있을까?
테스트 격리란 테스트들이 순서에 상관없이 독립적으로 수행되어야 함을 의미한다.
@DirtiesContext
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AcceptanceTest {
...
}
가장 쉬운 방법은 캐싱된 Context를 사용하지 않도록 하는 @DirtiesContext 어노테이션을 사용하는 것이다.
이러한 @DirtiesContext를 사용하면 캐싱된 Context를 재사용하지 않고, 매번 새로운 Context를 생성하기 때문에 테스트가 완전히 격리되기는 하지만, 시간이 매우 많이 걸린다는 단점이 있다.
사용하고 있는 DB 접근 기술 이용
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AcceptanceTest {
@Autowired
private UserRepository userRepository;
@Autowired
private ProjectRepository projectRepository;
@Autowired
private UserProjectRepository userProjectRepository;
@BeforeEach
void clearDatabase() {
userProjectRepository.deleteAll();
projectRepository.deleteAll();
userRepository.deleteAll();
}
...
}
위 예제는 DB 접근 기술로 JPA를 사용하고 있을 때이다.
두 번째 방법은 프로젝트에서 사용 중인 DB 접근 기술을 이용하여 테스트 전후로 DB의 데이터를 모두 삭제해주는 것이다. 이렇게 DB 접근 기술을 이용하면 모든 테스트들을 격리시키면서, 매번 새로운 Context를 생성하여 시간이 매우 오래 걸리는 @DirtiesContext의 단점을 보완할 수 있기는 하지만, 위 예제에서 볼 수 있듯이 외래키 제약 조건을 고려해서 삭제 순서를 정해주어야 하기 때문에 예외가 발생하기 쉽다는 단점이 있다.
@Sql
@Sql(scripts = "classpath:/databaseCleaner.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AcceptanceTest {
...
}
세 번째 방법은 @Sql 어노테이션을 사용하여 executionPhase로 설정한 시점마다 지정된 SQL 스크립트를 실행시키는 것이다. 이러한 @Sql을 사용하여 각 테스트의 실행 전이나 후에 DB를 초기화 시키는 SQL 스크립트를 실행시키면, 모든 테스트들을 격리시킬 수 있고, 참조 무결성 옵션을 끄는 쿼리를 수행하여 외래키 제약 조건을 신경쓰지 않을 수도 있다.
하지만 이 방법은 테이블이 새로 생성되거나 변경될 때마다 그에 맞게 스크립트를 수정해주어야 한다는 번거로움이 존재한다.
DatabaseCleaner 구현
@Component
public class DatabaseCleaner {
@PersistenceContext
private EntityManager entityManager;
private List<String> tableNames;
@PostConstruct
public void extractTableNames() {
Session session = entityManager.unwrap(Session.class);
tableNames = new ArrayList<>();
session.doWork(connection -> {
ResultSet rs = connection.getMetaData()
.getTables(null, null, null, new String[]{ "TABLE" });
while (rs.next()) {
tableNames.add(rs.getString("TABLE_NAME"));
}
});
}
@Transactional
public void execute() {
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1").executeUpdate();
}
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}
}
마지막 방법은 EntityManager를 활용하여 구현한 DatabaseCleaner를 이용하는 것이다. DatabaseCleaner의 기능을 살펴보면, 먼저 Bean으로 등록된 후 extractTableNames 메서드를 수행하여 모든 테이블의 이름을 추출하고 저장한다. 그리고 execute 메서드에서는 모든 테이블의 레코드를 제거하고, 기본 키를 초기화시킨다.
각 테스트의 실행 전이나 후에 이러한 DatabaseCleaner의 execute 메서드를 호출하여 DB를 초기화시키면, 테스트들을 격리시킬 수 있게 된다.
여기서 주목해볼만 한 부분은 테이블을 TRUNCATE 시키기 전에 SET REFERENTIAL_INTEGRITY FALSE로 참조 무결성 옵션을 꺼줌으로써 테이블 간의 삭제 순서를 신경 쓰지 않아도 된다는 점이다.
DatabaseCleaner는 테이블의 이름을 추출하는 방식이기 때문에 테이블이 추가되거나 변경되더라도 수정하지 않아도 되며, 참조 무결성 옵션을 제어함으로써 테이블 간의 삭제 순서도 고려하지 않아도 되므로 편리하다.
추가적으로 위 예제에서 본 테이블 이름 추출 방식 이외에도 다음과 같은 방식으로도 테이블 이름을 추출할 수 있다.
@PostConstruct
public void extractTableNames() {
tableNames = entityManager.getMetamodel()
.getEntities()
.stream()
.filter(entityType -> entityType.getJavaType().getAnnotation(Entity.class) != null)
.map(entityType -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entityType.getName()))
.collect(Collectors.toList());
}
이렇게 구현한 DatabaseCleaner는 테스트 코드에서 주입받아서 테스트 실행 전후로 execute 메서드를 호출해줌으로써 각 테스트들을 격리시킬 수 있다.
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AcceptanceTest {
@LocalServerPort
int port;
@Autowired
private DatabaseCleaner databaseCleaner;
@BeforeEach
void setUp() {
RestAssured.port = port;
databaseCleaner.execute();
}
...
}
마치며
테스트 코드를 작성하면서 각 테스트들을 격리시키기 위해 처음에 @DirtiesContext를 사용하다가 테스트 시간이 너무 오래 걸려 DB 접근 기술을 이용하도록 수정하였었다.
이후 DB 접근 기술을 이용하다보니 복잡한 연관 관계가 있는 테이블들의 경우, 외래키 제약 조건을 신경 써주어야 해서 초기화 과정에서 예외가 많이 발생하기도 했다.
이러한 불편함으로 @Sql을 도입하였고, 참조 무결성 옵션을 끄는 쿼리로 외래키 제약 조건을 신경쓰지 않고 테스트를 격리시킬 수 있었다. 하지만 테이블이 추가되거나 스키마가 변경될 때마다 SQL 스크립트를 수정해주어야 하는 번거로움이 존재했다.
마지막으로 이를 개선하기 위해 테이블 이름을 추출할 수 있는 방법을 찾아보았고, 그 결과로 DatabaseCleaner의 도입까지 도달할 수 있었다.
테스트 코드를 작성하면서 느낀 불편함을 해결하기 위해 좀 더 효율적인 방법을 찾아가는 재미가 있었고, 이를 계기로 테스트 코드와 좀 더 가까워질 수 있는 시간을 가질 수 있었던 것 같다.
Reference
'Backend > Spring' 카테고리의 다른 글
Spring REST Docs 적용 (1) | 2022.06.13 |
---|---|
Redis를 활용한 캐싱 적용기 (0) | 2022.05.20 |
트랜잭션 전파 알아보기 (0) | 2022.04.26 |
AOP와 빈 후처리기를 이용한 부가 기능 분리 (0) | 2022.03.26 |
다이내믹 프록시를 활용한 JPA QueryCounter 구현기 (0) | 2022.03.20 |