멀티모듈 환경에서 Testcontainers로 일관성있는 테스트 환경 만들기
서로 협력하고 있는 서비스들과의 통합 테스트
멀티 모듈 어플리케이션의 도메인 모듈인 (:domain:a
)는 아래의 다이어그램처럼 다양한 의존성을 갖고 있다.
:storages:mysql-main
: JPA기반의 mysql 저장소 (Repositories):storages:redis-main
: spring-data-redis 기반의 레디스 저장소 (Repositories):systems:client-x
: 내부 서버 어플리케이션 x와의 통신을 위한 클라이언트 모듈
복잡한 의존성을 가진 해당 모듈에 대한 단위 테스트를 작성하기에는 몇 가지 어려움이 있다. (물론, 불가능한 일은 아님)
- 멀티 모듈이기 때문에 Spring이 제공해주는 Context를 사용하는게 어렵다. (어플리케이션을 위한 의존성은 더 높은 수준의 모듈에 포함되어 있기 때문. 물론 해당 모듈 테스트 코드에서 Configuration을 재정의하더라도 꽤 복잡해질 수 있다.)
- 서버 X와 해당 어플리케이션이 다른 코드베이스로 존재하기 때문에, 인터페이스에만 의존한다는 원칙으로 개발하더라도 서버 X의 변화를 mock으로 처리하는데 한계가 있다. (바보같은 의견일 수도 있음.)
Spring Context를 이용하기 어려운 점
계층형 구조를 갖고 있는 멀티모듈 프로젝트였기 때문에, 도메인 모듈의 상위 계층으로 어플리케이션 모듈이 분리되어 있는 상태다.
그리고 이 어플리케이션 모듈이 스프링 어플리케이션의 의존성을 갖고 있으면서 하위 모듈을 조립하는 구조이기 때문에, 도메인 모듈 계층에서는 @SpringBootTest
나 @DataJpaTest
같은 어노테이션이 잘 작동하지 않는다.
멀티모듈의 프로젝트의 도메인 계층의 테스트를 작성할 때에는 테스트를 위해 조립하는 Configuration을 추가해야 한다.
@EnableJpaRepositories(basePackages = ["example.storages.mysqlcore"])
@EnableRedisRepositories(basePackages = ["example.storages.rediscore"])
@EntityScan(basePackages = ["example.storages.mysqlcore.entities", "example.storages.rediscore.entities"])
@ComponentScan(
basePackages = [
"example.domain.a",
],
)
@TestPropertySource(
properties = [
],
)
@SpringBootApplication
class DomainATestConfiguration
Testcontainers를 사용한 외부 서비스 통합 테스트
엄밀한 작동 검증을 위해서 내부 서버 어플리케이션과 같이 통합 테스트하는 필요성이 있다. 코드로 컨테이너를 동작시킬 수 있는 Testcontainers를 사용하면, 테스트 코드를 작성할 때 외부 서비스와의 통합 테스트를 쉽게 작성할 수 있다.
@SpringBootTest
@Transactional
class TestDomainAService {
lateinit var xAPIClient: XAPIClient
lateinit var sut: DomainAService
companion object {
@JvmField
val testNetwork: Network = Network.newNetwork()
@JvmField
val dynamoDBContainer: GenericContainer<*> =
GenericContainer("amazon/dynamodb-local:latest")
.withExposedPorts(8000)
.withNetwork(testNetwork)
.withNetworkAliases("dynamodb")
@JvmField
val xAPIClientContainer: GenericContainer<*> =
GenericContainer(".../x-api:latest")
.withExposedPorts(8080)
.withNetwork(testNetwork)
.dependsOn(dynamoDBContainer)
.withEnv(
mapOf(
"STAGE" to "test",
"AWS_ACCESS_KEY_ID" to AWS_ACCESS_KEY_ID,
"AWS_SECRET_ACCESS_KEY" to AWS_SECRET_ACCESS_KEY,
),
)
@JvmField
val redisContainer: GenericContainer<*> =
GenericContainer("redis:6.2.5")
.withExposedPorts(6379)
.withNetworkAliases("dynamodb")
@BeforeAll
@JvmStatic
fun setUp() {
dynamoDBContainer.start()
redisContainer.start()
xAPIClientContainer.start()
System.setProperty("spring.data.redis.host", redisContainer.host)
System.setProperty("spring.data.redis.port", redisContainer.firstMappedPort.toString())
runBlocking {
createTables(dynamoDBContainer.firstMappedPort)
}
}
@AfterAll
@JvmStatic
fun tearDown() {
xAPIClientContainer.stop()
dynamoDBContainer.stop()
redisContainer.stop()
}
}
@PostConstruct
fun init() {
xAPIClient = DomainAAPIClient(
baseUrl = "http://localhost:${xAPIClientContainer.firstMappedPort}",
)
assert(xAPIClient.checkReadiness().message.lowercase() == "ok")
sut = DomainAService(
xAPIClient,
AEventRepository,
)
}
dynamodb의 Caller Identity 일치
dynamodb는 aws의 서비스이고, 개발자들의 개발을 위해 컨테이너 이미지를 제공한다. 테스트 코드가 잘 작동하라면 먼저 api 호출을 통해 사용하는 테이블을 생성해야 한다.
여기서 주의해야할 점은 aws credentials. SDK로 통해 테이블 생성 작업을 코드로 수행할 때의 aws credentials과 테스트 안에서 dynamodb를 사용하는 aws credentials가 일치해야 한다. Caller Identity가 다르다면, 자동화된 코드가 만들어낸 테이블을 실제 테스트 코드가 접근하지 못하는 문제를 한다.
이번 예시에서는 createTable()
메서드에서 쓰이는 AWS 키들이 내부 API 서버 X의 컨테이너의 환경변수에 주입되는 것들과 일치해야 하나의 테이블에 접근 가능하다.
AWS SDK for Kotlin을 기준으로는 아래와 같은 명시를 해주었다.
import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider
import aws.sdk.kotlin.services.dynamodb.DynamoDbClient
suspend fun createTables(dynamoDbPort: Int) {
DynamoDbClient {
region = "ap-northeast-2"
endpointUrl = Url.parse("http://localhost:$dynamoDbPort")
credentialsProvider = StaticCredentialsProvider {
accessKeyId = AWS_ACCESS_KEY_ID
secretAccessKey = AWS_SECRET_ACCESS_KEY
}
}.let {
// ...
}
컨테이너 병렬 실행
Testcontainers는 컨테이너를 병렬로 실행할 수 있다. 빌드나 테스트에 소요되는 시간을 줄일 수 있다.
@SpringBootTest
@Transactional
@Testcontainers(parallel = true)
class TestDomainAService {
가장 먼저, Testcontainers의 @Testcontainers(parallel = true)
를 통해 병렬로 컨테이너를 실행함을 선언한다.
그리고 컨테이너 변수마다 @Container
어노테이션을 붙여준다.
@Container
@JvmField
val dynamoDBCoreContainer: GenericContainer<*> =
GenericContainer("amazon/dynamodb-local:latest")
.withExposedPorts(8000)
.withNetwork(testNetwork)
.withNetworkAliases("dynamodb")
.waitingFor(Wait.forHttp("/").forStatusCode(400))
.withReuse(true)
Junit Extension 구현
테스트 클래스에서 반복적으로 선언하는 걸 피하기 위해서 상속을 사용할 수도 있지만, Extension 방식이 더 확장성을 가질 수 있다. 상속에는 제약이 있기 때문에, 단순히 BeforeAll 같은 훅을 추가하는 작업이라면 Extension을 사용하는 것이 좋다고 본다.
import org.junit.jupiter.api.extension.BeforeAllCallback
import org.junit.jupiter.api.extension.ExtensionContext
class TestContainersExtension : BeforeAllCallback {
override fun beforeAll(context: ExtensionContext) {
dynamoDBCoreContainer.getOrStart()
}
private fun GenericContainer<*>.getOrStart() {
if (!this.isRunning) {
this.start()
}
companion object {
// containers 선언
}
}
Network 를 사용하고 있는 Containers 를 재사용하기
TestContainers 사용할 때 Network를 불가피하게 띄워야 하는 경우들이 있다. 그럴 때 Network를 사용하고 있는 테스트 컨테이너들은 reuse가 기본적으로 잘 작동하지 않는다. (테스트 할 때마다 network는 재사용될 수 없어서 network Id가 매번 다르기 때문에 다른 컨테이너 정의라고 판단) 그래서 위 링크의 코드처럼 Network를 찾는 과정을 거쳐 Network도 재사용되도록 해야 한다.
private val docker = DockerClientFactory.instance().client()
private val networkId: String
get() = docker.listNetworksCmd().exec().firstOrNull { it.name == "x-api-network" }?.id
?: docker.createNetworkCmd().withName("x-api-network").exec().id
private val chatAPINetwork: Network
get() = object : Network {
override fun apply(base: Statement, description: Description): Statement = base
override fun getId(): String = networkId
override fun close() {
// never close
}
}
Conclusion
멀티 모듈 환경에서 통합 테스트를 작성할 때, Testcontainers를 사용하면 외부 서비스와의 통합 테스트를 쉽게 작성할 수 있다. 그리고 아래와 같은 테스트 코드 품질을 달성할 수 있다.
- 복잡한 서비스와의 협업을 모킹하기 위한 처리하기 위한 코드를 작성할 필요가 없다. (너무 의존하면 안되겠지만.)
- 언제 수행해도 동일한 결과가 반환될 수 있게 되도록 테스트 환경을 일관성 있게 만들 수 있다.