테스트코드에서 @Transactional을 쓰는 것이 마치 금기인 것 같은 분위기이다.
남들이 쓰지 말래서, 아무도 안 쓰니까, 코드 리뷰받을까 봐 등등의 이유 말고
제대로 된 이유를 알아보자!
[ 목차 ]
- 왜 테스트 코드에서 @Transactional을 사용하고 싶은 걸까?
- 언제, 어떤 문제가 발생할까?
- 그럼 어떻게 해야 할까?
- 그렇다면 테스트코드에서의 @Transactional은 무조건 나쁜 걸까?
1. 왜 테스트 코드에서 @Transactional을 사용하고 싶은 걸까?
대표적인 이유는 자동 롤백을 통한 손쉬운 테스트 격리이다.
JUnit의 @Test 내에서 @Transactional을 선언하면 예외가 발생하지 않아도 데이터가 롤백된다.
따라서 테스트 수행 이후 데이터가 모두 삭제되기 때문에 테스트 간 격리가 가능하다.
어노테이션 한 번에 손쉽게 격리되니 매력적일 수 밖에.
[ TMI ]
- TranscationTestExecutionListener를 통해 해당 로직이 실행된다고 한다.
- 자세한 내용이 궁금하다면 공식 문서 참고!
2. 언제, 어떤 문제가 발생할까?
[1] 메인 코드에서 @Transactional을 누락한 경우
메인 코드에서 @Transactional을 누락했지만 테스트 코드에서는 @Transactional이 존재한다면 메인 코드와 테스트 코드가 다르게 동작할 수 있다.
[ 예시 ]
특정 entity를 조회하는 로직을 테스트하는 경우를 가정해 보자.
테스트 코드에서는 @Transactional을 선언함으로써 lazy loading을 통해 정상 조회된다.
하지만 메인 코드에서 @Transactional 을 누락했다면 lazy loading 불가능함으로써 LazyInitializationException 발생할 수 있다.
[2] 비동기 메서드가 존재하는 경우
비동기 메서드가 존재하는 경우 동일한 트랜잭션이 아니므로 롤백되지 않는다.
[3] TransactionalEventListener를 이용하는 경우
메인 로직이 끝나더라도 테스트 로직의 트랜잭션이 끝나지 않았기 때문에 TransactionalEventListener가 정상적으로 수행되지 않는다.
[4] @BeforeTestClass, @AfterTestClass를 사용하는 경우
@BeforeTestClass, @AfterTestClass는 클래스 단위의 life cycle을 가진다.
따라서 @Test와 동일한 트랜잭션이 아니기 때문에 의도치 않은 동작이 발생할 수 있다.
[ FYI ]
BeforeEach, AfterEach는 메서드 단위의 life cycle을 가지기 때문에 @Test와 같은 트랜잭션을 공유한다.
[5] @SpringBootTest를 RANDOM_PORT / DEFINED_PORT로 이용하는 경우
RANDOM_PORT와 DEFINED_PORT는 별도의 스레드에서 스프링 컨테이너가 실행된다.
이는 클라이언트(테스트)와 서버가 별도의 스레드에 존재함을 의미한다.
따라서 테스트 코드에 @Transactional이 존재하더라도 동일한 트랜잭션이 아니므로 롤백되지 않는다.
[6] 트랜잭션 전파 속성을 조절한 경우
메인 코드의 트랜잭션 전파 속성이 REQUIRED_NEW 등인 경우 테스트 코드의 트랜잭션과 독립됨으로써 동일한 트랜잭션이 아니므로 롤백되지 않는다.
3. 그럼 어떻게 해야 할까?
[1] 해당 테스트에서 필요한 데이터는 테스트 안에서 만들고, 그 안에서만 사용하기
테스트를 위해 필요한 데이터는 여러 테스트에서 공유하지 않고 새롭게 생성한다. ( 개인적으로는 가장 정석의 방법이라고 생각한다. )
@Test
@DisplayName("주문 로직 테스트")
void test(){
// given
상품, 회원 등의 객체를 새롭게 만든다.
// when
주문 로직을 실행한다.
// then
정상 실행되었는지 확인한다.
}
- 장점
- 테스트코드 내에서 필요한 데이터들을 given절에서 모두 확인할 수 있다.
- 단점
- 매번 데이터를 생성하는 로직을 작성해야 한다.
[2] 테스트 실행 전/후에 데이터를 초기화하기
테스트 실행 전/후에 데이터를 수동으로 초기화할 수 있는 로직을 작성한다.
[ 방법 1 ] @AfterEach & EntityManager 조합
@AfterEach를 통해 모든 테이블의 데이터를 Truncate 하는 로직을 작성함으로써 매 테스트 이후 데이터가 삭제될 수 있도록 한다.
private EntityManage em;
@AfterEach
void databaseClean(){
// 전체 테이블명 취득
var tableNames = em.getMetamodel()
.getEntities()
.stream()
.map(Type::getJavaType)
.map(javaType -> javaType.getAnnotation(Table.class))
.map(Table::name)
.collect(Collectors.toList());
entityManager.flush();
// FK 체크 임시 비활성화
entityManager.createNativeQuery("SET foreign_key_checks = 0").executeUpdate();
// 전체 테이블 삭제
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
}
// FK 체크 활성화
entityManager.createNativeQuery("SET foreign_key_checks = 1").executeUpdate();
}
[ 방법 2 ] @Sql 활용
특정 sql 파일에 모든 테이블을 초기화하는 쿼리를 작성하고, @Sql을 통해 클래스 실행 이전 데이터가 삭제될 수 있도록 한다.
- 장점
- 한번 만들어두면 각 테스트클래스에서 주입받아 사용할 수 있다.
- 단점
- JPA 이외의 기술을 사용하면 EntityManager를 사용할 수 없거나, DB 기술이 바뀌어 쿼리문 문법이 바뀌는 등 기술의 변동이 일어나면 함께 수정해야 할 수 있다.
- JUnit의 기본적으로 순차 실행 되지만, 병렬 실행되도록 설정을 변경하는 경우 각 테스트 간에 영향을 미칠 수 있다.
[3] @DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD)를 통해 매번 새로운 컨텍스트를 로딩하기 (비추천)
@DirtiesContext 어노테이션에 BEFORE_EACH_TEST_METHOD 파라미터를 추가하면 테스트 메서드마다 새로운 컨텍스트를 로딩한다. 이는 테이블까지 새로 만들어지면서 자연스레 격리가 이루어지는 방식이다.
- 장점
- 어노테이션 하나만으로 격리가 가능하니 매우 간단하다.
- 단점
- 매번 컨텍스트를 새로 로드해야 하니 그만큼 실행시간이 오래 걸린다.
4. 그렇다면 테스트코드에서의 @Transactional은 무조건 나쁜 걸까?
프로그래밍 관련 질문에 무조건이라는 단어가 붙는다면 답은 No일 가능성이 크다.
실제로 @DataJpaTest에는 @Transactional이 포함되어 있다.
@DataJpaTest를 사용하는 목적은 단순한 데이터 조작만 테스트이므로 롤백되는 것이 보다 효율적인 방안이기 때문이리라 짐작한다.
그러니 이제 테스트코드에서 @Transactional을 쓰겠다고 주장하든, 쓰지 않겠다고 주장하든 제대로 된 이유를 근거로 내밀자.
남들이 쓰지 말래서, 아무도 안 쓰니까, 코드리뷰 받을까봐 등등의 이유 말고!