기존의 레거시 서버들의 테스트 환경에서는
1. 특정 테스트용 스키마를 이용하여 테스트
2. In-Memory DB (H2) 를 이용하여 테스트
2가지 방법으로 동작되고 있었습니다.
하지만 이로 인해 많은 파생되는 문제들을 겪었는데요.
1. 특정 테스트용 스키마를 이용하여 테스트 의 경우
멱등성있는 환경을 유지하기가 어려웠고.
2. In-Memory DB (H2) 를 이용하여 테스트는 운영에서 사용하는 RDBMS 와 다른 환경으로 인한 차이로 인한 오류를 잡지 못하고 운영에 배포되거나, 반대로 리얼에서는 돌아가나 테스트를 실패하는 경우가 존재할 수 있다는 문제점이 있었습니다.
또한, 다양한 3rd Party 서비스를 사용하고 있었지만 대부분 Mocking을 통한 테스트 혹은 제대로 테스트가 되지 않은 케이스가 많아 항상 운영상에 곤혹을 겪는 상황을 마주하게 되었습니다.
그래서 우선 떠올린 방법은
Docker
초기 아이디어에서는 테스트 환경을 위한 docker-compose.yml 을 셋업해두고, 로컬피시의 경우 로컬피시에서 실행시켜 수행하고
배포 전 테스트의 경우, CI/CD 과정에서 컨테이너를 먼저 띄우고 테스트를 수행시키는 파이프라인을 추가하려고 마음먹었습니다.
하지만
각 컨테이너 마다의 포트 관리 그리고 테스트마다 컨테이너를 내렸다 올렸다 하기 귀찮다.. 는 원초적인 귀찮음이 들어
찾아보던 중 TestContainer 를 발견하고 도입하게되었습니다.
TestContainer
https://www.testcontainers.org/
Testcontainers
Testcontainers About Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container. Testcontainers make the following k
www.testcontainers.org
Test Container 는 도커 컨테이너기반 테스트를 쉽게 도와주는 Java 라이브러리입니다.
공식 홈페이지에서는 다음과 같은 사용처를 소개합니다.
Data access layer integration tests : 각 RDBMS 컨테이너를 활용하여 복잡한 머신 세팅 없이 알려져있는 DB 상태대로 호환성 있게 테스트 할 수 있습니다.
Application integration tests: 데이터베이스, 메시징 큐, 웹 서버 등 다양한 의존성들을 테스트에 걸맞는 짧은 생명주기로 실행시킬수 있습니다.
UI/Acceptance tests: Container 화 된 웹 브라우저를 이용하여, UI 인수테스트를 진행할 수 있습니다. 브라우저 업그레이드에 따른 플러그인이나 브라우저 상태를 염려하지 않아도 됩니다.
++ 모든 컨테이너화 가능한 것들은 테스트로에 이용할 수 있습니다.
그렇다고 합니다.
그래서 우선 MySQL 그 다음에는 LocalStack 을 활용한 AWS의 의존성들, 나아가 테스트 환경을 제공하지 않는 다른 회사와의 연계 프로젝트 테스트를 위한 MockTestServer 등도 모두 컨테이너화 해서 테스트하면 좋을것 같습니다.
MySQL 만 먼저 예제로 구현해보겠습니다.
dependency
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'
code
test container 는 기본적으로 per test 의 라이프 사이클을 지닙니다.
testcontainer per test
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = Replace.NONE)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class SomeTest {
@Container
private MySQLContainer<?> MY_SQL_CONTAINER = new MySQLContainer<>(DockerImageName.parse("mysql:버전"))
.withDatabaseName("test")
.withUsername("root")
.withPassword("test")
.withExposedPorts(3306) // docker 의 -p 옵션 입니다. 설정하지 않을 경우 랜덤한 포트로 실행됩니다.
.withCommand("--character-set-server=utf8mb4") // db 옵션 커맨드
.withInitScript("sql/schema.sql") // 초기 세팅 sql 스크립트
.withStartupTimeoutSeconds(60); // container 실행 타임아웃
@BeforeEach
void initDatabaseProperties() {
System.setProperty("spring.datasource.url", MY_SQL_CONTAINER::getJdbcUrl);
System.setProperty("spring.datasource.username", MY_SQL_CONTAINER::getUsername);
System.setProperty("spring.datasource.password", MY_SQL_CONTAINER::getPassword);
}
}
위와 같은 방법으로 테스트 컨테이너를 사용할 경우, 컨테이너는 각 test 마다 생명주기를 달리 합니다.
이로인한 장점은 매번 새 컨테이너를 띄우기 때문에 멱등성을 위한 테스트 후처리가 필요없다는 점입니다.
예를들어, JPA를 사용하는 프로젝트에서 테스트의 @Transactional 을 이용하여 테스트를 하면 데이터의 롤백을 고려하지 않아도 되어 간편하나, 실제 소스에는 @Transactional 이 없는 경우 LazyInitializationException 이 발생함에도 테스트가 통과되는 경우가 생겨 수기로 데이터 롤백 혹은 테스트 환경 셋팅을 해주는것을 권장합니다.
위 방법은 매번 새 컨테이너를 실행시키기 때문에 해당 부분을 고려하지 않아도 되게 되는것입니다.
하지만 단점도 명확합니다. TestContainer를 이용하여 컨테이너를 실행시키는데 보통 3~5초 가량의 시간이 소요되는데
매 테스트마다 재 시작을 할 경우, 테스트의 속도가 현저하게 느려질 것입니다.
또, 위에서 말한대로 테스트별로 많은 수의 컨테이너를 띄우는 경우도 있을텐데 그렇다면 더 눈에띄는 성능저하가 보일 것입니다.
이럴 경우, 정적 컨텍스트를 활용한 싱글톤 컨테이너를 생성하여 라이프사이클을 길게 가져갈 수 있습니다.
singleton
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = Replace.NONE)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class IntegrationTest {
private static final MySQLContainer<?> MY_SQL_CONTAINER;
static {
MY_SQL_CONTAINER = new MySQLContainer<>(DockerImageName.parse("mysql:버전"))
.withDatabaseName("test")
.withUsername("root")
.withPassword("test")
.withExposedPorts(3306)
.withCommand("--character-set-server=utf8mb4")
.withInitScript("sql/schema.sql")
.withStartupTimeoutSeconds(60);
MY_SQL_CONTAINER.start();
}
@DynamicPropertySource
private static void initDynamicDatabaseProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MY_SQL_CONTAINER::getJdbcUrl);
registry.add("spring.datasource.username", MY_SQL_CONTAINER::getUsername);
registry.add("spring.datasource.password", MY_SQL_CONTAINER::getPassword);
}
}
정적 컨텍스트를 사용했기 때문에 @DynamicPropertySource 도 사용할 수 있습니다.
첫번째 방법에 비해 훨씬 빠르지만, 멱등성을 고려한 후 처리를 해주어야합니다.
저같은 경우에는 JPA 환경에서 멱등성 처리를 다음과 같이합니다.
@Component
@RequiredArgsConstructor
public class DataBaseCleaner implements InitializingBean {
@Autowired
private EntityManager entityManager;
private List<String> tableNames;
@Override
public void afterPropertiesSet() { // 모든 엔티티 클래스 이름을 받아와 빈 생성 후 세팅합니다.
tableNames = entityManager.getMetamodel().getEntities().stream()
.filter(entityType -> entityType.getJavaType().getAnnotation(Entity.class) != null)
.map(entityType -> {
Table table = entityType.getJavaType().getAnnotation(Table.class);
if (table != null) {
return table.name();
} else {
return CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entityType.getName());
}
}).collect(Collectors.toList());;
}
@Transactional
public void truncateAll() {
entityManager.flush();
entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate();
truncateEntitiy();
truncateNonEntityTable();
entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate();
}
private void truncateEntitiy() {
for (String tableName : tableNames) { // 받아온 엔티티 테이블 명 들을 모두 Truncate 시킵니다
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
}
}
private void truncateNonEntityTable() { // JPA 엔티티로 등록되지 않은 테이블을 수기로 삭제하는 코드를 추가합니다
// data.sql 재활용 을 위함 해당 부분을 관리하기 싫다면 data.sql 에 해당 데이터들은 WHERE NOT EXISTS 를 이용하여 넣자
entityManager.createNativeQuery("TRUNCATE TABLE random_byte").executeUpdate();
}
}
최종 사용하고 있는 통합테스트 베이스 테스트
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = Replace.NONE)
@AutoConfigureEncodedMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class IntegrationTests extends ApiDocumentGenerator {
private static final MySQLContainer<?> MY_SQL_CONTAINER;
static {
MY_SQL_CONTAINER = new MySQLContainer<>(DockerImageName.parse("mysql:8.0.25"))
.withDatabaseName("test")
.withUsername("root")
.withPassword("test")
.withExposedPorts(3306)
.withCommand("--character-set-server=utf8mb4")
.withInitScript("sql/schema.sql")
.withStartupTimeoutSeconds(60);
MY_SQL_CONTAINER.start();
}
@DynamicPropertySource
private static void initDynamicDatabaseProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MY_SQL_CONTAINER::getJdbcUrl);
registry.add("spring.datasource.username", MY_SQL_CONTAINER::getUsername);
registry.add("spring.datasource.password", MY_SQL_CONTAINER::getPassword);
}
@Autowired
DataBaseCleaner dataBaseCleaner;
@AfterEach
void clear() {
System.out.println("AFTER EACH");
dataBaseCleaner.truncateAll();
}
}
'Spring' 카테고리의 다른 글
도메인 객체에서 Bean 객체의 기능을 필요로 해 수행한 코드 개선기 (0) | 2022.05.25 |
---|---|
SpringBoot + Redis 를 이용한 글로벌 캐시 (0) | 2022.03.14 |
Enum 을 BeanProvider로 사용해보기 (0) | 2022.03.14 |
Spring Bean LifeCycle 스프링 빈 생명주기 (0) | 2022.01.24 |