Spring

[Spring] SpringBootTest 테스트 격리 방법

DEV숨 2022. 11. 15. 12:49

테스트 격리란?

마틴 파울러의 비결정적 테스트의 문제를 해결하기 위한 것.

비결정적 테스트란? 같은 입력값에 대해 항상 같은 결과를 출력하지 않는 테스트를 의미한다.

테스트 격리가 되지 않고 비결정적 테스트가 되는 근본적인 원인은? 각각의 테스트가 하나의 자원을 공유하기 때문이다.

그래서 스프링부트와 JUnit에서는 @BeforeEach, @AfterEach, @Transactional과 같은 어노테이션 기반의 격리를 지원한다.

 

 

@SpringBootTest에서 테스트 격리하기

1. @Transactional 어노테이션 사용

@SpringBootTest를 사용한다면? 롤백되지 않는다.

만일 인수테스트를 할 때 @SpringBootTest 어노테이션을 사용한다면 문제가 생긴다. @SpringBootTest는 어노테이션에 webEnvironment = WebEnvironment.*RANDOM_PORT*처럼 port를 지정하여 서버를 띄운다. 이 때 테스트가 돌아가는 서버와 스프링 컨텍스트 서버가 다른 스레드에서 실행된다. 그래서 @Transactional 어노테이션을 달아도 다른 스레드에서 새로운 트랜잭션으로 커밋하기 때문에 롤백이 되지 않는다.

 

 

2. 테스트 수행 이후 데이터 직접 삭제

테스트에서 사용한 데이터를 JUnit의 @AfterEach를 사용하여 테스트가 종료되는 시점에 데이터를 삭제하는 요청을 보내는 것이다.

이러한 방식은 테스트시에 생성해야 할 데이터가 많거나, 연관 관계 매핑이 있다면 굉장히 비효율적인 방식이다.

만일 도메인에 대한 지식이 없는 사람이 테스트를 작성한다면, 연관 관계 매핑으로 생성되는 엔티티를 추적하여 직접 지울 수 없는 상황도 발생할 것이다.

또한 @AfterEach에서 삭제해야 할 데이터가 많다면 테스트 격리만을 위해 테스트 시 삭제 요청이 생성요청보다 많아지는 경우가 생길 수 있다.

 

 

3. @DirtiesContext로 컨텍스트 새로 로드

이 @DirtiesContext 어노테이션이란?

현재 테스트가 실행되고자 하는 컨텍스트에 이미 빈이 올라가 있으면 Dirties를 확인하고 컨텍스트를 새로 로드한다. 즉, 테이블도 다시 새로 만든다.

이 방법은 매우 간편하지만 테스트를 실행하기전에 매번 컨텍스트를 다시 로드하기 때문에 테스트하는데 시간이 오래 걸린다.

부득이한 경우가 아니라면 사용하지 않는게 좋은 방식이다.

 

 

4. 매 테스트 이후 테이블 초기화

매 테스트 이후 테이블 초기화 하는 방법에는 두가지가 있다.

  • DELETE으로 테이블을 초기화하기.
  • TRUNCATE으로 테이블을 초기화하기.

두 가지 방식은 API 요청이 필요없다는 장점이 있다.

그러나 JPA의 경우 deleteAll, deleteById처럼 delete을 호출 할 경우 곧바로 DELETE 쿼리가 수행되는 것이 아닌, select로 조회한 뒤에 delete 쿼리나 수행된다.

또한 삭제를 수행할 때 delete는 행마다 락(lock)을 건다.

delete명령어의 경우 데이터는 지워지지만 테이블 용량을 줄어들지 않는다.

그래서 TRUNCATE방법으로 테이블을 초기화하는 것이 좋다.

TRUNCATE 방법은 행마다 락이 걸리지 않으며, select쿼리도 날아가지 않는다. 또한 데이터가 삭제될 뿐만 아니라 테이블 용량이 줄어든다.

그렇다면 TRUNCATE을 어떻게 적용할 수 있을까? 두 가지 방식이 있다.

 

 

4-1) @Sql 어노테이션 사용

스프링부트에서 제공하는 어노테이션이다. 클래스에 대한 테스트가 실행되기 전에 @Sql이 가리키는 경로에 있는 SQL 실행이 먼저 일어난다. 따라서 sql파일 안에 모든 테이블에 대해 TRUNCATE문을 작성해두면 파일하나와 어노테이션만으로 테스트 격리가 가능하다.

그러나 엔티티가 추가되거나 연관관계 테이블이 추가될 때마다 파일을 수정해주어야 한다는 단점이 있다.

 

4-2) EntityManager 사용

이 방법은 SQL 파일을 실행시키는 것이 아닌 JPA의 EntityManager를 사용하는 방식이다.

@Component
public class DatabaseCleaner implements InitializingBean {

    @PersistenceContext
    private EntityManager entityManager;

    private List<String> tableNames;

    @Override
    public void afterPropertiesSet() {
        tableNames = entityManager.getMetamodel().getEntities().stream()
                .map(entityType -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entityType.getName()))
                .collect(Collectors.toList());
    }

    @Transactional
    public void clearTable() {
        entityManager.flush();
        entityManager.clear();

        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();

        for (String tableName : tableNames) {
            entityManager.createNativeQuery("TRUNCATE TABLE " + tableName + " RESTART IDENTITY ").executeUpdate();
        }

        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
    }
}

다음과 같이 entityManger를 주입받고, 각각의 테스트가 진행된 후 AfterEach로 TRUNCATE 해주는 방식이다.

JPA를 사용했지만 다른 데이터베이스 접근 기술을 사용하더라도 같은 방식으로 TRUNCATE 쿼리가 실행되게 바꾸어주면 된다.

 

 

 

 

reference

https://tecoble.techcourse.co.kr/post/2020-09-15-test-isolation/