0. 테스트 격리 필요성과 방법
테스트 격리란?
테스트 격리는 테스트가 서로 영향을 주지 않도록 구성하는 것을 의미한다. 즉, 어떤 순서로 실행하든 항상 같은 결과가 나와야 한다.
격리가 되지 않은 코드를 살펴보자. 아래 테스트 코드 실행 시에 "회원 생성" 테스트에서 저장된 데이터가 남아 있기 때문에, "회원 조회" 테스트는 실패할 수도 있다.
@Test
void 회원_생성() {
saveUser("charles");
}
@Test
void 회원_조회() {
List<User> users = findAll();
assertThat(users).hasSize(1); // 실패 가능 지점
}
Spring Test에서는 같은 Context를 사용하는 테스트가 존재할 때, 기존의 Context를 재활용하는 특징이 있다. (이를 ContextCaching이라고 한다.) 그렇기 때문에, 어플리케이션 컨텍스트의 구성이나 상태를 테스트가 변경하면 안된다. 해당 컨텍스트를 그대로 유지한다면, 위 회원 조회와 같이 매 테스트마다 성공여부가 바뀔 수 있다. 이는 올바른 테스트가 아니다.
테스트 격리 방법
스프링에서 테스트를 격리하는 방식은 대표적으로 3가지를 고려할 수 있다. @Transactional @Sql @DirtiesContext인데, 성능상 트레이드오프가 있는 @DirtiesContext를 먼저 알아보자.
1. @DirtiesContext
@DirtiesContext는 앞서 살펴본 컨텍스트를 각 테스트마다 새로 생성하는 방식이다.
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
조금 더 구체적으로 설명하자면, 해당 어노테이션이 붙은 클래스는, 스프링 테스트 컨텍스트 프레임워크가 해당 클래스 내부 테스트는 어플리케이션의 컨텍스트 상태를 변경한다라고 인지하고, ContextCaching 된 컨텍스트 공유를 허용하지 않는다.
1.1 Mode 설정
어노테이션 속성으로는 methodMode와 classMode를 설정할 수 있으며, 이는 각각 메서드에 해당 어노테이션을 사용할 때와 클래스에 어노테이션을 사용할 때 지정할 수 있는 옵션이다. 여기서 중요한 점은 "어느 시점에 컨텍스트를 버릴지 결정한다는 것이다."
즉, 위의 예시 코드는 클래스에 달려있는 어노테이션이며, BEFORE_EACH_TEST_METHOD는 해당 클래스의 각 테스트 메서드가 실행되는 시점에 기존 컨텍스트를 버린다는 의미이다. (전체 classMode와 methodMode가 궁금하다면 공식문서를 참조하길 바란다. 예시 코드와 함께 나와있다.)
1.2 DirtiesContext의 치명적 단점
결국 위의 모드들 중 가장 지엽적인 범위인 메서드 단위로 컨텍스트 폐기를 반복한다면, 컨텍스트는 모든 메서드 실행 전 혹은 후에 재생성된다. 결국 테스트가 느려지는 시간상의 트레이드 오프가 발생한다.
DirtiesContext는 그리고 근본적으로 DB의 저장된 데이터를 초기화해주지 않는다. 인메모리 데이터베이스의 경우, DataSource가 재생성되고 이에 따라 인메모리 DB도 다시 생성되기 때문에 초기화 되는 것처럼 보일지언정, 테스트시 데이터베이스를 실제 DB로 구축해두었다면, DirtiesContext는 의미가 없어진다.
위 두가지 문제를 해결하기 위해, 아래 2가지 옵션을 고려할 수 있는데 이를 살펴보자.
2. @Sql
@Sql은 사실 테스트 격리 목적의 어노테이션이라기보다, 테스트 메서드 실행전에 쿼리를 날려 기존 테스트에서 실행했던 상태 변화를 복구해주는 개념이라고 보면 된다. 즉, @Sql은 테스트 실행 전후에 SQL 스크립트를 실행하여 데이터베이스 상태를 초기화하는 방법이다.
사실 앞서 테스트 격리가 필요한 이유는 이전 테스트에서 DB의 상태를 변경시켜주는 경우가 대부분이다. (어플리케이션 내부 메모리가 업데이트되는 경우도 있겠지만, 사실상 API 서버의 경우 stateless이기 때문에 DB 상태변화가 가장 많을 것이다.) 그렇기 때문에 @Sql로 일부 해결 가능하다.
즉, 앞선 테스트에서 특정 row를 insert를 진행했다면, @Sql을 통해 테스트 종료 시점에 다시 delete하여 원복하는 개념으로 이해하면 편하다. 물론 모든 비즈니스 로직이 단순히 insert, delete 1대1 구조로 매핑되면 좋겠지만, 이는 현실적이지 않다.
따라서, 일반적으로 turncate를 이용한다. DB에서 TRUNCATE 는 테이블 데이터를 통째로 빠르게 삭제하는 명령어이다. 따라서 truncate.sql을 resources 디렉토리에 저장해 둔 뒤, 이를 특정 시점에 실행시킬 수 있다.
-- 디렉토리 위치: test/resources/truncate.sql
-- 예시코드
TRUNCATE TABLE RESERVATION;
TRUNCATE TABLE RESERVATION_TIME;
@Sql(scripts = {"/truncate.sql"}, executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class IntegrationTest {
}
즉, 위의 예시코드는 실행 맥락(ExecutionPhase)을 테스트 메서드 실행 직전으로 설정한 것으로 테스트 실행 전에 DB 전부 비우는 과정을 반복한다.
여기에 추가로 @Sql("./data.sql")을 사용하여, 테스트용 초기 데이터를 설정해줄 수도 있다.
2.1 @Sql의 단점
@Sql의 단점은 직관적이다. 결국, 테이블이 추가될 때마다 TUNCATE 메서드를 추가해줘야 하며, FK로 테이블간의 연관관계가 매핑되어 있는 경우, 자식 테이블을 먼저 지우고, 부모 테이블을 지워줘야 하는 등 번거로운 작업이 수반된다.
2.2 결국 언제 @Sql를 사용하는 것이 좋은가?
결국, 트랜잭션 롤백으로 해결하기 어려운 경우나, 복잡한 초기 데이터 세팅(위에서 살펴본 data.sql)이 필요한 통합 테스트 환경에서 유용하게 활용할 수 있다. 그리고 DB 상태만 초기화하면 되는 경우에 사용할 수 있다.
3. @Transactional
마지막으로 가장 기본적으로 사용할 수 있는 @Transactional에 대해 알아보자. @Transactional도 마찬가지로 테스트 격리를 위해 탄생한 키워드는 아니다. (@Transactional 자세히 알아보기)
@Transactional를 테스트 격리를 위해 사용한다면, 각 테스트 메서드를 하나의 트랜젝션 안에서 실행하고 테스트가 끝나면 자동으로 rollback 시키는 방식으로 활용할 수 있다.
여기서 주의할 점은 @Transactional를 클래스에 붙여도 메서드에 붙이는 것과 동일하게 한 메서드가 독립적인 트랜잭션으로 인식된다.
동작 사이클은 간단히 다음과 같다.
- 테스트 실행
- 트랜잭션 시작
- 테스트 코드 실행 및 DB IO연산 실행
- 테스트 종료
- 자동 rollback 실행
- DB 원상 복구
즉, @Transactional도 기본적으로 DB 변경사항만 롤백하는 방식이다. 그래서 단순히 테스트 데이터만 격리하기 위한 목적이라면 @Transactional가 가장 가볍고 빠르다.
3.1 @Transactional를 테스트 격리시 활용할 때, 주의할 점
@Transactional은 같은 트랜잭션 안에서 실행된 작업만 rollback 된다라는 특징이 있기 때문에, 트랜잭션 경계 밖에서 실행되면 격리가 깨진다. 아래 예시코드를 살펴보자.
@Service
public class Service {
@Transactional(propagation = REQUIRES_NEW)
public void save() {
repository.save(...);
}
}
아래 코드를 살펴보면, propagation = REQUIRES_NEW에 의해 기존 트랜잭션을 A라고 한다면, 새로운 트랜잭션인 B가 생성되는 것을 알 수 있다. 이 트랜잭션 B가 특정 데이터를 DB에 커밋했는데 이 이후 A가 끝나고 rollback을 진행한다고 가정하면, 이때 A는 B가 커밋한 내용은 rollback 시키지 않는다.
다른 예시도 살펴보자. 이는 실제 서버 테스트에서 발생할 수 있는 문제이다. RANDOM_PORT 때문인데,
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Transactional
RANDOM_PORT는 테스트 코드와 서버 코드가 다른 스레드에서 실행된다. 그렇기에 트랜잭션이 공유되지 않고 이 때문에 rollback이 작동하지 않게 된다.
같은 맥락에서 비동기 실행이나, 외부 시스템에 이벤트를 던저 별도의 트랜잭션으로 인지되는 경우, 격리가 제대로 되지 않을 수 있다.
특히 AUTO_INCREMENT는 ROLLBACK되지 않는다. 이 점도 유의하자.
그래도 일반적인 @SpringBootTest + Repository나 Service 테스트에서는 @Transactional로 격리하는 방식이 가장 흔하게 사용된다. 결국, @Transactional은 트랜잭션 경계를 벗어나는 순간 테스트 격리가 깨진다는 점만 염두하자.