목차
- Container를 활용하기 전 테스트
- TestContainer를 통한 테스트
- TestContainer란
- TestContainer 기본 사용법
- Postgresql Container
- ElasticSearch Container
- TestContainer를 활용하여 실제 개발환경에 적용하기
- 기능 추상화
- 코드로 녹이기
- 멱등성있는 테스트 개발환경
Container를 활용하기 전 테스트
- 직접 DEV 환경의 서버를 호출
- 멱등성 을 지키지 못한 잘못된 방식의 테스트
멱등선이란?
연산을 여러번 적용하더라도 결과가 바뀌지 않는 성질을 뜻한다.
즉, 여러번 함수를 실행하더라도 늘 같은 결과가 나와야 한다는 의미입니다.
TestContainer를 통한 테스트
TestContainer란?
- Junit 테스트를 지원하는 java library로, Dokcer container를 활용하여 경량화된 테스트 환경을 제공
- Data access layer integration test
- Application integration test
- UI/Acceptance tests
TestContainer 기본 사용법
Postgresql Container
1.test-scoped dependency 추가
- build.gradle
implementation 'org.postgresql:postgresql'
testImplementation "org.testcontainers:postgresql:1.16.3"
2.PostgreSQLContainer 생성
- seminar-testcontainer/src/test/java/com/gongdel/seminar/testcontainer/postgres/UserRepositoryTest.java
@ClassRule
public static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer("postgres:11.1")
.withDatabaseName("gongdel-tests-db")
.withUsername("gongdel")
.withPassword("gongdel");
@ClassRule : 테스트 클래스 슈트 전체에 적용할 수 있는 Rule이다
Rule
테스트 케이스를 실행하기 전후에 추가 코드를 실행할 수 있도록 도와준다.
@Before와 @After로 선언된 메서드에서도 실행 전후 처리로 코드를 넣을 수 있지만,
JUnitRules로 작성하면 재사용하거나 더 확장 가능한 기능으로 개발할 수 있는 장점이 있다.
전체소스
package com.gongdel.seminar.testcontainer.postgres;
import org.assertj.core.api.Assertions;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;
import org.testcontainers.containers.PostgreSQLContainer;
import java.util.NoSuchElementException;
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(initializers = {UserRepositoryTest.Initializer.class})
@Transactional
public class UserRepositoryTest {
@Autowired private UserRepository userRepository;
@ClassRule
public static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer("postgres:11.1")
.withDatabaseName("gongdel-tests-db")
.withUsername("gongdel")
.withPassword("gongdel");
static class Initializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
TestPropertyValues.of(
"spring.datasource.url=" + postgreSQLContainer.getJdbcUrl(),
"spring.datasource.username=" + postgreSQLContainer.getUsername(),
"spring.datasource.password=" + postgreSQLContainer.getPassword()
).applyTo(configurableApplicationContext.getEnvironment());
}
}
@Test
public void save() {
// Given
User user = User.createUser("gongdel");
// When
User savedUser = userRepository.save(user);
// Then
Assertions.assertThat(savedUser.getName()).isEqualTo(user.getName());
}
@Test
public void findOne() {
// Given
User user = User.createUser("gongdel");
userRepository.save(user);
// When
User getUser = userRepository.findById(user.getId()).orElseThrow(NoSuchElementException::new);
// Then
Assertions.assertThat(getUser.getName()).isEqualTo(user.getName());
}
}
ElasticSearch Container
1.test-scoped dependency 추가
- build.gradle
implementation 'org.springframework.data:spring-data-elasticsearch'
implementation 'org.elasticsearch:elasticsearch'
testImplementation 'org.testcontainers:elasticsearch'
2.ElasticsearchContainer 생성
- seminar-testcontainer/src/test/java/com/gongdel/seminar/testcontainer/elastic/ElasticSearchContainerTest.java
@ClassRule
public static ElasticsearchContainer container
= new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:7.12.0");
전체소스
package com.gongdel.seminar.testcontainer.elastic;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestClientBuilder;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.client.indices.CreateIndexRequest;
import org.elasticsearch.client.indices.GetIndexRequest;
import org.jetbrains.annotations.NotNull;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.testcontainers.elasticsearch.ElasticsearchContainer;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringRunner.class)
@SpringBootTest
public class ElasticSearchContainerTest {
@ClassRule
public static ElasticsearchContainer container
= new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:7.12.0");
@Test
public void save_index() throws IOException {
// Given
RestHighLevelClient client = getRestHighLevelClient();
// When
boolean isExisingIndex = client.indices()
.exists(new GetIndexRequest("gongdel_index"), RequestOptions.DEFAULT);
// Then
assertThat(isExisingIndex).isEqualTo(false);
// Given
client.indices()
.create(new CreateIndexRequest("gongdel_index"), RequestOptions.DEFAULT);
// When
isExisingIndex = client.indices()
.exists(new GetIndexRequest("gongdel_index"), RequestOptions.DEFAULT);
// Then
assertThat(isExisingIndex).isEqualTo(true);
}
@NotNull
private RestHighLevelClient getRestHighLevelClient() {
BasicCredentialsProvider credentialProvider = new BasicCredentialsProvider();
credentialProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("elasticsearch",
"elasticsearch"));
RestClientBuilder builder = RestClient
.builder(HttpHost.create(container.getHttpHostAddress()))
.setHttpClientConfigCallback(
httpClientBuilder
-> httpClientBuilder.setDefaultCredentialsProvider(credentialProvider)
);
return new RestHighLevelClient(builder);
}
}
TestContainer를 활용하여 실제 개발환경에 적용하기
기능 추상화
- TestContainer 사용 인터페이스 추상화
- 실무에서 사용하고 있는 Elaticsearch container로 추상화
ElasticsearchTestContainer
Elasticearch Container는
단 한번
만 생성되고 실행되어야 한다.AbstractElasticsearchTestData
도메인별(이상감지, 로그, 서비스맵이력)로 확장할 수 있는 구조야 한다.
멱등성
을 지키기 위해, 도메인별로 동일한 데이터를 조회할 수 있어야 한다.구현체
도메인의 스키마와 테스트 데이터 정의
코드로 녹이기
- ElasticsearchTestContainer (컨테이너 라이프 사이클 관리, DB 클라이언트 생성)
@Configuration("elasticsearchTestContainer")
@Profile(CygnusCoreConfig.PROFILE_TEST)
public class ElasticsearchTestContainer {
private final ElasticsearchContainer container;
private final RestHighLevelClient client;
private static final String ELASTICSEARCH = "elasticsearch";
private static final String DOCKER_IMAGE_NAME = "docker.elastic.co/elasticsearch/elasticsearch:7.9.3";
public ElasticsearchTestContainer() {
container = new ElasticsearchContainer(DOCKER_IMAGE_NAME);
container.start();
this.client = createRestHighLevelClient();
}
public RestHighLevelClient getClient() {
return client;
}
@PreDestroy
public void destroy() {
container.stop();
}
private RestHighLevelClient createRestHighLevelClient() { ...}
private BasicCredentialsProvider getBasicCredentialsProvider() {...}
}
@Profile(CygnusCoreConfig.PROFILE_TEST)
테스트 시에만 SpringBean Context에 생성
- AbstractElasticsearchTestData (인덱스(스키마) 매핑, 테스트용 데이터 삽입)
@Slf4j
public abstract class AbstractElasticsearchTestData<T extends ElasticBulkData> {
private final ElasticsearchTestContainer container;
protected AbstractElasticsearchTestData(ElasticsearchTestContainer container) {
this.container = container;
mappingIndex();
insertBulk();
}
/**
* @return index에 저장할 테스트 데이터 object
*/
abstract public List<T> createDefaultTestBulkData();
/**
* @return 저장할 index name
*/
abstract public String getTargetIndex();
private void mappingIndex() {
try {
container.getClient()
.indices()
.putTemplate(
ElasticsearchUtils.makeSystemTemplate(getGenericClass()),
RequestOptions.DEFAULT
);
} catch (IOException e) {
log.error(e.getMessage());
} catch (ClassNotFoundException e) {
log.error(e.getMessage());
}
}
private Class<T> getGenericClass() throws ClassNotFoundException {
Type mySuperclass = getClass().getGenericSuperclass();
Type type = ((ParameterizedType) mySuperclass).getActualTypeArguments()[0];
String className = type.toString().split(" ")[1];
return (Class<T>) Class.forName(className);
}
private void insertBulk() {
List<T> list = createDefaultTestBulkData();
BulkRequest bulkRequest = makeBulkRequest(list);
try {
container.getClient().bulk(bulkRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
log.error(e.getMessage());
}
}
private <T extends ElasticBulkData> BulkRequest makeBulkRequest(List<T> list) {
BulkRequest bulkRequest = new BulkRequest();
bulkRequest.setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE);
for (ElasticBulkData doc : list) {
IndexRequest indexRequest = bulkIndex(doc);
bulkRequest.add(indexRequest);
}
return bulkRequest;
}
// TODO: 많은 중복 코드 생상되는 interface, 팀 정의 후 util화 하기
private IndexRequest bulkIndex(ElasticBulkData elasticBulkData) {
String time = convertCollectTimeToFormatted(elasticBulkData, elasticBulkData.getIndexFormat());
//Entity 이름과 시간으로 IndexName을 만든다.
String indexName = getBaseIndexName(elasticBulkData.getClass()) + time;
//Entity로 들어오는 값들을 Map 형태로 index source에 넘겨주어야 한다.
Map<String, Object> entityToMap = JsonUtil.OBJECT_MAPPER.convertValue(elasticBulkData,
new TypeReference<Map<String, Object>>() {
});
return new IndexRequest(indexName).source(entityToMap);
}
}
getGenericClass()
제네릭의 T 타입 클래스를 가져옴
T 타입 클래스는 인덱스(시퀀스)를 정의하고 있는 클래스
예) BusinessMetricAnomalyIndexcreateDefaultTestBulkData()
구현 클래스에서 insert할 테스트 데이터를 정의
- AbstractElasticsearchTestData 구현체(이상감지, 로그, 서비스맵이력 등 도메인이 추가되면 상속해서 사용)
@Component
@Profile(CygnusCoreConfig.PROFILE_TEST)
@DependsOn({"elasticsearchTestContainer"})
public class ContainerAnomalyTestData extends AbstractElasticsearchTestData<BusinessMetricAnomalyIndex> {
public static final String INDEX = "biz_anomaly_metric-20220209";
public ContainerAnomalyTestData(ElasticsearchTestContainer container) {
super(container);
Objects.requireNonNull(container);
}
@Override
public List<BusinessMetricAnomalyIndex> createDefaultTestBulkData() {
String data = metadata();
return JsonUtil.fromString(data, new TypeReference<List<BusinessMetricAnomalyIndex>>() {
});
}
@Override
public String getTargetIndex() {
return INDEX;
}
/**
* @return 추출 데이터 인덱스 : biz_anomaly_metric-20220209, 테스트 데이터 개수 100개
*/
private String metadata() {...}
}
@DependsOn({"elasticsearchTestContainer"})
elasticsearchTestContainer bean이 생성된 후에 bean을 등록하겠다는 표현
- 테스트
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("testDao-applicationContext.xml")
@ActiveProfiles(CygnusCoreConfig.PROFILE_TEST)
@Transactional
public class ContainerAnomalyTestDataTest {
@Autowired
private ContainerAnomalyTestData containerAnomalyTestData;
@Autowired
private ElasticsearchTestContainer container;
@Test
public void check_targetIndex() throws IOException {
// Given
String expected_targetIndex = containerAnomalyTestData.getTargetIndex();
// When
boolean exists = container.getClient().indices().exists(new GetIndexRequest(expected_targetIndex), RequestOptions.DEFAULT);
// Then
Assert.assertTrue(exists);
}
}
@ActiveProfiles(CygnusCoreConfig.PROFILE_TEST)
테스트 환경으로 실행시키겠다. 즉 테스트용 빈을 생성해서 사용하겠다는 표시
멱등성있는 테스트 환경
'세미나' 카테고리의 다른 글
[세미나] SI / 대기업에서 스타트업으로 이직하는 방법 -참석후기 (0) | 2018.06.01 |
---|---|
[세미나] Code States J2S 컨퍼런스 - 펌(내용정리) (0) | 2018.05.05 |
[세미나] 자바지기님의 자바개발자 학습 로드맵-펌(내용정리) (0) | 2018.05.04 |
개발자를 위한 Google 검색 노하우- 펌(내용정리) (0) | 2018.05.01 |
제로 스펙에 가까웠던 듣보잡 개발자의 유명 IT 기업 도전기 - 참석 후기 (0) | 2018.04.08 |