(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 초기화 방식
- 각 TestMethod 마다 매번 새로운 testcontainer 사용하기
- 각 TestClass 마다 매번 새로운 testcontainer 사용하기
- testcontainer는 1개 띄우고 계속 유지하는데, 각 Test 마다 flyway로 DB를 특정 상태로 되돌리기
- 그냥 전체 테스트 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 등이 발생함.
- 기본적으로 공통 docker DB 사용하고, 지정한 테스트만 개별 docker DB를 사용하는 방법
- TestClass 마다 개별 docker DB 사용하는 방법
- 이전 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
2
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 등을 사용해야 한다.
트러블 슈팅
- 오라클 컨테이너(hub.docker.com/r/oracleinanutshell/oracle-xe-11g )에 flyway 적용 시 DB가 비어있지 않아 이슈가 있었음.
- 테스트 클래스를 일정 수 이상 묶어서 실행하면, 어느 정도 실행하다가 갑자기 JDCB Connection을 무한히 대기하는 현상
관련 링크
- test container는 각 테스트 클래스에서 생성하는게 좋을 수 있다.
- stackoverflow.com/questions/62425598/how-to-reuse-testcontainers-between-multiple-springboottests
- www.baeldung.com/spring-boot-testcontainers-integration-test
- 정리가 잘 되어 있으나, 중간에 ‘we can modify the JDBC URL and instruct the Testcontainers to create a database instance per test class. ‘ 라고 되어 있는데, 실제로는
spring.datasource.url=jdbc:tc:...
명시하게 되면 각 테스트 클래스 마다 새로운 DB 사용하는게 아니라, 전체 test suite에서 하나의 DB를 사용하게 된다. (Context가 달라지면 HikariPool은 새로 만드는게 맞지만, docker DB는 계속 사용한다)
- 정리가 잘 되어 있으나, 중간에 ‘we can modify the JDBC URL and instruct the Testcontainers to create a database instance per test class. ‘ 라고 되어 있는데, 실제로는
과연 In Memory DB가 TC에 더 효과적인가? 에 대해.
- github.com/dotnet/efcore/issues/18457
- efcore라는 DB mapper에서
InMemoryProvider
를 제거하느냐 마느냐에 대한 논쟁으로 이 것에 대해서는 의견이 분분하지만, 대략 필요한 부분을 종합해보면- 유닛 테스트에서는 InMemory DB가 충분히 유용하다.
- 통합 테스트에서는 당연히 리얼 DB 써야한다.
- efcore라는 DB mapper에서
- 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.