본문 바로가기
프로그래밍/테스트

[spring + spock + TestContainer] Spring, Spock Framework에서 기능 테스트 하기 - TestContainer 사용

by 사바라다 2021. 4. 30.

안녕하세요. Spring에서 DB 기능 테스트를 할 때 어떤 걸 주로 이용하시나요? 제가 생각하기로는 여러분들은 로컬에서 쉽게 돌릴 수 있는 인메모리 DB인 H2를 가장 많이 사용하실 것 같습니다. 왜냐하면 사용하기 쉽기 때문이겠죠.

오늘제가 여러분들께 알려드리고자 하는 것은 조금 다른 기능 테스트 방법을 제공해주는 TestContainers입니다. H2를 사용하는 것과 어떻게 다르며 사용할 수 있는지 알아보도록 하겠습니다. :)

TestContainer

TestContainer는 로컬 환경에서 실제 DB에 테스트를 할 수 있도록 지원하는 Java 라이브러리입니다. DB라면 인메모리 DB인 H2를 사용하면 되지 않느냐? 라고 생각하실 수 있습니다. 그럼에도 불구하고 TestContainer를 왜 사용할까요? 실제 DB와 H2는 다르기 때문입니다. DB는 DB 별로 쿼리나 일부 기능이 다릅니다. 따라서 테스트 때는 H2를 사용하고 실제로는 다른 MariaDB와 같은 DB를 이용한다면 테스트에서는 나오지 않았던 이슈가 운영상에 발생할 가능성이 있습니다. TestContainer는 이러한 문제점을 막을 수 있도록 테스트에서도 H2와 같은 인메모리 디비를 사용하는 것이 아닌 실제 MariaDB와 같은 DB를 올리고 테스트가 끝나면 종료되도록 할 수 있는 라이브러리입니다.

사용하기 위한 요구 사항

Docker를 이용하여 환경을 테스트시에만 올렸다 내릴 수 있습니다. 따라서 Docker와 일반적으로 사용하시는 test framework가 필요합니다.

  • 도커(Docker)
  • JVM 테스팅 프레임워크
    • JUnit 4
    • Jupiter/JUnit 5
    • Spock

실습

위에서 TestContainer가 인메모리 DB에 비해 유리한 점과 사용하기 위한 사전조건을 알아보았습니다. 그렇다면 이제 실제로 한번 사용해보도록 하겠습니다.

환경

제가 테스트한 환경은 아래와 같습니다. JUnit은 사용하지 않고 Spock Framework를 사용하였습니다.

  • MacOS Mojave 10.14.5
  • Docker Desktop 3.3.1
  • Java 11
  • Spring Boot 2.3.4.RELEASE
  • Spring Data JPA
  • Spock Framework 1.3-groovy-2.5
  • MariaDB 10.3.6

의존성

testImplementation "org.spockframework:spock-core:1.3-groovy-2.5"
testImplementation "org.spockframework:spock-spring:1.3-groovy-2.5"
testImplementation "org.testcontainers:spock:1.15.0-rc2"
testImplementation "org.testcontainers:mariadb:1.15.0-rc2"

testContainer 코드

testContainer를 사용하기 위해서 필요한 기본적인 flow는 아래와 같습니다. 3가지의 사전 flow를 지켜주실 필요가 있습니다.

  1. Docker에 Container를 띄웁니다.
  2. Docker에 Container가 전부 뜰때까지 기다립니다.
  3. Spring framework에 testContainer의 정보를 전달합니다.

위의 3가지 과정을 정상적으로 마무리되면 이제 테스트 과정에서 mariaDB를 사용할 수 있게 됩니다.

설정 코드

그렇다면 위 3가지 과정을 어떻게 구현하는지 코드로 보도록 하겠습니다. 코드를 보시면 제일 먼저 mariaDB라는 instance가 있습니다. 해당 instance는 mariaDB docker를 띄우는 역할을 합니다. 띄우는데 필요한 정보들은 with~~ 메서드를 통해 채워 넣을 수 있습니다.

그리고 아래 Initializer static class을 확인해보겠습니다. 해당 class가 하는 작업은 MariaDB Docker Container가 모두 뜨면 해당 정보를 Spring이 바라볼 수 있도록 DB의 정보를 가져와 세팅하는 것입니다.

@Testcontainers
@DataJpaTest(showSql = false)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@ContextConfiguration(initializers = [Initializer.class])
class ReviewRepositoryV1Test extends Specification {

    /**
     * test가 실행되 때 1번만 실행하기 위해 static 처리 
     * 해당 instance가 MariaDB container를 띄우는 역할을 함
     */
    public static MariaDBContainer mariaDB = new MariaDBContainer() 
            .withDatabaseName("<database_name>")
            .withUsername("<user_name>")
            .withPassword("<user_password>")
            .withFileSystemBind("<initialize_path>") // (optional)
            .withCommand("<command>") // (optional)

    /**
     * mariaDB Container를 먼저 띄운 후 Spring에 주입하기 위한 순서 유지를 위한 처리
     */
    @Shared
    public MariaDBContainer mySQLContainer = mariaDB

    /**
     * container를 Spring에 주입하기 위한 설정
     */
    static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

        void initialize(ConfigurableApplicationContext configurableApplicationContext) {
            TestPropertyValues.of(
                    "spring.datasource.hikari.jdbc-url=" + mariaDB.getJdbcUrl(),
                    "spring.datasource.hikari.username=" + mariaDB.getUsername(),
                    "spring.datasource.hikari.password=" + mariaDB.getPassword()
            ).applyTo(configurableApplicationContext.getEnvironment())
        }
    }
}

테스트 코드

실제 사용하는 테스트 코드는 아래와 같습니다. 테스트는 spock 프레임워크로 작성되었으며 간단하게 DB를 조회하는 것입니다. 정상적으로 DB에 접근해서 데이터를 가져오는 것을 확인하였습니다.

@Autowired
private ReviewRepositoryV1 repositoryV1

def "findByGameIdAndUserId_간단_테스트"() {
    given:
    int gameId = 41

    expect: "querying the database"
    repositoryV1.findAllByGameId(gameId).size() == 2
}

log 확인

그렇다면 일부 로그를 확인해보며 어떻게 이루어졌는지 보도록 하겠습니다. 로그를 통해 알 수 있는 부분이 testContainer는 Spring 배너가 뜨는 것보다 먼저 실행된다는 사실입니다. 그 후 contextInitializerClasses가 세팅되어 Spring DB 환경에 값이 들어가는 것을 알 수 있었습니다.

16:27:00.494 [Test worker] INFO org.testcontainers.dockerclient.DockerClientProviderStrategy - Found Docker environment with local Unix socket (unix:///var/run/docker.sock)
16:27:00.496 [Test worker] INFO org.testcontainers.DockerClientFactory - Docker host IP address is localhost
16:27:00.564 [Test worker] INFO org.testcontainers.DockerClientFactory - Connected to docker: 
  Server Version: 20.10.5
  API Version: 1.41
  Operating System: Docker Desktop
  Total Memory: 1987 MB
16:27:02.801 [Test worker] INFO org.testcontainers.DockerClientFactory - Ryuk started - will monitor and terminate Testcontainers containers on JVM exit
16:27:02.801 [Test worker] INFO org.testcontainers.DockerClientFactory - Checking the system...
16:27:02.802 [Test worker] INFO org.testcontainers.DockerClientFactory - ✔︎ Docker server version should be at least 1.6.0
16:27:02.929 [Test worker] INFO org.testcontainers.DockerClientFactory - ✔︎ Docker environment should have more than 2GB free disk space
16:27:02.947 [Test worker] INFO 🐳 [mariadb:10.3.6] - Creating container for image: mariadb:10.3.6
16:27:03.996 [Test worker] INFO 🐳 [mariadb:10.3.6] - Starting container with ID: 7c68eb9892b127a9e5fe31adf41ff86e7b847beff7d563916d74405da68316ca
16:27:05.367 [Test worker] INFO 🐳 [mariadb:10.3.6] - Container mariadb:10.3.6 is starting: 7c68eb9892b127a9e5fe31adf41ff86e7b847beff7d563916d74405da68316ca
16:27:05.381 [Test worker] INFO 🐳 [mariadb:10.3.6] - Waiting for database connection to become available at jdbc:mariadb://localhost:55009/pika using query 'SELECT 1'
16:27:14.502 [Test worker] INFO 🐳 [mariadb:10.3.6] - Container is started (JDBC URL: jdbc:mariadb://localhost:55009/pika)
16:27:14.502 [Test worker] INFO 🐳 [mariadb:10.3.6] - Container mariadb:10.3.6 started in PT15.144598S

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.3.4.RELEASE)

...[중략]...
2021-04-30 16:27:20.695  INFO 72627 --- [    Test worker] o.s.t.c.transaction.TransactionContext   : Began transaction (1) ... contextInitializerClasses = '[class com.pika.core.review.repository.ReviewRepositoryV1Test$Initializer]' ...

전체 코드

@Testcontainers
@DataJpaTest(showSql = false)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@ContextConfiguration(initializers = [Initializer.class])
class ReviewRepositoryV1Test extends Specification {

    @Autowired
    private ReviewRepositoryV1 repositoryV1

    @Autowired
    private EntityManager entityManager


    /**
     * test가 실행되 때 1번만 실행하기 위해 static 처리 
     * 해당 instance가 MariaDB container를 띄우는 역할을 함
     */
    public static MariaDBContainer mariaDB = new MariaDBContainer() 
            .withDatabaseName("<database_name>")
            .withUsername("<user_name>")
            .withPassword("<user_password>")
            .withFileSystemBind("<initialize_path>") // (optional)
            .withCommand("<command>") // (optional)

    /**
     * mariaDB Container를 먼저 띄운 후 Spring에 주입하기 위한 순서 유지를 위한 처리
     */
    @Shared
    public MariaDBContainer mySQLContainer = mariaDB

    /**
     * container를 Spring에 주입하기 위한 설정
     */
    static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

        void initialize(ConfigurableApplicationContext configurableApplicationContext) {
            TestPropertyValues.of(
                    "spring.datasource.hikari.jdbc-url=" + mariaDB.getJdbcUrl(),
                    "spring.datasource.hikari.username=" + mariaDB.getUsername(),
                    "spring.datasource.hikari.password=" + mariaDB.getPassword()
            ).applyTo(configurableApplicationContext.getEnvironment())
        }
    }

    def "findByGameIdAndUserId 테스트"() {
        given:
        int gameId = 41

        expect: "querying the database"
        repositoryV1.findAllByGameId(gameId).size() == 2
    }
}

마무리

오늘은 이렇게 TestContainer를 사용하는 방법에 대해서 알아보는 시간을 가져보았습니다.

docker를 이용하여 local 환경에서 실제 환경과 동일하게 테스트를 할 수 있다는 사실을 알 수 있었습니다.

감사합니다.

참조

https://www.testcontainers.org/

댓글