본문 바로가기

세미나

[사내세미나] Container를 활용해 멱등성있는 테스트 환경 구축하기

목차

  1. Container를 활용하기 전 테스트
  2. TestContainer를 통한 테스트
    • TestContainer란
    • TestContainer 기본 사용법
      • Postgresql Container
      • ElasticSearch Container
  3. 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 타입 클래스는 인덱스(시퀀스)를 정의하고 있는 클래스
예) BusinessMetricAnomalyIndex

createDefaultTestBulkData()

구현 클래스에서 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)

테스트 환경으로 실행시키겠다. 즉 테스트용 빈을 생성해서 사용하겠다는 표시


멱등성있는 테스트 환경