Java

TestContainer 재사용하기(feat. 테스트 실행시간 최적화)

jwKim96 2022. 8. 6. 19:52

0. 계기

Sikdorak 프로젝트를 진행하며 팀원들과 테스트 DB에 대한 고민을 했었는데요.(ku-kim,Seokho-Ham)
토론한 결과 실행환경과 비슷한 조건에서 테스트하기위해 TestContaienr 를 도입하기로 결정했습니다.
하지만, 테스트 수행 시 컨테이너를 실행하는 과정이 여러번 반복되어 시간이 많이 걸리는 것이 아쉬웠는데요.
그래서 이를 해결해보기 위해, Testcontainer 에 대해 알아보며 개선하게 되었습니다.
(관련 PR : https://github.com/jjik-muk/sikdorak/pull/55)

테스트 실행 환경

1. 문제상황

@Testcontainers
public abstract class MysqlTestContainer {

    private static final String MYSQL_VERSION = "mysql:8";
    private static final String DATABASE_NAME = "testDB";

    @Container
    static final MySQLContainer MYSQL_CONTAINER = new MySQLContainer(MYSQL_VERSION)
        .withDatabaseName(DATABASE_NAME);
}

위와 같은 컨테이너 설정을 테스트 클래스이 상속하는 방식으로 적용하였는데요.
다음와 같은 문제가 있었습니다.

  • Container 생성횟수 : 7
  • Test 총 소요시간 : 1m 8sec

실행시마다 조금 달라지지만, 제 개발환경에서는 보통 1m ~ 1m 20sec 사이의 시간이 걸렸는데요.
테스트 로그를 보다보니 7회나 컨테이너가 재실행되는것을 발견하여, 이게 병목이지 않을까 싶어 그 이유를 찾아보았습니다.

1.1 Datasource 설정

spring:
  datasource:
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
    url: jdbc:tc:mysql:8://testDB

...

testcontainer 의 DriverClass 를 사용하면 URL 에 명시한 DB 종류에 따라 알아서 설정하고 동작하게 합니다.
url 에서 protocol 부분을 보면, jdbc:tc~ 라고 시작합니다.

공식문서에서는 이렇게 프로토콜을 입력하면, hostname, port, database name 이 무시된다고 합니다.
알아서 설정해서 datasource 를 만들어 준다는 내용이라고 예상됩니다.

여기서 Datasource 생성 후 검증을 위해 DB 와 연결을 시도하는데, 이때 1회 mysql 컨테이너가 실행됩니다.

1.2 테스트 소스코드 구조

테스트 소스코드의 구조를 간략하게 나타내면 아래와 같습니다.

src/test
├── java/com/jjikmuk/sikdorak
│   ├── acceptance
│   │   ├── InitAcceptanceTest.java
│   │   ├── StoreAccepanceTest.java
│   │   ├── ReviewAcceptanceTest.java
│   │   ├── UserAcceptanceTest.java
│   ├── integration
│   │   ├── InitIntegrationTest.java
│   │   ├── StoreIntegrationTest.java
│   │   ├── ReviewIntegrationTest.java
│   │   ├── UserIntegrationTest.java
│   ├── common
│   │   ├── MysqlTestContainer.java
├── resources
│   ├── application.yml

1.3 상속 관계

위 클래스들의 상속관계는 다음과 같습니다.

class MysqlTestContainer {} // MySQL 테스트 컨테이너 관련 설정이 담겨있음
├── class InitAcceptanceTest extends MysqlTestContainer {} // 인수테스트 관련 초기화 작업 수행
│   ├── class StoreAccepanceTest extends InitAcceptanceTest {}
│   ├── class ReviewAcceptanceTest extends InitAcceptanceTest {}
│   ├── class UserAcceptanceTest extends InitAcceptanceTest {}
├── class InitIntegrationTest extends MysqlTestContainer {} // 통합테스트 관련 초기화 작업 수행
│   ├── class StoreIntegrationTest extends InitIntegrationTest {}
│   ├── class ReviewIntegrationTest extends InitIntegrationTest {}
│   ├── class UserIntegrationTest extends InitIntegrationTest {}

1.4 테스트 컨테이너 설정

아까 살펴봤었던 MysqlTestContainer 클래스는 다음과 같은 테스트 컨테이너 관련 설정이 있었습니다.

@Testcontainers
public abstract class MysqlTestContainer {

    private static final String MYSQL_VERSION = "mysql:8";
    private static final String DATABASE_NAME = "testDB";

    @Container
    static final MySQLContainer MYSQL_CONTAINER = new MySQLContainer(MYSQL_VERSION)
        .withDatabaseName(DATABASE_NAME);
}

여기서 @Testcontainers 어노테이션을 따라 들어가보면 다음과 같은 내용이 있습니다.

여기서 주목할 것은 @ExtendWith(TestcontainersExtension.class) 이 부분인데요.
이 클래스는 Junit Jupiter extension model 을 구현한 Extension 클래스 입니다.

testcontainer 의 공식문서에서는 JUnit5 와 연동되는 기능에 대해 다음과 같이 설명합니다.

While Testcontainers is tightly coupled with the JUnit 4.x rule API, this module provides an API that is based on the JUnit Jupiter extension model.

이전에는 JUnit4 의 @Rule, @ClassRule 이라는 어노테이션에 의존적이었다고 합니다.
하지만, 이제 JUnit5 와는 Jupiter extension model 에 기반한 API 를 제공한다고 합니다.

실제로 소스코드를 보면 TestcontainersExtension.class 클래스는 Test lifecycle callback 을 구현합니다.

이 내용이 궁금하여 소스코드 레벨로 찾아보았는데, 관심있으신 분들은 3. 딥다이브 를 참고해주세요.

그래서 다시 위 소스 중 컨테이너 선언 부분으로 돌아가서 보면,

    ...

    @Container
    static final MySQLContainer MYSQL_CONTAINER = new MySQLContainer(MYSQL_VERSION)
        .withDatabaseName(DATABASE_NAME);

    ....

위 처럼 static 이 붙으면 테스트 메소드간에 공유되도록 BeforeAllCallback 을 타고,
그렇지 않은 경우 BeforeEachCallback 을 타게 됩니다.

그래서 실제 테스트 클래스들인 아래 6개의 클래스들에서 BeforeAllCallback 이 실행되며, 6회 mysql 컨테이너가 실행 되었던 것 입니다.

  • class StoreAccepanceTest extends InitAcceptanceTest {}
  • class ReviewAcceptanceTest extends InitAcceptanceTest {}
  • class UserAcceptanceTest extends InitAcceptanceTest {}
  • class StoreIntegrationTest extends InitIntegrationTest {}
  • class ReviewIntegrationTest extends InitIntegrationTest {}
  • class UserIntegrationTest extends InitIntegrationTest {}

그래서 실행된 횟수를 모두 합하면, 제일 위에서 말씀드렸던 7회 = Datasource(1) + Test class(6) 실행된 이유를 알게 되었습니다.
그러나 팀에서는 애초에 한번 띄워놓고, 계속 재사용한다고 가정하고 테스트를 작성했었습니다.
그래서 이 횟수를 1회로 줄인다면, 테스트 속도가 크게 향상될 것이라 생각했습니다.

2. 해결

위에서 설명이 너무 길어졌는데요, 해결방법은 아주 간단합니다.

Datasource 에서 1회 실행되니, 나머지 클래스들이 MysqlTestContainer 를 상속하지 않게 하면 됩니다.

MysqlTestContainer 를 지우고, InitAcceptanceTest, InitIntegrationTest 에서 상속을 해제합니다.
그리고 다시 실행해보면 최초에 Datasource 관련 작업시 1회 컨테이너가 실행되고, 그 다음에는 재사용 하게 됩니다.

이 차이를 보실 수 있게 비교한 자료를 첨부합니다.

1. @Container + not static field = BeforeEachCallBack

  • Container 생성횟수 : 13 = Datasource 관련 (1) + 각 테스트 테스트 메소드 실행 전 BeforeEach(12)
  • Test 총 소요시간 : 1m 50sec
@Testcontainers
public abstract class MysqlTestContainer {

    private static final String MYSQL_VERSION = "mysql:8";
    private static final String DATABASE_NAME = "testDB";

    @Container
    final MySQLContainer MYSQL_CONTAINER = new MySQLContainer(MYSQL_VERSION)
        .withDatabaseName(DATABASE_NAME);
}

2. @Container + static field = BeforeAllCallBack

  • Container 생성횟수 : 7 = Datasource 관련 (1) + 각 테스트 클래스의 BeforeAll(6)
  • Test 총 소요시간 : 1m 8sec
@Testcontainers
public abstract class MysqlTestContainer {

    private static final String MYSQL_VERSION = "mysql:8";
    private static final String DATABASE_NAME = "testDB";

    @Container
    static final MySQLContainer MYSQL_CONTAINER = new MySQLContainer(MYSQL_VERSION)
        .withDatabaseName(DATABASE_NAME);
}

3. static field + static block

  • Container 생성횟수 : 2 = Datasource 관련 (1) + MysqlTestContainer 클래스 로드 시점(1)
  • Test 총 소요시간 : 35sec
@Testcontainers
public abstract class MysqlTestContainer {

    private static final String MYSQL_VERSION = "mysql:8";
    private static final String DATABASE_NAME = "testDB";

    static final MySQLContainer MYSQL_CONTAINER = new MySQLContainer(MYSQL_VERSION)
        .withDatabaseName(DATABASE_NAME);

    static {
        MYSQL_CONTAINER.start();
    }
}

4. Datasource only

  • Container 생성횟수 : 1 = Datasource 관련 (1)
  • Test 총 소요시간 : 21sec
spring:
  datasource:
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
    url: jdbc:tc:mysql:8://testDB

...

3. 딥다이브

위에서 잠시 나왔던 @Container 어노테이션을 어떻게 Testcontainer 가 다루는지 궁금해서 조금 더 찾아봤습니다.
이는 TestcontainersExtension.java 에서 찾을 수 있었는데요.

@Container + static field, @Container + not static field 순서로 한번 살펴보겠습니다.

3.1 @Container + static field

이 경우는 TestcontainersExtension.java#L45 부터 시작합니다.
보시기 편하게 아래에 소스 일부를 가져왔습니다.

beforeAll() 는 JUnit engine 이 Test class 에서

아래의 findSharedContainers() 메소드를 통해 공유하는 컨테이너의 목록을 찾아오는데요.
따라가보면 JUnit 의 ReflectionUtils 를 통해 isSharedContainer() 조건에 맞는 필드들만 가져옵니다.

그 아래의 isSharedContainer() 메소드를 보면, isContainer() 이고, static 인지 확인하죠
그리고 isContainer() 조건은 필드에 @Container 가 붙어있고, 타입(클래스)이 Startable 인터페이스 하위 클래스인 경우 입니다.

아까 MysqlTestContainer 의 필드에 있었던 MySQLContainer 의 상위로 올라가면 GenericContainer 클래스가 나옵니다.
이 클래스가 Startable 인터페이스를 구현했기 때문에, 위 조건을 모두 만족하여 컨테이너가 실행됩니다.

3.2 @Container + not static field

beforeEach() 는 JUnit engine 이 Test method 실행 전에 호출합니다.

로직을 따라가다가, this::findRestartContainers 라는 부분이 있는데 이 메소드를 따라가보면,
이번에는 JUnit 의 ReflectionUtils 를 통해 isRestartContainer() 조건에 맞는 필드들만 가져옵니다.

그 아래의 isRestartContainer() 메소드를 보면, isContainer() 이고, not static 인지 확인합니다.

3.3 정리

Testcontainer 는 이 처럼 JUnit 의 extension model 을 구현하였고,
리플렉션을 통해 @Container 가 붙은 필드의 static 여부에 따라 다르게 동작하게 하는 것 입니다.