백엔드 개발을 하다보면 개발자 본인 PC 에서 로컬 환경에서 구동하기 위한 데이터베이스를 포함한 각종 인프라가 필요합니다. docker 의 container 를 이용해서 편리하게 인프라를 구성할 수 있고, 각종 자동화 스크립트, 라이브러리를 통해서 인프라의 초기화도 가능합니다.
그런데, 이 로컬 환경의 인프라는 로컬 환경에서 개발을 하기 위한 인프라는 테스트를 위한 인프라로 재사용하기가 상당히 까다롭고 좋은 방법도 아닙니다. 이런 여러가지 사정으로 인해 테스트를 위한 인프라를 별도로 구성해 놓는 것이 가장 좋은 방법입니다.
여기서는 통합 테스트 시 필요한 인프라를 테스트 시작 시 구성, 완료 시 폐기할 수 있는 docker 기반의 라이브러리의 장담점을 소개하고, 간단한 예시로 사용 방법을 안내해 보겠습니다.
장단점
장점
- 통합 테스트(integration test) 시에 생성, 완료 시 폐기되므로 재사용되는 인프라를 사용할 때 데이터 초기화 등을 고민하지 않아도 된다.
- docker container 기반으로 동작하여 docker image 가 제공되는 인프라는 모두 구성이 가능하다. 외부 API 또한 외부 API 에 직접 연결할 필요없이 wiremock 을 이용하여 json 형식으로 요청, 응답을 명세하여 외부 API 와 통신하는 것처럼 테스트 환경을 구성할 수 있다.
- docker 를 지원하는 CI(Continuous Integration) 환경이면 인프라 걱정없이 테스트가 가능하다.
단점
- 테스트를 실행할 때마다 인프라를 새로 생성해야 해서 초기화가 필요하여 지속적인 인프라에 비해 속도가 느리다.
- docker-compose 와 별개로 테스트를 위한 추가 설정이 필요하다.
위 장담점 중 개인적으로 느낀 가장 큰 장단점은 아래와 같습니다.
장점은 구성만 잘 해 놓으면 언제, 어디서든 동일한 테스트 결과를 보장받을 수 있습니다.
단점으로는 인프라가 많으면 많을수록, 테스트 횟수가 증가할수록 인프라 초기화 속도로 인해 스트레스가 증가합니다.
사용방법
개발 환경이 주로 spring boot 인 이유로 해당 환경에서 사용해 본 경험을 기반으로 작성합니다.
다른 개발 환경이라도 크게 다르지는 않을 것이라 생각합니다.
개발에 관한 문서는 공식 사이트에서 참고하세요. https://testcontainers.com/
기본순서
- 필요한 인프라에 대한 docker image 를 검색하거나, 로컬 환경용으로 작성한 docker-compose 를 참고합니다.
- 해당 인프라에 대한 Container 클래스를 작성합니다.
- spring boot 의 통합 테스트 시작 시 자동으로 초기화하도록 코드를 추가합니다.
예시
백엔드 개발 시 Database 를 가장 기본적으로 사용하므로 MySQL 을 기반으로 설명합니다.
MySQL 5.7 기반으로 Database 를 만들것이고, docker-compose 대비 어떻게 작성되어야 하는지 함께 적어보겠습니다.
일단 docker-compose 의 설정을 봅시다.
version: '3.9'
services:
mysql:
container_name: mysql
image: mysql/mysql-server:5.7
hostname: mysql
ports:
- "43306:3306"
environment:
- MYSQL_ROOT_PASSWORD=root
- MYSQL_ROOT_HOST=%
- MYSQL_DATABASE=testdb
- MYSQL_USER=username
- MYSQL_PASSWORD=password!!
- TZ=Asia/Seoul
command: [ "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci", "--lower_case_table_names=1", "--sql_mode=IGNORE_SPACE,STRICT_TRANS_TABLES,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION" ]
volumes:
- ./mysql/initdb.d:/docker-entrypoint-initdb.d
추가적인 db 초기화에 필요한 스크립트를 docker-compose volumns 설정을 통해 해줍니다. 여기서는 생략합니다.
위와 같은 설정을 testcontainers 를 어떻게 옮기는지 보여드리겠습니다.
import mu.KotlinLogging
import org.testcontainers.containers.GenericContainer
import org.testcontainers.containers.wait.strategy.Wait
import org.testcontainers.utility.MountableFile
class MySQLContainer<SELF : GenericContainer<SELF>> private constructor(
dockerImageName: String,
) : GenericContainer<SELF>(
dockerImageName
) {
internal constructor() : this("mysql/mysql-server:5.7")
init {
startupAttempts = 1
withExposedPorts(PORT)
withEnv("MYSQL_ROOT_PASSWORD", "root")
withEnv("MYSQL_ROOT_HOST", "%")
withEnv("MYSQL_DATABASE", DATABASE)
withEnv("MYSQL_USER", USERNAME)
withEnv("MYSQL_PASSWORD", PASSWORD)
withEnv("TZ", "Asia/Seoul")
withCommand(
"--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci",
"--lower_case_table_names=1",
"--sql_mode=IGNORE_SPACE,STRICT_TRANS_TABLES,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION",
)
withCopyToContainer(
MountableFile.forHostPath("sps-docker/mysql/initdb.d/00_init.sql"),
"/docker-entrypoint-initdb.d/00_init.sql",
)
waitingFor(Wait.forListeningPort())
}
override fun start() {
super.start()
val options = "useSSL=false&serverTimezone=Asia/Seoul&rewriteBatchedStatements=true"
val jdbcUrl = "jdbc:mysql://${host}:${getMappedPort(PORT)}/${DATABASE}?${options}"
System.setProperty("TC_MYSQL_JDBC_URL", jdbcUrl)
System.setProperty("TC_MYSQL_USERNAME", USERNAME)
System.setProperty("TC_MYSQL_PASSWORD", PASSWORD)
log.info { "started." }
}
companion object {
private val log = KotlinLogging.logger { }
private const val PORT = 3306
private const val USERNAME = "username"
private const val PASSWORD = "password!!"
private const val DATABASE = "testdb"
}
}
코드를 보시면 크게 init 블록과 start 메서드로 구분됩니다.
init 블록의 코드가 docker-compose 와 아주 유사합니다.
start 메서드에는 테스트 시 인프라의 정보를 시스템 환경 변수로 지정합니다. 이렇게 환경 변수화하는 이유는 spring-boot 가 초기화되는 과정에서 동적으로 생성된 인프라와 원할하게 연결되도록 하기 위함입니다.
이제 spring boot test 를 이용해서 통합 테스트 환경에서 어떻게 초기화하는지 봅시다.
@Tag("integration-test")
@ActiveProfiles("test")
@SpringBootTest(
classes = [WebApplication::class],
properties = ["spring.profiles.active=test"],
)
@ContextConfiguration(
initializers = [IntegrationSpec.IntegrationInitializer::class],
)
abstract class IntegrationSpec(body: FunSpec.() -> Unit) : FunSpec(body) {
override fun extensions(): List<Extension> = listOf(SpringExtension)
...
class IntegrationInitializer: ApplicationContextInitializer<ConfigurableApplicationContext> {
companion object {
private val mySQLContainer by lazy { MySQLContainer() }
}
override fun initialize(applicationContext: ConfigurableApplicationContext) {
if (!mySQLContainer.isRunning) {
mySQLContainer.start()
}
}
}
}
ApplicationContextInitializer 를 이용해서 인프라를 초기화할 수 있습니다.
application.yml 도 함께 보시죠.
spring:
config:
activate:
on-profile: test
datasource:
hikari:
driver-class-name: com.mysql.jdbc.Driver
jdbc-url: ${TC_MYSQL_JDBC_URL}
username: ${TC_MYSQL_USERNAME}
password: ${TC_MYSQL_PASSWORD}
위 MySQLContainer start 메서드에서 환경 변수화 하는 부분을 보셨을 것 입니다. 이렇게 application.yml 에서 아까 선언된 환경 변수를 이용해서 database 와 연결할 수 있습니다.
이렇게 전반적인 spring boot 기반 통합 테스트 시 자동 생성되는 docker container 를 이용한 테스트 인프라 구성에 대해서 알아보았습니다.
여기서는 MySQL 만 보여드렸는데, docker image 만 있다면 kafka 같은 이벤트 스트리밍, wiremock 을 이용한 rest api 등등 테스트 인프라를 쉽고 빠르게 구성할 수 있습니다.
위 내용은 어떻게 구성하는지에 대한 예시이며, 해당 코드만으로 동작을 보장하지 않습니다.
끝까지 봐주셔서 감사합니다.