본문 바로가기
개발

TestContainers를 Spring Bean으로 등록해보자

by 상5c 2022. 9. 18.

TestContainers 소개와 이어지는 글입니다.


기본 사용법과 단점

인터넷에 올라온 예제에서는 대부분 상속 형태로 TestContainers를 사용한다.

@Testcontainers
abstract class SampleContainersTest {

    @Container
    static GenericContainer<?> postgresSQLContainer = new GenericContainer<>("postgres");
}

public class Sample extends SampleContainersTest {

    @Test
    void test() {
        System.out.println("hello world!");
    }
}

이 방식의 단점은 여러 클래스에서 상속받은 경우, 각각의 클래스마다 docker run이 실행된다. 만약 테스트 클래스가 10개로 분리되어있으면 docker run이 10번 실행되는 구조이다.

안그래도 느린 테스트로 스트레스를 받고 있었고, Container 생성을 최소화 하여 테스트의 속도를 빠르게 하고 싶었다. 코드 구조도 매 테스트마다 데이터베이스 cleanup을 진행하는 형태여서 Database를 최초 한번 실행하고 재활용하고싶었다.

@TestContainers와 @Container

TestContainers 사용시에는 @TestContainers와 @Container 두 가지 어노테이션이 필요하다.

@Container

타겟 필드를 표시하는 용도로 사용된다.

@TestContainers

@Container 어노테이션을 찾아서 수명 주기 메소드를 실행시킨다.

@TestContainers 어노테이션의 주석

 

TestContainers 어노테이션의 동작은 org.testcontainers.junit.jupiter.TestContainersExtension 클래스를 찾아가보면 알 수 있다.

// TestContainersExtension.java

@Override
public void beforeAll(ExtensionContext context) {
    Class<?> testClass = context
        .getTestClass()
        .orElseThrow(() -> {
            return new ExtensionConfigurationException("TestcontainersExtension is only supported for classes.");
        });

    Store store = context.getStore(NAMESPACE);
    List<StoreAdapter> sharedContainersStoreAdapters = findSharedContainers(testClass);

    sharedContainersStoreAdapters.forEach(adapter -> {
        store.getOrComputeIfAbsent(adapter.getKey(), k -> adapter.start());
    });

    List<TestLifecycleAware> lifecycleAwareContainers = sharedContainersStoreAdapters
        .stream()
        .filter(this::isTestLifecycleAware)
        .map(lifecycleAwareAdapter -> (TestLifecycleAware) lifecycleAwareAdapter.container)
        .collect(Collectors.toList());

    store.put(SHARED_LIFECYCLE_AWARE_CONTAINERS, lifecycleAwareContainers);
    signalBeforeTestToContainers(lifecycleAwareContainers, testDescriptionFrom(context));
  }

beforeAll method만 가져왔다.
위에서 언급한 @TestContainers 주석의 설명처럼, 타겟 필드를 찾고 라이프사이클 메소드를 실행한다.

여기서 주목할 점은 adapter.start() 이다. 테스트 시작시 start() 메소드를 호출하고, afterAll에서는 stop() 메소드를 호출한다.

// GenericContainer.java
/**
 * Starts the container using docker, pulling an image if necessary.
 */
@Override
@SneakyThrows({ InterruptedException.class, ExecutionException.class })
public void start() {
    if (containerId != null) {
        return;
    }
    Startables.deepStart(dependencies).get();
    // trigger LazyDockerClient's resolve so that we fail fast here and not in getDockerImageName()
    dockerClient.authConfig();
    doStart();
}

실제로 실행되는 GenericContainer 클래스의 start 메소드이다. docker run이 실행됨을 알 수 있다.

정리하자면 testcontainers 어노테이션은 container 어노테이션이 달린 필드를 찾아 start, stop을 실행해주는 역할이다. 스프링 빈으로 등록하려면 두 메소드를 적절한 시기에 호출해주어야 한다.

Spring Bean으로 등록하여 사용하기

여러가지 방법을 고민했으나, 우아한 형제들 기술블로그를 참고하여 bean 생성/소멸시점에 호출해주도록 설정해주었다.

@Bean(initMethod = "start", destroyMethod = "stop")
public GenericContainer<?> dockerComposeContainer() {
  return new GenericContainer<>("postgres");
}

DockerComposeContainer를 빈으로 등록하는 경우에도 동일하게 사용하면 된다.

@Bean(initMethod = "start", destroyMethod = "stop")
public DockerComposeContainer<?> dockerComposeContainer() {
  return new DockerComposeContainer<>(
      new File("src/test/resources/testcontainers/docker-compose.yml"))
      .withExposedService(MSSQL,MSSQL_PORT)
      .withLogConsumer(MSSQL, new Slf4jLogConsumer(log))
      .withExposedService(MONGO,MONGO_PORT)
      .withLogConsumer(MONGO, new Slf4jLogConsumer(log))
}

테스트컨테이너의 경우 동적으로 포트가 바인딩 되기 때문에 @DynamicPropertySource 메소드를 통해 DataSource 관련 설정 값을 덮어쓰기 해줬었다.

빈으로 등록하는 방식의 경우 아래와 같이 해결할 수 있다.

@Bean
@DependsOn("dockerComposeContainer")
public DataSource dataSource(final DockerComposeContainer<?> dockerComposeContainer) {
  return DataSourceBuilder.create()
      .url(String.format("jdbc:sqlserver://%s:%d;databaseName=%s",
          dockerComposeContainer.getServiceHost(MSSQL,MSSQL_PORT),
          dockerComposeContainer.getServicePort(MSSQL,MSSQL_PORT),
          "GANGTOON")
      )
      .driverClassName("com.microsoft.sqlserver.jdbc.SQLServerDriver")
      .username("sa")
      .password("Pass@word")
      .build();
}

 

사용은 다른 스프링 빈을 주입받는 것처럼, 필요한 곳에서 @Autowired를 통해 사용하면 된다.
맨 위에서 설명한 방식처럼 추상 클래스에 사용 후 상속받아도 스프링에서 라이프사이클을 관리하기 때문에 docker run이 한 번만 실행된다.

abstract class SampleContainersTest {

    @Autowired
    GenericContainer<?> postgresSQLContainer;
}

라이프사이클 관리를 스프링에 위임했기에 @TestContainers와 @Container 어노테이션을 제거했다.

 

 

LocalStack을 활용한 Integration Test 환경 만들기 | 우아한형제들 기술블로그

{{item.name}} 안녕하세요. 주문마케팅서비스팀 송정훈입니다. 이 글에서는 AWS 서비스를 활용하는 웹 어플리케이션이 클라우드 환경이 아닌, 로컬개발환경에서 쉽게 실행하고 테스트할 수 있는 방

techblog.woowahan.com

 

TestContainer 로 멱등성있는 integration test 환경 구축하기

By 박성은

medium.com