프로젝트에서 통합 테스트와 인수 테스트를 수행할 때, @SpringBootTest를 사용해 테스트를 수행했습니다.
이 때 테스트 격리를 위해 WebEnvironment는 RANDOM_PORT로 지정했습니다.
이로 인해 각각의 테스트 메서드들은 별도의 포트로 실행되었고, @Transactional을 추가했음에도 불구하고 테스트 격리가 되지 않음을 확인했습니다.
다음과 같은 방식을 고려할 수 있습니다.
다만, 다음과 같은 이유로 인해 모두 아쉬운 점을 가지고 있었습니다.
다른 방식을 고민하다 TestExecutionListener를 커스터마이징하기로 결정했습니다.
TestExecutionListener는 스프링에서 제공하는 테스트 실행 주기에서 콜백 메서드를 통해 사용자가 추가 작업을 수행하도록 지원하는 인터페이스입니다.
다음과 같은 Junit 생명 주기에 따른 콜백 메서드를 제공합니다.
Junit 생명 주기 | TestExecutionListener | 설명 |
---|---|---|
@BeforeAll | beforeTestClass() | 모든 테스트를 실행하기 전 단 한 번만 실행/호출 |
X | prepareTestInstance() | 테스트 인스턴스가 준비되었을 때 호출 |
@Test | X | 테스트 메서드 실행 |
@BeforeEach | beforeTestMethod() | 각 테스트 메서드 실행 전에 실행/호출 |
X | afterTestExecution() | 각 테스트 메서드 실행 직후 호출 |
@AfterEach | afterTestMethod() | 각 테스트 메서드 실행 후에 실행/호출 |
@AfterAll | afterTestClass() | 모든 테스트를 실행한 후 단 한 번만 실행/호출 |
각 메서드는 모두 default로 정의되어 있습니다.
TestExecutionListener에서 제공하는 API는 모두 default로 주어집니다.
그러므로 필요한 메서드만 오버라이딩하면 됩니다.
즉, 바로 TestExecutionListener를 구현해 커스터마이징해도 무방하다는 의미입니다.
다만 AbstractTestExecutionListener는 모든 default 메서드를 비어 있는 상태로 오버라이딩하고 있고, Ordered 인터페이스도 확장하고 있어 조금 더 편하게 확장이 가능합니다.
다음과 같이 AbstractTestExecutionListener를 커스터마이징한 DatabaseCleanListener를 정의했습니다.
public class DatabaseCleanListener extends AbstractTestExecutionListener {
@Override
public void beforeTestExecution(final TestContext testContext) {
final EntityManager em = findEntityManager(testContext);
final List<String> tableNames = calculateTableNames(em);
clean(em, tableNames);
}
private EntityManager findEntityManager(final TestContext testContext) {
return testContext.getApplicationContext()
.getBean(EntityManager.class);
}
private List<String> calculateTableNames(final EntityManager em) {
return em.getMetamodel()
.getEntities()
.stream()
.filter(entityType -> entityType.getJavaType().getAnnotation(Entity.class) != null)
.map(this::calculateTableName)
.toList();
}
private String calculateTableName(final EntityType<?> entityType) {
final Table tableAnnotation = entityType.getJavaType().getAnnotation(Table.class);
if (tableAnnotation != null) {
return tableAnnotation.name().toLowerCase();
}
return convertToSnakeCase(entityType.getName());
}
private String convertToSnakeCase(String input) {
return input.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
}
private void clean(final EntityManager em, final List<String> tableNames) {
em.flush();
final StringBuilder sb = new StringBuilder("SET REFERENTIAL_INTEGRITY FALSE;");
for (final String tableName : tableNames) {
sb.append("TRUNCATE TABLE ")
.append(tableName)
.append(";");
sb.append("ALTER TABLE ")
.append(tableName)
.append(" ALTER COLUMN id RESTART WITH 1;");
}
sb.append("SET REFERENTIAL_INTEGRITY TRUE;");
em.createNativeQuery(sb.toString()).executeUpdate();
}
}
각 메서드의 용도는 다음과 같습니다.
이렇게 커스터마이징한 TestExecutionListner를 테스트 생명 주기에 포함시켜야하는데, 이 때 사용하는 애노테이션이 @TestExecutionListeners 입니다.
중요한 옵션을 MergeMode로, @TestExecutionListeners 내부 enum으로 선언되어 있습니다.
enum에는 다음과 같은 옵션이 존재합니다.
JUnit에서는 기본적으로 다양한 TestExecutionListener가 등록되어 있는데, REPLACES_DEFAULTS를 선택하면 등록된 TestExecutionListeners를 수작업으로 등록해야 하기 때문이 불편합니다.
그러므로 MERGE_WITH_DEFAULTS를 통해 기본적으로 등록된 TestExecutionListeners에 DatabaseCleanListener를 추가했습니다.
테스트 클래스마다 @TestExecutionListeners를 등록해야 하는데, 상당히 번거롭습니다.
통합 테스트 및 인수 테스트 등 자주 사용되기 때문에 다음과 같이 별도의 애노테이션을 정의했습니다.
@Trasactional
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Retention(RetentionPolicy.RUNTIME)
@TestExecutionListeners(value = {DatabaseCleanListener.class}, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
public @interface IsolateDatabase {
}
// 사용 예시
@IsolateDatabase
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@SuppressWarnings("NonAsciiCharacters")
class AuctionServiceTest extends AuctionServiceFixture {
...
}
이후 @IsolateDatabase를 명시한 테스트 수행 시 테스트 메서드 실행 전 위와 같이 DB 격리를 수행하는 것을 확인할 수 있습니다.
글 잘봤습니다, 그런데 EntityManager는 작동하기 위해서 @Transactional이 필요한데 어디서 설정하나요?