Framework & Library/Spring

[Spring Boot] @SpringBootTest를 사용한 테스트의 격리

sechoi 2023. 10. 1. 23:57

이전 방식: @DirtiesContext를 통한 격리

https://github.com/wootecam-gugucon/shopping-mall 프로젝트에서 @SpringBootTest로 통합 테스트를 진행할 때 @DirtiesContext를 사용해 테스트 간 격리를 시켰다.

 

@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class IntegrationTest {

    // 기타 설정
}

 

https://docs.spring.io/spring-framework/reference/testing/annotations/integration-spring/annotation-dirtiescontext.html

 

@DirtiesContext는 ApplicationContext가 더러워졌다고 표시하는 어노테이션이다. 컨텍스트가 더러워지면 캐시에서 제거되고 닫히므로 다음 테스트에서 Spring 컨테이너가 다시 빌드되며 ApplicationContext를 생성한다. 

 

따라서 schema.sql과 data.sql, 그리고 ddl-auto로 지정한 데이터베이스 초기화가 다시 이루어진다. 이는 프로젝트에서 여러 불편함을 야기시켰다.

 

1. 실행 시간 증가

해당 어노테이션을 사용한 인수 테스트에서는 매번 스프링 컨테이너를 다시 띄웠기 때문에 실행 시간이 오래 걸렸다. 이는 개발하는 데 있어서 큰 불편함을 주었으며(테스트 실행=화장실 타임이었다...) 테스트 추가를 꺼리게 만들었다. TDD 방식으로 개발했기 때문에 테스트에서의 불편함은 큰 단점이었다.

 

2. 불필요한 테스트 데이터 주입

스프링 컨테이너를 새로 띄우며 data.sql 또한 매번 실행되어 테스트용 데이터가 만들어졌다. 따라서 테스트 메서드의 내용과 관계없이 모두 같은 데이터를 주입받아야 했기 때문에 불필요한 데이터를 받는 메서드도 있었다.

또한 data.sql가 변하면 테스트가 실패할 수 있으며, 테스트 코드만 보고서는 데이터의 내용을 알 수 없기 때문에 테스트가 해당 파일에 매우 종속적이었다. 이러한 이유로 테스트의 안정성과 가독성 면에서 좋지 않은 방식이었다.

 

이후 방식: TestExecutionListener를 통한 데이터베이스 초기화

https://mangkyu.tistory.com/264

 

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

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

mangkyu.tistory.com

이 포스팅을 참고해 새로운 방식으로 테스트를 격리시킬 수 있었다.

이전 방식에서 문제였던 것은 데이터의 공유였다. 다른 설정들은 굳이 스프링 컨테이너를 새로 띄우면서 초기화할 필요가 없었다.

 

public class IntegrationTestExecutionListener extends AbstractTestExecutionListener {

    @Override
    public void afterTestMethod(final TestContext testContext) {
        final JdbcTemplate jdbcTemplate = getJdbcTemplate(testContext);
        truncateTables(jdbcTemplate);
    }

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

    private void truncateTables(final JdbcTemplate jdbcTemplate) {
        execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY FALSE");
        getTruncateQueries(jdbcTemplate).forEach(query -> execute(jdbcTemplate, query));
        execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY TRUE");
    }

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

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

따라서 TestExecutionListener의 afterTestMethod()를 오버라이드 하여, 테스트 메서드가 끝날 때마다 데이터만 초기화 하도록 해주었다.

  1. 쿼리를 직접 실행하기 위해 getJdbcTemplate()에서 현재 Application Context의 JdbcTemplate 빈을 가져온다. 
  2. 데이터 삭제 시 순서와 상관없는 원할한 삭제를 위해 truncateTables()에서 `SET REFERENTIAL_INTEGRITY FALSE` 쿼리로 외래키 제약을 잠시 해제한다.
  3. getTruncateQueries()에서 전체 테이블을 가져와 truncate 쿼리를 생성하고 실행한다.

 

@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Retention(RetentionPolicy.RUNTIME)
@TestExecutionListeners(value = {IntegrationTestExecutionListener.class,}, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
public @interface IntegrationTest {
}

그리고 커스텀 어노테이션으로 간편하게 설정을 추가할 수 있도록 했다. 테스트 코드가 예뻐졌다 👩‍🎨

 

주의: truncate 명령어

데이터베이스의 테이블을 삭제하는 명령어에는 truncate와 drop이 있다. 둘 다 테이블의 전체 데이터(row)를 삭제하는 건 같으나 차이점이 있다.

- truncate: 테이블 자체는 남아있으며, 인덱스 혹은 auto_increment 속성 또한 남아있다.

- drop: 테이블도 삭제된다. 아무것도 남지 않는다.

 

현재 truncate 명령어로 데이터를 초기화하고 있기 때문에 auto_increment 속성은 공유된다. 따라서 테스트 메서드마다 기본 키가 1부터 시작됨이 보장되지 않는다. 이것도 테스트 격리가 덜 된 것으로 볼 수도 있으나...

 

1. 기본 키는 IDENTITY 정책으로 데이터베이스에 생성을 위임했기 때문에, 우리가 직접 기본 키를 정할 일이 없으므로 어떤 값이든지 상관없다. 즉 테스트 내 데이터의 기본 키가 1이든 100이든 테스트는 잘 작동하기 때문에 격리가 된 것으로 판단했다.

 

2. 사실은... drop으로 못바꿨다. 껄껄

@Sql(value = {"classpath:schema.sql"})

커스텀 어노테이션에 @Sql을 추가해 drop으로 삭제되는 테이블을 매번 다시 만들도록 시도해봤었다. 그랬더니 테스트 처음에 한 번 스프링 컨테이너가 올라가면서 schema.sql 문이 실행되는 것과 겹쳐 설정에 따라 맨 앞 혹은 맨 뒤 테스트에서 인덱스 생성에 중복이 생겨 테스트가 실패했다.

 

인수 테스트에 @Transactinoal을 선언해 자동 롤백하는 방법도 생각했으나, 프로덕션 코드와 그에 해당하는 테스트 코드의 성공 여부가 달라질 수 있기 때문에(참고) 사용하지 않았다.

 

해당 프로젝트가 끝난 지금 다시 생각해보니, spring.sql.init.mode 프로퍼티 설정으로 schema.sql로 인한 데이터 초기화를 막으면 될 것 같다.