Post

(Test) 통합 Test를 위한 DB는 어떻게 구성하면 좋을까? - Testcontainers

통합TC에TestContainer사용하기-dist-외부공개.pdf 2.30MB

pdf 내용 요약

Spring Batch는 통합TC 수행하고 나서 자동 rollback이 불가하다.

TC에 alpha DB를 사용하는 경우 아래와 같은 문제가 생긴다.

  • Spring MVC에서는 TC에 @Transactional @Rollback 처리하면, TC 수행 후 자동 롤백이 되기 때문에, 테스트 수행 시 alpha DB를 사용하도록 구성하는 경우가 많다.
  • 그러나 Spring Batch에서는 TC에 @Transactional 사용이 불가하다. 따라서 @Rollback도 사용 불가하다.
  • 롤백이 없으니 TC를 수행하고 나면 DB 상태가 매번 바뀌게 되며, alpha QA하며 실시간으로 변경되는 DB 상태와 TC 수행으로 변경되는 DB 상태가 서로에게 영향을 미치게 된다.
    • TC에서 시작 전 어떤 테이블을 TRUNCATE 했는데, alpha QA에서 필요한 데이터가 들어있었다든가,
    • alpha test로 인해 생성된 데이터가 TC 수행 도중 끼어들어서 TC가 실패한다든가,
  • 모든 통합 TC에 직접 rollback 코드를 작성해준다 하더라도, alpha test가 통합 TC 수행에 영향 주는 것은 막을 수 없다. (심지어 rollback 직접 작성하는것 매우 번거롭다.)
    • 이는 Test를 비결정적(non-deterministic)으로 만들고, 비결정적인 테스트는 신뢰 할 수 없다. (false-negative)

그렇다면 alpha DB를 사용하지 않으면서 테스트 할 수 있는 방안으로는 어떤 것들이 있나?

  • 테스트 전용 DB를 사용한다? – X
    • 여러개의 TC가 동시에 수행되면 DB 상태를 보장 할 수 없다. (CI 때문에 동시 실행 잦음)
  • 각 host 마다 local DB를 사용한다? – X
    • 개발자 PC에 있는 local DB는 충돌이 없겠지만, CI 서버에 있는 local DB의 경우 여러개의 TC가 동시에 수행되면 DB 상태를 보장 할 수 없다.
    • schema 변경 관리가 상당히 귀찮아진다. (schema 변경하면 CI 서버에 있는 DB에도 반영해주어야 한다)
  • H2같은 in-memory DB를 사용한다? – X
    • 운영 DB와 In-Memory DB의 문법이 100% 호환 되지 않는다. (systdate, SUBSTRB, pagination 등)
    • In-Memory DB에서 잘 넘어갔는데, 운영 DB에서는 안될 수 있다. 이런 케이스를 마주칠 가능성이 분명 존재한다. (내장함수 동작 등 차이 있는 부분이 분명히 존재한다)
    • 오라클에서만 되는 문법을 다 걷어내고 호환되도록 짜는 것은 어렵고, 넌센스이며, 절대 H2로 바꾸기 어려울 것 같은 코드도 마주칠 수 있다. 그렇다고 쿼리를 이중으로 짜는건 너무 비효율적이다. (잘 돌아가는데… H2를 위해서 운영 쿼리를 바꿔야 한다? 좀 그렇다…)
  • Testcontainers 를 사용한다 – O
  • alpha DB를 사용하고, 각 test context가 사용하는 table space를 분리하여 자동 생성/삭제한다. –
    • (안해봤는데 이 것도 가능할 것 같다.)

Testcontainer를 사용하는 방법?

지금 만족해야 하는 조건이 [1. 고립된 데이터 상태에서의 테스트 2. 운영DB와 동일한 문법, 동작] 이므로 기존의 Oracle이나 MySql을 이용해 고립된 데이터 상태를 만들 수 있다면 문제가 해결된다.

1
테스트 DB 만들고  ->  [DDL.sql, DATA.sql] 로 초기화하고  ->  TC 돌리고  ->  DB 삭제

TestContainers 사용해서 아예 DB 올렸다 내렸다 하는 식으로 진행하면, 조건 만족 가능하다.
DB 띄우는 시간은 좀 오래걸리겠지만, 이득이 훨씬 커보인다.

  • TestContainers 를 사용한 방법과 Gradle, Maven 빌드 스크립트에 포함하는 방법이 있음.
  • 매번 올렸다 내렸다 하는건 -DreuseDatabase 플래그 이용해서 해결 가능한 듯.
  • DDL이나 초기 데이터는 flyway로 관리

TestContainer, 설정 가이드

  • 명시 안해도 자동으로 @SpringBootTest 테스트 실행 시 container DB 사용하도록 설정.
    • Query를 날리려면 Mapper를 DI 받아야 하므로 어차피 SpringBootTest여야 함. (Spring과 무관한 Unit Test는 X)
    • 각각의 테스트클래스에서 DB 설정하지 않아도 되게끔. 최소 annotation이나 interface로 분리.
  • test container 사용 안하고 디버깅 목적으로 로컬DB, 알파DB 사용해서 돌리고 싶다면, 간단하게 변경 가능할 것.

testcontainer 초기화 방식

  1. 각 TestMethod 마다 매번 새로운 testcontainer 사용하기
  2. 각 TestClass 마다 매번 새로운 testcontainer 사용하기
  3. testcontainer는 1개 띄우고 계속 유지하는데, 각 Test 마다 flyway로 DB를 특정 상태로 되돌리기
  4. 그냥 전체 테스트 suite에서 하나의 testcontainer를 초기화 없이 연속해서 사용 (pick)

4번은 TC를 실행하는 순서에 따라서 상태가 일정하지 않으므로 TC가 의도치 않게 성공/실패할 가능성이 있지만, DB 띄우는 시간을 줄일 수 있음.
docker DB 띄우고 flyway로 초기화하는데 약 20s가 소모됨. 고립 수준과 TC 수행 시간 사이의 trade-off를 고려하여 상황에 맞게 선택할 것.

참고 ) DataSource와 docker container의 생명 주기

  • DB와 연결되어 있는 DataSource 수가 0이 된다면 docker container도 종료된다.
  • DB와 연결되어 있는 DataSource가 생긴다면, docker container가 다시 실행된다.
  • 따라서, 한 test에서 DataSource를 close 하더라도, 다른 ApplicationContext의 DataSource가 docker DB와 연결되어 있다면 docker가 종료되지 않는다.
    • @DirtiesContext 붙여 DataSource Bean이 삭제되는 경우도 마찬가지다. 연결되어 있는 DataSource가 있다면 docker가 종료되지 않는다.

(운영해보니) 결국 나중에 이전 TC의 실행 결과가 다음 TC에 영향을 주어 PK violation 등이 발생함.

  1. 기본적으로 공통 docker DB 사용하고, 지정한 테스트만 개별 docker DB를 사용하는 방법
  2. TestClass 마다 개별 docker DB 사용하는 방법
  3. 이전 TC의 실행 결과에 영향을 받지 않도록 Test를 수정하는 방법

세 가지 방법이 가능함.
1,2번은 testcontainer 설정이 TestClass 내부에 들어가야 해서 로컬DB 쓰려면 TestClass를 수정해주어야 한다는 단점이 있음.

** 참고) TC의 수행 순서를 고정 하는 방법은 가능은 하지만 나쁨. 이런 테스트가 또 생기면 그 때 마다 순서를 고정할 것인가?

현재 상황에서의 추가적인 요구사항으로는…

  • MyBatis가 test container 사용하도록 하려면, test container와 연결된 dataSource bean 필요.
    • [DataSourceAutoConfiguration,DataSourceTransactionManagerAutoConfiguration] 이 비활성화 되어 있으므로, properties 기반 설정을 사용하지 못한다.
    • java config 이용해 [DataSouce, PlatformTransactionManager] Bean을 직접 만들어 줄 것.

AutoConfiguration 사용한다면 java config 작성할 필요 없이 properties 기반으로도 가능

OracleContainer 객체 사용하는 방법

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
@Profile("test")
@TestConfiguration
public class TestDatabaseConfig {
    @Bean
    public OracleContainer oracleContainer() {
        OracleContainer oracleContainer = new OracleContainer("private.docker.registry/umbum/oracle-xe-11g");
        oracleContainer.start();
        return oracleContainer;
    }
    @Bean
    @DependsOn("oracleContainer")
    public HikariDataSource dataSource(OracleContainer oracleContainer) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(oracleContainer.getJdbcUrl());
        HikariDataSource dataSource = new HikariDataSource(config);
        initSchema(dataSource);
        return dataSource;
    }
    private void initSchema(DataSource dataSource) {
        Flyway flyway = new Flyway();
        flyway.setDataSource(dataSource);
        flyway.setBaselineVersionAsString("0");
        flyway.baseline();
        flyway.migrate();
    }
    @Bean
    public PlatformTransactionManager transactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}
1
ryuk.container.image=private.docker.registry/umbum/testcontainers-ryuk:0.3.1

JDBC url 사용하는 방법

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
@Profile("test")
@TestConfiguration
public class TestDatabaseConfig {
    @Bean
    public HikariDataSource dataSource() {
        HikariConfig config = new HikariConfig();
        
        config.setJdbcUrl("jdbc:tc:oracle:///databasename"
            + "?TC_INITFUNCTION=dev.umbum.batch.config.TestDatabaseConfig::initSchema");
        return new HikariDataSource(config);
    }

    public static void initSchema(Connection connection) throws SQLException {
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(connection.getMetaData().getURL());
        Flyway flyway = new Flyway();
        flyway.setDataSource(dataSource);
        flyway.setBaselineVersionAsString("0");
        flyway.baseline();
        flyway.migrate();
    }
    @Bean
    public PlatformTransactionManager transactionManager(@Qualifier("dataSource") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}
1
2
ryuk.container.image=private.docker.registry/umbum/testcontainers-ryuk:0.3.1
oracle.container.image=private.docker.registry/umbum/oracle-xe-11g

flyway 안쓰고 할 수는 없나?

1
2
3
4
5
6
// 잘 동작함.
config.setJdbcUrl("jdbc:tc:oracle:///databasename?TC_INITSCRIPT=db/migration/V1__Initial.sql");

// 소스를 대략 보니 파일만 받을 수 있는 것 같다. 아래처럼 디렉토리를 적으면 invalid SQL statement 오류 발생하는데 파일 이름을 sql문으로 인식하는 듯...?
config.setJdbcUrl("jdbc:tc:oracle:///databasename?TC_INITSCRIPT=db/migration");
>> init script가 하나라면 jdbc url에 적고 간단히 끝낼 수 있지만, 두개 이상의 init script를 사용하려면 INITFUNCTION + flyway 등을 사용해야 한다.

트러블 슈팅

관련 링크

과연 In Memory DB가 TC에 더 효과적인가? 에 대해.
  • github.com/dotnet/efcore/issues/18457
    • efcore라는 DB mapper에서 InMemoryProvider를 제거하느냐 마느냐에 대한 논쟁으로 이 것에 대해서는 의견이 분분하지만, 대략 필요한 부분을 종합해보면
      • 유닛 테스트에서는 InMemory DB가 충분히 유용하다.
      • 통합 테스트에서는 당연히 리얼 DB 써야한다.
  • jimmybogard.com/avoid-in-memory-databases-for-tests/
    • Unit Test에서도 그닥 이점이 없다. 라고 꽤나 강하게 얘기하고 있다. 이건 efcore에서 In-Memory DB가 리얼DB를 흉내내도록 하는 것에 대한 어려움이 포함되어 있기 때문에 사견이 좀 강하게 들어간 것 같다.
    • 아무튼 이 사람도 유닛 테스트에는 InMemory DB 쓰더라도, integration test에는 무조건 리얼 DB 써야 된다고 주장한다.
  • 👍phauer.com/2017/dont-use-in-memory-databases-tests-h2/
    • 내가 했던 고민과 거의 비슷한 내용.
    • Solution: Throw H2 and Fongo away and use a dockerized version of your real database instead. Docker simplifies the management of database instances.
This post is licensed under CC BY 4.0 by the author.