티스토리 뷰

Spring

[Spring] @SpringBootTest의 테스트 격리시키기(TestExecutionListener), @Transactional로 롤백되지 않는 이유

망나니개발자 2022. 7. 18. 10:00
반응형

이번에 넥스트스텝 ATDD 강의를 듣게 되었습니다. 과제 중에 @SpringBootTest를 사용하는 테스트들을 격리시키는 부분이 있었는데, 제가 사용했던 방법을 공유하도록 하겠습니다.

 

 

 

 

1. SpringBootTest가 @Transactional로 롤백되지 않는 이유


[ SpringBootTest에서 트랜잭션 롤백되지 않는 이유 ]

테스트에서의 트랜잭션 롤백

@DataJpaTest를 사용하면 영속성 계층을 편리하게 테스트 할 수 있다. 각각의 테스트 메소드가 끝나면 테이블은 비워지면서 모든 테스트가 격리된다. 그 이유는 @DataJpaTest 어노테이션 안에 @Transactional이 있어서 테스트가 끝나면 트랜잭션을 롤백시키기 때문이다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(DataJpaTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@ImportAutoConfiguration
public @interface DataJpaTest {

    ...

}

 

 

하지만 다음과 같이 @SpringBootTest를 RANDOM_PORT나 DEFINED_PORT로 사용하면 트랜잭션이 롤백되지 않는다. 그래서 테스트들이 격리되지 않아 실패하는 모습을 볼 수도 있다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MyTest {

}

 

 

 

@SpringBootTest에서 트랜잭션 롤백되지 않는 이유

@SpringBootTest를 RANDOM_PORT나 DEFINED_PORT로 사용하면 별도의 쓰레드에서 스프링 컨테이너가 실행된다. 테스트가 끝나고 이를 롤백시키려면 하나의 트랜잭션으로 묶여야 하는데, 스프링 컨테이너가 실제로 구동되어 테스트와 다른 쓰레드에서 실행되니 하나의 트랜잭션으로 묶일 수 없는 것이다. 그래서 @SpringBootTest를 RANDOM_PORT나 DEFINED_PORT로 사용하면 @Transactional을 사용해도 롤백되지 않는다. 그러므로 별도의 방법이 필요한데, 이를 우아하게 해결하는 방법에 대해 알아보도록 하자.

 

 

 

 

 

 

 

2. @SpringBootTest의 테스트 격리시키기(TestExecutionListener)


[ @SpringBootTest의 테스트 격리시키기(TestExecutionListener) ]

테스트를 격리시키려면 모든 테이블의 데이터를 삭제해 초기화해주는 방법 밖에 없다. 이를 위한 가장 간단한 방법은 모든 Repository를 조회해 deleteAll()을 시키는 것이다. 이러한 방법은 매우 간단하지만 다음과 같은 한계점이 존재한다.

  • 외래키 등의 제약 조건에 따라 삭제가 어려울 수 있음
  • Repository를 사용하지 않는 테이블이라면 삭제되지 않음

 

 

그래서 테이블 명세만 남기고 모든 데이터를 제거하는 TRUNCATE 명령어를 모든 테이블에 수행해주는 것이 바람직하다. 이러한 과정은 1개의 테스트가 끝날 때마다 실행되어야 하는데, 다음과 같은 순서로 실행되어야 한다.

  1. 테스트가 끝나면 모든 테이블에 대한 TRUNCATE TABLE 명령어를 얻음
  2. 제약조건 무효화 명령어를 실행시킴
  3. 모든 TRUNCATE TABLE 명령어를 실행시킴
  4. 제약조건 재설정 명령어를 실행시킴

 

 

1. 테스트가 끝나면 모든 테이블에 대한 TRUNCATE TABLE 명령어를 얻음

수동으로 테이블에 대한 쿼리문을 관리하면 테이블이 증가할 때마다 수동 작업이 생기므로 번거롭다. RDB에서는 테이블 정보를 관리하는 테이블이 존재하므로 이를 적절히 활용하면 되는데, 일반적으로 테스트에서는 H2(인메모리 DB)를 사용하므로 H2에서는 다음과 같은 명령어를 실행하면 우리가 관리하는 테이블 이름들을 얻을 수 있다.

SELECT TABLE_NAME AS q FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC'

# 쿼리 결과
STATION;
LINE;
SECTION;

 

 

여기에 TABLE_NAME 조회 결과에 "TRUNCATE TABLE" 문자열을 이어붙이면 TRUNCATE TABLE 명령어가 완성된다. 그러면 모든 테이블들에 대한 TRUNCATE 명령어를 List<String>으로 얻을 수 있다.

SELECT Concat('TRUNCATE TABLE ', TABLE_NAME AS q FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC'

# 쿼리 결과
TRUNCATE TABLE STATION;
TRUNCATE TABLE LINE;
TRUNCATE TABLE SECTION;

 

 

 

 

2. 제약조건 무효화 명령어를 실행시킴

하지만 TRUNCATE TABLE 명령어는 외래키(FK) 등에 의해 정상적으로 실행되지 않을 수 있다. 그러므로 TRUNCATE 명령어들의 실행 전에 제약조건을 무효화시키고 끝나면 다시 걸어주어야 한다. 이 쿼리문은 다음과 같다.

SET REFERENTIAL_INTEGRITY FALSE

 

 

 

 

3. 모든 TRUNCATE TABLE 명령어를 실행시킴

제약 조건이 무효화되었으면 이제 얻은 TRUNCATE 명령어들을 실행시키면 된다. 명령어들 예시는 다음과 같을 것이다.

TRUNCATE TABLE STATION;
TRUNCATE TABLE LINE;
TRUNCATE TABLE SECTION;

...

 

 

 

 

4. 제약조건 재설정 명령어를 실행시킴

TRUNCATE 명령어들의 실행이 끝났다면 다시 제약 조건을 걸어주어야 한다. 해당 명령어는 다음과 같다.

SET REFERENTIAL_INTEGRITY TRUE

 

 

 

 

이러한 과정은 테스트 메소드의 실행이 끝나고 진행되면 된다. 이를 코드로 작성하면 다음과 같다.

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

    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    
    @AfterEach
    public void afterEach() {
        final List<String> truncateQueries = getTruncateQueries(jdbcTemplate);
        truncateTables(jdbcTemplate, truncateQueries);
    }

    private List<String> getTruncateQueries(final JdbcTemplate jdbcTemplate) {
        return jdbcTemplate.queryForList("SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') AS q FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC'", String.class);
    }

    private void truncateTables(final JdbcTemplate jdbcTemplate, final List<String> truncateQueries) {
        execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY FALSE");
        truncateQueries.forEach(v -> execute(jdbcTemplate, v));
        execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY TRUE");
    }

    private void execute(final JdbcTemplate jdbcTemplate, final String query) {
        jdbcTemplate.execute(query);
    }

}

 

 

 

 

 

하지만 이러한 방식은 다음과 같은 문제점을 가지고 있다. 그러므로 이를 고도화하여 우아하게 해결해보도록 하자.

(위에서 실행하는 쿼리들은 H2를 기반으로 작성되어 있습니다. 테스트되는 DB가 다르다면 각각의 DB에 맞게 변경해주면 됩니다.)

  • 테스트 클래스가 늘어난다면 중복이 발생함
  • 중복을 제거하려면 상속을 이용해야 함

 

 

 

 

[ @SpringBootTest 테스트 격리 고도화하기 ]

앞선 방식을 모든 테스트 클래스에 적용하는 것은 번거롭다. 이에 대한 대안으로 상속을 주로 떠올리는데, 상속 역시 번거롭다.

Spring은 테스트의 실행 주기에 개입할 수 있도록 리스너 인터페이스인 TestExecutionListener를 제공하고 있다. 이 구현체를 만들어 테스트 실행 시에 등록하면 다음과 같은 시점에 테스트에 개입할 수 있다. 각각은 실행 순서대로 나열된 것이다.

  • beforeTestClass:  테스트 클래스 내의 어떠한 테스트도 실행되기 전에 테스트 클래스를 전처리하기 위해 사용된다.
  • prepareTestInstance: 테스트 객체를 생성하기 위한 전처리 작업을 위해 사용된다.
  • beforeTestMethod: BeforeEach와 같은 Before 콜백들이 실행되기 전에 테스트를 전처리 할 때 사용된다.
  • beforeTestExecution: BeforeEach와 같은 Before 콜백들이 실행된 후에 테스트를 전처리 할 때 사용된다.
  • afterTestExecution: AfterEach와 같은 After 콜백들이 실행되기 전에 테스트를 후처리 할 때 사용된다.
  • afterTestMethod:  AfterEach와 같은 After 콜백들이 실행된 후에 테스트를 후처리 할 때 사용된다.
  • afterTestClass: 모든 테스트의 실행이 끝나고, 테스트 클래스를 후처리할 때 사용된다.

 

 

위의 실행 주기 중에서 우리는 각각의 테스트들이 실행된 후에 Truncate처리를 해주면 되므로 afterTestExecution 또는 afterTestMethod를 사용해주면 된다. 참고로 TestExecutionListener는 인터페이스에 default 메소드가 등장하기 전(Java8 이전)에 등장해서, 해당 인터페이스를 직접 구현하면 모든 메소드를 오버라이딩해주어야 한다. 그러므로 AbstractTestExecutionListener를 사용해 필요한 메소드만 오버라이딩 해주도록 하자. 테스트 클래스의 이름은 상황에 맞게 수정해주도록 하자.

public class AcceptanceTestExecutionListener extends AbstractTestExecutionListener {

    @Override
    public void afterTestMethod(final TestContext testContext) {
        final JdbcTemplate jdbcTemplate = getJdbcTemplate(testContext);
        final List<String> truncateQueries = getTruncateQueries(jdbcTemplate);
        truncateTables(jdbcTemplate, truncateQueries);
    }

    private List<String> getTruncateQueries(final JdbcTemplate jdbcTemplate) {
        return jdbcTemplate.queryForList("SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') AS q FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC'", String.class);
    }

    private JdbcTemplate getJdbcTemplate(final TestContext testContext) {
        return testContext.getApplicationContext().getBean(JdbcTemplate.class);
    }

    private void truncateTables(final JdbcTemplate jdbcTemplate, final List<String> truncateQueries) {
        execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY FALSE");
        truncateQueries.forEach(v -> execute(jdbcTemplate, v));
        execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY TRUE");
    }

    private void execute(final JdbcTemplate jdbcTemplate, final String query) {
        jdbcTemplate.execute(query);
    }

}

 

 

그리고 해당 TestExecutionListener를 테스트 실행 시에 등록해야 하는데, 이는 @TestExecutionListeners를 사용해주면 된다. 여기서 mergeMode는 default로 존재하는 다른 리서너들을 대체할 것인지를 설정하는 것인데, default 리스너를 함께 사용하도록 하면 된다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestExecutionListeners(value = {AcceptanceTestExecutionListener.class,}, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
class MyTest {

}

 

 

하지만 @SpringBootTest를 사용하는 모든 테스트에 이것을 붙여주는 것은 번거롭다. 그러므로 이를 위한 커스톰 어노테이션을 만들어주도록 하자. 나의 경우에는 인수 테스트를 위해서만 @SpringBootTest를 사용하므로 AcceptanceTest라고 이름을 지어주었다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Retention(RetentionPolicy.RUNTIME)
@TestExecutionListeners(value = {AcceptanceTestExecutionListener.class,}, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
public @interface AcceptanceTest {
}

 

 

그러면 이제 테스트를 작성할 때 다음과 같이 어노테이션 하나만 붙여주면 번거로운 작업들이 모두 제거된다. 심지어 테이블을 TRUNCATE 시키는 코드는 세부 구현으로 감춰 복잡성을 줄일 수 있다.

@AcceptanceTest
class MyTest {

}

 

 

 

 

 

이번에는 @SpringBootTest를 사용할 때 트랜잭션이 롤백되지 않는 이유와 테스트를 격리시키는 방법에 대해 알아봤습니다. 해당 내용은 넥스트스텝 ATDD 강의를 하면서 진행하였는데, 해당 내용을 전체 방에 공유하여 강사님으로부터도 좋은 피드백을 받았습니다ㅎㅎ

 

 

 

실제 코드를 참고하려면 깃허브를 참고해주시고, 혹시 더 좋은 방법이나 아이디어가 떠오르시는 분이 계시다면 댓글 남겨주세요!

최대한 빠르게 확인 및 테스트해보도록 하겠습니다ㅎㅎ 감사합니다:)

 

반응형
댓글
댓글쓰기 폼
반응형
공지사항
Total
3,266,498
Today
286
Yesterday
2,361
링크
TAG
more
«   2022/11   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함