테스트를 진행할 때, 테스트간에 영향을 주어서는 안된다.
예를 들어, 삽입 테스트 이후 조회 테스트가 실행되더라도 이전 삽입 테스트 결과에 영향을 받지 않아야한다.
그래서 우테코에서 처음 제공되었던 테스트 코드에는 @DirtiesContext가 설정되어있었다.
(@DirtiesContext는 간단히 말하면 테스트마다 컨텍스트를 새로 로드하는 설정이다. 그래서 상대적으로 많은 시간이 소요된다.)
테스트 코드가 늘어날수록 테스트에 소요되는 시간이 늘어났고, 그 몇 초 조차 기다리기 싫어진 나는 @Sql을 사용해 테스트 데이터를 초기화하는 방법을 적용했었다.
아래와 같은 방식으로 적용 가능한데, 해당 테스트 클래스를 실행할 때 reset_test_data.sql을 실행하겠다는 의미이다. 실행되는 옵션으로 BEFORE_TEST_CLASS, AFTER_TEST_CLASS, BEFORE_TEST_METHOD, AFTER_TEST_METHOD가 있는데, 디폴트 설정은 "BEFORE_TEST_METHOD"여서 별도로 명시하지 않으면 각 테스트 메서드 실행 전 Sql문이 동작한다.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Sql(scripts = "/reset_test_data.sql")
class ReservationIntegrationTest {
...
}
이 방식은 데이터의 변경이 있을 경우 쿼리문을 직접 수정해주어야한다는 단점이 있다.
EntityManager를 통해 데이터를 좀 더 동적으로 관리할 수 있는 방법이 있다고 해서 적용해보기로 했다.
1. InitializingBean 활용
첫번째 방법은 InitializingBean을 활용하는 방법이다.
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은 공식문서에서 설명하고 있는 것 처럼 BeanFactory에 의해 모든 속성이 설정된 후 반응해야하는 bean에 의해 구현되어야하는 인터페이스로, 사용자 정의 초기화를 하거나 필수 속성이 모두 설정되었는지 확인하기 위해 사용된다.
afterPropertiesSet()을 구현해야하는데, 메서드 명에서도 확인할 수 있듯이 properties가 모두 셋팅된 후 호출되는 메서드이다. 우리가 하려는 동작은 모든 애플리케이션 설정이 완료된 후 원하는 데이터로 초기화하는 것이므로, 이 메서드를 통해 생성되어있는 테이블 정보들을 가져오도록 했다. 그리고 데이터를 초기화하는 initialize() 메서드를 작성했다.
테스트 클래스에서 @BeforeEach를 통해 initialize()를 호출하여 테이블을 초기화 할 수 있다.
예시 코드는 아래와 같다. InitializingBean을 구현한 클래스를 작성하고, 해당 클래스를 테스트 클래스에서 사용하면 된다.
@Component
public class TestDataInitializer implements InitializingBean {
@PersistenceContext
private EntityManager entityManager;
private List<String> tableNames = new ArrayList<>();
// 빈 설정이 완료되고나면 테이블 정보를 가져옴
// entityManager.getMetamodel().getEntities().stream()
// .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
// 방식으로도 가져올 수 있으나, 이 경우 대소문자 설정을 따로 해주어야해서 "SHOW TABLES"를 사용했다.
@Override
public void afterPropertiesSet() {
List<Object[]> tableInfos = entityManager.createNativeQuery("SHOW TABLES").getResultList();
for (Object[] tableInfo : tableInfos) {
String tableName = tableInfo[0].toString();
tableNames.add(tableName);
}
}
@Transactional
public void initialize() {
// 쓰기 지연 SQL 저장소 비우기
entityManager.flush();
// 참조 무결성 제약 조건 해제
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
// 모든 테이블 데이터 삭제
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
}
// 초기 테스트 데이터 추가(필요 시)
entityManager.persist(new Reservation(...));
// 참조 무결성 제약 조건 활성화
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}
}
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ClientReservationTest {
@LocalServerPort
private int port;
@Autowired
private TestDataInitializer testDataInitializer;
@BeforeEach
void setUp() {
RestAssured.port = port;
testDataInitializer.initialize();
}
...
}
그런데, 이 방법은 초기화가 필요한 테스트마다 InitializingBean를 구현한 필드를 추가하고 @BeforeEach를 설정해주어야한다는 문제가 있다. 좀 더 간단히 적용할 수 있는 방법은 없을까? 알아보다가 BeforeEachCallback을 사용한 방법이 있어 적용해보았다.
2. BeforeEachCallback
BeforeEachCallback은 JUnit5에서 제공하는 함수형 인터페이스이다.
BeforeEachCallback defines the API for Extensions that wish to provide additional behavior to tests before each test is invoked.
In this context, the term test refers to the actual test method plus any user defined setup methods.
Implementations must provide a no-args constructor.
void beforeEach() 메서드를 구현해야하는데, 이 메서드에 테스트 실행 전 수행할 작업을 작성해주면 된다.
beforeEach()는 매개변수로 ExtensionContext를 갖는데, 현재 실행 중인 테스트에 대한 정보를 제공한다
@FunctionalInterface
@API(
status = Status.STABLE,
since = "5.0"
)
public interface BeforeEachCallback extends Extension {
void beforeEach(ExtensionContext var1) throws Exception;
}
위의 InitializingBean에서 작성했던 내용을 "TestDataInitializer"라는 일반 클래스로 수정하여 @Component로 등록했다.
BeforeEachCallback을 사용하기 위해 이를 구현한 클래스 TestDataInitExtension을 작성하고 TestDataInitializer의 초기화 로직을 수행하도록 했다.
public class TestDataInitExtension implements BeforeEachCallback {
@Override
public void beforeEach(ExtensionContext extensionContext) throws Exception {
TestDataInitializer testDataInitializer = SpringExtension.getApplicationContext(extensionContext)
.getBean(TestDataInitializer.class);
testDataInitializer.resetDatabase();
}
}
@Component
public class TestDataInitializer {
private List<String> tableNames = new ArrayList<>();
@PersistenceContext
private EntityManager entityManager;
@Transactional
public void resetDatabase() {
entityManager.flush();
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
truncateData();
addTestData();
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}
// 객체가 생성되고 의존성 주입이 완료된 후, 즉 객체가 사용 준비가 된 시점에서 호출. 테이블들의 이름을 가져옴
@PostConstruct
private void findDatabaseTableNames() throws Exception {
List<Object[]> tableInfos = entityManager.createNativeQuery("SHOW TABLES").getResultList();
for (Object[] tableInfo : tableInfos) {
String tableName = tableInfo[0].toString();
tableNames.add(tableName);
}
}
// 기존 데이터베이스 삭제
private void truncateData() {
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN id RESTART")
.executeUpdate();
}
}
// 필요한 테스트용 데이터 추가
private void addTestData() {
entityManager.persist(new Member(...));
...
}
}
마지막으로, 해당 초기화 작업을 수행할 테스트 클래스에 @ExtendWith()을 통해 설정해주면 적용된다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(TestDataInitExtension.class) // 애너테이션 추가
class ClientReservationTest {
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
}
...
}
테스트에서 데이터베이스를 사용할 때, 테스트간 격리를 위해 데이터베이스를 초기화하는 방법에 대해 알아봤다.
@DirtiesContext를 사용할때와 BeforeEachCallback(EntityManager)를 사용했을 때를 비교해보면, 실행 속도가 11초 -> 2초로 크게 감소한것을 확인할 수 있다.
정리
1. @DirtiesContext
- 장점: 테스트마다 컨텍스트를 새로 로드해서 성능이 저하될 수 있다.
- 단점: 자동 관리되므로 사용이 간편하고 깨끗한 상태를 보장한다.
- 활용: 전체 컨텍스트를 초기화해야하는 경우
2. @Sql
- 장점: Sql구문을 통해 명시적으로 초기화할 수 있다. 다양한 Sql 스크립트를 사용해 상황에 따라 적합한 초기화를 수행할 수 있다.
- 단점: 사용하는 데이터베이스에 종속적이고, 데이터베이스 변경이 있을 경우 지속적으로 관리해야하며, 복잡한 초기화 로직이 있을 경우 스크립트 작성이 복잡해진다.
- 활용: 명시적인 초기화가 필요한 경우
3. EntityManager
- 장점: 사용하는 데이터베이스에 관계 없이 동적으로 데이터를 관리할 수 있으며, 초기화 로직을 자바 코드로 작성할 수 있다.
- 단점: 초기화해야하는 내용이 많을 경우 코드가 복잡해질 수 있다.
- 활용: 복잡한 초기화 로직이 필요한 경우
참고자료
'우아한테크코스 > 레벨 2 - Spring' 카테고리의 다른 글
[Spring] RestAssured, RestDocs로 API 문서 자동화 하기 with 공식문서 (0) | 2024.06.09 |
---|---|
[Spring] RestClient, MockRestServiceServer로 단위 테스트하기 (1) | 2024.06.02 |
[JPA] @Embedded, @Embeddable 개념과 사용법 (0) | 2024.05.16 |
[Spring] HandlerMethodArgumentResolver 사용하기 (0) | 2024.05.09 |
[Spring] DTO 검증하고 결과 알려주기 with @Valid, @ExceptionHandler (0) | 2024.05.03 |