왜 Testcontainer를 사용하는가?
Spring DB 테스트 Rollback 원리
Spring에서는 테스트 코드 작성을 위해 @Test 어노테이션과 @Transactional 어노테이션을 함께 사용하면 기본적으로 롤백이 활성화됩니다. 이는 테스트가 실행된 후 변경된 데이터가 실제 데이터베이스에 영향을 미치지 않도록 보장하는 것입니다.
NoSql 테스트
NoSQL 데이터베이스를 사용할 때는 이러한 접근 방식이 다소 복잡해질 수 있습니다.
NoSQL 데이터베이스는 일반적으로 ACID 트랜잭션을 지원하지 않거나, 관계형 데이터베이스와는 다르게 구현됩니다. 따라서 Spring의 트랜잭션 처리 메커니즘을 NoSQL 데이터베이스에 적용하는 것은 번거롭습니다.
NoSQL 데이터베이스를 사용하는 경우에는 다음과 같은 테스트 전략을 고려할 수 있습니다:
- Testcontainer 사용: Testcontainer를 활용하여 테스트용 DB 컨테이너를 실행합니다. 기존 DB와 다른 컨테이너를 제공하기때문에 실제 데이터에 영향이 없습니다.
- 래플리카 또는 클러스터 구성: 복제본과 분산환경에서 테스트를 진행하기에 실제 데이터에 영향이 없습니다.
- 인메모리 데이터베이스 사용: 테스트 용도로 인메모리 데이터베이스를 사용하여 테스트를 진행할 수 있습니다. 이를 통해 실제 데이터베이스 서버 없이도 테스트를 수행할 수 있으며, 속도와 효율성을 높일 수 있습니다.
제가 진행할려는 방식은 1번 Testcontainer를 이용하여 Elasticsearch Test를 스프링 부트에서 진행할려고 합니다.
실습전 이론과 진행 방식 요약
이론
- DataElasticsearchTest 어노테이션 적용, 기존 @SpringBootTest+ElasticsearchTest 으로 이해하면됩니다
- ServiceConnection 어노테이션
- 스프링부트 3.1 버전부터는 @DynamicPropertySource 대신 @ServiceConnection 어노테이션이 도입되었습니다.
- @ServiceConnection은 테스트 컨테이너 인스턴스 필드에 적용되어 외부 서비스와의 연결 정보를 설정할 수 있습니다.
- 이를 통해 기존의 복잡한 설정 변경 없이도 외부 서비스와 연결할 수 있습니다.
- @DynamicPropertySource
- @DynamicPropertySource는 스프링 프레임워크 5.2.5 버전부터 도입된 메서드 레벨 어노테이션입니다.
- 이 어노테이션은 테스트 시 외부 컴포넌트(데이터베이스, 메시징 시스템 등)와 연결되는 애플리케이션을 테스트하는 데 사용됩니다.
- 기존에는 외부 컴포넌트와 연결하기 위해 복잡한 설정 변경이 필요했지만, @DynamicPropertySource를 사용하면 동적으로 속성을 추가할 수 있어 편리합니다.
진행과정
- DataElasticsearchTest 어노테이션 등록: Spring Test에서 Elasticsearch를 사용하는 테스트를 위해 @DataElasticsearchTest 어노테이션을 등록합니다. 이를 통해 Elasticsearch와 관련된 테스트가 가능하게 됩니다.
- ElasticsearchContainer를 통한 컨테이너 등록: @Container 어노테이션을 사용하여 Testcontainer에서 제공하는 ElasticsearchContainer를 등록합니다. 이를 통해 테스트를 실행할 때 특정 버전의 Elasticsearch를 컨테이너로 실행할 수 있습니다.
- ServiceConnection을 통한 컨테이너 실행 설정: @ServiceConnection 어노테이션을 사용하여 실행된 Elasticsearch과의 config작업을 진행합니다.
- 테스트 셋업 설정: @BeforeEach 어노테이션을 사용하여 테스트 메서드 실행 전에 설정을 초기화합니다. 여기서는 Testcontainer가 실행 중인지 확인하고, 실행되지 않았다면 기다린 후 실행되도록 설정합니다. 이를 통해 Docker가 해당 컨테이너 이미지를 실행하고 데이터를 테스트할 수 있도록 준비합니다.
실습
당연한 이야기지만 Docker가 실행중이어야 Testcontainer가 작동합니다.
예제 코드
@DataElasticsearchTest
@Testcontainers
@Slf4j
public class ElasticsearchIntegrationTests {
@Container
@ServiceConnection
public static ElasticsearchContainer ES_CONTAINER = new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:7.17.5");
@Autowired
private ProductRepository productRepository;
@Test
void testDatabaseIsRunning() {
assertThat(ES_CONTAINER.isRunning()).isTrue();
}
@Test
public void testProductRepository() {
var product = productRepository.save(new Product(null, "test", BigDecimal.ONE));
assertThat(product).isNotNull();
assertThat(product.id()).isNotNull();
productRepository.findById(product.id()).ifPresent(
p -> {
log.debug("found product by id: {}", p);
assertThat(p.name()).isEqualTo("test");
}
);
}
}
예제 코드가 잘 작동했다면 이제 실제 코드로 가봅니다.
실제 사용 버전은 Springboot 3.2, elasticsearch 8.*이지만 테스트 코드는 7.*버전에서 진행했습니다. 이유는 차차 설명하겠습니다.
실제 코드
- ElasticsearchRepository를 구현한 실제 Repository를 Autowired해옵니다.
public interface QuestionsRepository extends ElasticsearchRepository<QuestionEntity, String> - 저의 경우 복잡한 쿼리 사용을 위해 BoolQueryBuilder 빈 등록과 데이터 조회 등록을 위해 ElasticsearchTemplate를 사용했으므로 각각 MockBean, Autowired시켜줍니다.
- Testcontainer 이미지 작동 후에 테스트 진행하도록 BeforeEach()에 await메서드를 통해 대기하도록 합니다. 추가로 테스트에 사용할 더미데이터를 하나 삽입해줍니다.
@DataElasticsearchTest
@Tag("db_test")
@Testcontainers
public class QuestionsRepositoryTest {
// 8.x SSL HTTPS ERROR -> 7.x HTTP
private final static String IMAGE_NAME = "docker.elastic.co/elasticsearch/elasticsearch:7.17.5";
//private final static String IMAGE_NAME = "docker.elastic.co/elasticsearch/elasticsearch:8.11.3";
@Container
@ServiceConnection
public static ElasticsearchContainer ES_CONTAINER = new ElasticsearchContainer(IMAGE_NAME);
@Autowired
private QuestionsRepository questionsRepository;
@MockBean
private BoolQueryBuilder boolQueryBuilder;
@Autowired
private ElasticsearchTemplate elasticsearchTemplate;
private final Long existExamId = 0L;
private final String questionUuid = "questionsuuid";
@BeforeEach
public void setup() {
await()
.untilAsserted(() -> {
assertThat(ES_CONTAINER.isRunning()).isTrue();
});
QuestionEntity question = QuestionEntity.builder()
.id(questionUuid)
.examId(existExamId)
.type("객관식")
.question("다음 중 총중량 1.5톤 피견인 승용자동차를 4.5톤 화물자동차로 견인하는 경우 필요한 운전면허에 해당하지 않은 것은?")
.options(List.of(
"① 제1종 대형면허 및 소형견인차면허",
"② 제1종 보통면허 및 대형견인차면허",
"③ 제1종 보통면허 및 소형견인차면허",
"④ 제2종 보통면허 및 대형견인차면허"))
.questionImagesIn(List.of(
ImageDto.builder().url("url1").description("설명1").attribute("속성1").build()))
.questionImagesOut(List.of(
ImageDto.builder().url("url2").description("설명2").attribute("속성2").build()))
.answers(List.of(4))
.commentary("도로교통법 시행규칙 별표18 총중량 750킬로그램을 초과하는 3톤 이하의 피견인 자동차를 견인하기 위해서는 견인 하는 자동차를 운전할 수 있는 면허와 소형견인차면허 또는 대형견인차면허를 가지고 있어야 한다.")
.commentaryImagesIn(List.of(
ImageDto.builder().url("url3").description("설명3").attribute("속성3").build()))
.commentaryImagesOut(List.of(
ImageDto.builder().url("url4").description("설명4").attribute("속성4").build()))
.tagsMap(Map.of("category", List.of("화물")))
.build();
questionsRepository.save(question);
}
}
테스트 결과
해결 못한 부분
성공적인 Elasticsearch 7.x 테스트, 실패한 8.x 테스트
https://github.com/spring-projects/spring-boot/issues/35926
- 대략적인 내용: Elasticsearch 8.x버전은 기본적으로 SSL이 활성화 되어있어 HTTPS로 테스트 되어야한다. 스프링은 기본적으로 SSL비활성을 전제로 HTTP로 테스트한다.
- 결론: 별도로 증명을 할 수 있는 env파일 또는 이외의 방법을 생각해야하는데 이는 테스트의 범위를 넘어간다고 생각한다 → 따라서 7.x버전으로 테스트 진행
비교적 최신 내용이라 잘못된 내용이 많을 수 있습니다. 참고 바랍니다
참고
SpringBoot 3.1이상에서 Testcontainer 사용법
https://www.baeldung.com/spring-boot-built-in-testcontainers
- @ServiceConnetion을 사용하여 SpringBoot의 자동 구성이 필요한 모든 속성을 동적으로 등록가능, 무대 뒤에서 컨테이너 클래스 또는 도커 이미지 이름을 기반으로 필요한 속성을 결정함
- 3.1이전 버전: @DynamicPropertySource 주석을 이용해서 속성을 추가했어야함
- 각 컨테이너 연결 방식
https://docs.spring.io/spring-boot/docs/current/reference/html/data.html#data.nosql.elasticsearch
- ElasticSearch+Spring
'스프링 > 테스트' 카테고리의 다른 글
Postman을 활용한 API 테스트에서의 로그인 세션 유지 및 타 API 테스트 (0) | 2024.04.19 |
---|---|
[스프링] JUnit @Tag를 이용한 테스트 코드 분리 (0) | 2024.03.15 |