Test

@SpringBootTest 에서 테스트 격리하기

KAispread 2023. 10. 1. 21:07
728x90
반응형

개요

E2I팀은 RestAssured 프레임워크를 사용하여 인수 테스트를 작성하고 있다. Member 테이블과 관련된 테스트 작성 도중 개별적으로 수행했을 땐 성공하던 테스트가 여러 테스트를 함께 돌렸을 때 깨지는 현상을 발견하였다.

개별 테스트에선 성공하지만 여러 테스트를 한꺼번에 수행했을 때 테스트가 깨지는 모습

보통 이런 경우는 테스트간 환경 분리가 제대로 되지 않아서 발생할 확률이 크다. 이전 테스트에서 수행했던 내역이 남아, 다른 테스트에 영향을 끼치는 것이다.

 

원인 분석

바로 원인을 분석하기 위해 에러 메시지를 확인해본결과, 쿼리를 실행할 때 문제가 발생한다는 것을 알 수 있었고 디버깅해본 결과, Member Entity를 저장하는 로직에서 UndeclaredThrowableException이 발생하는 것을 확인할 수 있었다.

UndeclaredThrowableException 은 여러 상황에서 발생할 수 있지만, 데이터를 저장할 때 이러한 예외가 발생하는 경우는 주로 Unique 제약 조건을 걸어둔 필드에 중복된 값을 삽입하려할 때이다.

E2I 팀은 Test Fixture 을 미리 정의하여 사용하고 있었기 때문에 이전 테스트에서 삽입한 값이 Rollback 되지 않는다면 반드시 예외가 발생한다. 현재까지의 상황을 미루어 보았을 때, 테스트 격리가 제대로 이루어지지 않아 발생한 문제라는 생각이 들었다.

테스트 클래스에 @Transactional 어노테이션을 붙어놓았기 때문에, 당연히 이전 테스트에서 삽입한 데이터는 Rollback 될 것이라 생각했다. @SpringBootTest 에서 @Transactional의 Rollback 기능이 수행되지 않는 조건이 존재하는지 확인하고자 이와 관련된 자료를 찾아보게 되었고, 다음과 같은 정보를 찾아낼 수 있었다.

 

@SpringBootTest에서 @Transactional의 Rollback

stackoverflow 답변

관련된 내용을 찾아본 결과, RANDOM_PORT 혹은 DEFINED_PORT를 사용하는 경우 HTTP client와 Spring 서버 자체가 별도의 쓰레드에서 실행되므로 테스트와 트랜잭션이 하나로 묶일 수 없어, rollback 기능이 수행되지 않는 것이다.

RestAssured는 실제 스프링 컨테이너를 띄워 실행하는 테스트 프레임워크이기 때문에 RANDOM_PORT / DEFINED_PORT 설정이 불가피하다. 따라서, 별도의 쓰레드에서 스프링 컨테이너가 실행되고, 이로 인해 @Transactional 의 Rollback이 수행되지 않아, 테스트간 격리가 이루어지지 않던 것이었다.

 

테스트 격리

따라서, 테스트간 환경을 격리시키기 위해 테이블의 모든 데이터를 삭제하기로 결정했다. tearDown 메서드에 Repository를 통해 Member 테이블의 전체 데이터를 삭제하는 코드를 넣어주었다.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public abstract class AbstractAcceptanceTest {

    @LocalServerPort
    int port;

    @Autowired
    private MemberRepository memberRepository;

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }

    // Member 테이블 모든 데이터 삭제
    @AfterEach
    void tearDown() {
        memberRepository.deleteAllInBatch();
    }

    @Autowired
    protected ResourceLoader resourceLoader;

}

결과로 모든 테스트가 성공하는 모습을 볼 수 있었다.

 

 

tearDown을 개선해보자

Repository를 사용하여 데이터를 삭제해주는 경우, 다음과 같은 문제점이 있다. 

  • 참조 무결성 제약 조건으로 인해, 쉽게 데이터를 지울 수 없음 (데이터 삭제 순서가 중요함)
  • Spring Data Jpa의 Repository를 사용하지 않는 테이블의 경우 데이터를 삭제하는 코드를 따로 작성해야함

 

따라서, 전체 테이블에 대한 데이터 삭제 로직을 하나의 컴포넌트에서 수행하고자 하였다. 

다음과 같은 순서로 데이터를 없애주면 된다.

  1. 전체 테이블 이름을 불러옴
  2. 참조 무결성 제약 조건 무효화
  3. TRUNCATE 로 각 테이블의 모든 데이터 삭제
  4. 참조 무결성 제약 조건 활성화

코드는 다음과 같이 구현하였다. InitializingBean 인터페이스를 구현하여 스프링 빈이 초기화 될 때 테이블 이름을 불러오도록 하였다.

// 데이터 베이스 초기화
@Component
@Profile("local || default")
public class DatabaseCleaner implements InitializingBean {

    @PersistenceContext
    private EntityManager entityManager;
	
    // 데이터 삭제 제외 테이블
    private static final List<String> deletionExclusionList = List.of(
        "GROUP_CODE",
        "CODE"
    );

    private List<String> tableNames;

    // 스프링 빈이 초기화 될 때 실행
    @Override
    public void afterPropertiesSet() {
        // @Table의 name 속성을 통해 테이블 이름을 저장
        tableNames = entityManager.getMetamodel().getEntities().stream()
            .map(entityType -> entityType.getJavaType().getDeclaredAnnotation(Table.class))
            .filter(tableMetaData -> tableMetaData != null 
		      && !deletionExclusionList.contains(tableMetaData.name()))
            .map(Table::name)
            .toList();
    }

    @Transactional
    public void cleanUp() {
        // 쓰기 지연 SQL 저장소 비우기
        entityManager.flush();
        
        // 참조 무결성 제약 조건 임시 해제
        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();

        // 모든 테이블 데이터 삭제 (TRUNCATE)
        for (String tableName : tableNames) {
            entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
        }

        // 참조 무결성 제약 조건 활성화
        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
    }
    
}

 

이후, 테스트가 끝날때마다 cleanUp을 수행해주면 된다.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public abstract class AbstractAcceptanceTest {

    @LocalServerPort
    int port;

    @Autowired
    private DatabaseCleaner databaseCleaner;

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }

    @AfterEach
    void tearDown() {
        databaseCleaner.cleanUp();
    }

    @Autowired
    protected ResourceLoader resourceLoader;

}

 

이후 테스트 결과를 확인해보면? 

각 테스트마다 모든 테이블 데이터를 삭제하여 테스트 격리가 이루어진 것을 확인할 수 있다.

 

참고

deleteAll() vs deleteAllInBatch() 차이

deleteAllInBatch()
-> 테이블의 전체 데이터를 지우는 벌크성 쿼리이다. 어떤 데이터를 먼저 지울지 고민해야함 
deleteAllInBatch()에서는 delete 쿼리만 발생


deleteAll() 
-> 마찬가지로 테이블의 전체 데이터를 삭제한다.
-> DELETE 문 이외에도, 여러 건의 파생 쿼리 발생
-> 이는 연관된 엔티티가 있는지 확인하고 해당 엔티티를 지워주고자하는 동작으로 인해 발생하는 쿼리

-> 실제 내부 로직을 확인해보면 findAll() 작업이 한 번 더 수행됨
deleteAll()에서는 여러건의 쿼리 발생

https://mangkyu.tistory.com/264

https://bperhaps.tistory.com/entry/%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%97%AC%ED%96%89%EA%B8%B0-2

https://stackoverflow.com/questions/46729849/transactions-in-spring-boot-testing-not-rolled-back

728x90
반응형