TestContainers로 유저시나리오와 비슷한 통합테스트 만들어 보기

진정한 통테를...

안녕하세요, 컬리의 딜리버리프러덕트팀에서 백엔드 개발을 하고 있는 김태훈입니다.

고객이 주문한 상품을 물류센터로 부터 고객이 원하는 장소까지 전달하기 위한 배송 마지막 구간을 "라스트마일(Last Mile)" 이라고 합니다. 저희팀은 컬리에서 구매한 상품의 배송에 관련된 여러 가지 라스트마일 서비스를 만들고 있습니다. "라스트마일" 관련된 개발업무에 관심이 있으신 분은 지체하지 마시고 지원 부탁드립니다 !!

고민은 입사만 늦출 뿐.

채용공고



배포의 무게

앞서 말씀드린 대로 저희 팀에서는 컬리에서 구매한 상품을 미리 안내된 시간에 맞추어 고객에게 전달하기 위한 미션을 수행 중입니다. 복잡한 서비스인 만큼 이해관계자도 많이 있는데요, 이런 고객들의 다양한 요구사항에 빠르게 대응하려면 배포주기를 짧고 일정하게 가져가야 합니다. 그리고 안정적이어야 하죠.

https://miro.com/blog/choose-between-agile-lean-scrum-kanban/

안정적인 배포를 위해 대부분의 개발팀에선 개발이 완료되면 QA(Quality Assurance)를 진행하게 됩니다.
하지만 사람이 진행하는 QA는 비용이 많이 듭니다. 안정은 추구할 수 있지만 빠른 배포가 어렵게 되죠.
이러한 제한사항을 극복하기 위해 개발자들은 단위테스트, 통합테스트, 회귀테스트 등등 여러 가지 자동화된 테스트를 만들어 배포 시에 안정성을 높이고 비용을 줄이는 노력을 합니다.



관심사에 따른 통합테스트

여기 스토리티켓이 하나 있습니다.

고객이 주문한 주문을 배송 관리 서비스에서 확인할 수 있다

이 사용자 스토리는 운영자(사용자)가 배송 관리 서비스에서 주문을 확인하면 완료되는 스토리입니다.
스토리 상의 사용자는 한 명이지만 테스트를 위해서는 두 개의 사용자 액션이 존재해야 합니다.

  1. 주문을 한다.(사용자1)
  2. 주문을 확인한다.(사용자2)

데이터는 조금 더 길게 흘러갑니다. 관련된 서버도 여러 가지가 있죠.

(서버가 분리되어 있다고 가정하면) 이런 스토리를 자동화된 통합테스트 코드로 구현한다면 아마 각 서버의 관심사에 맞는 테스트를 진행하고 외부 의존성은 가짜데이터로(mock) 처리할 것입니다.

이벤트 수집 서버의 통합테스트 코드 

@Test
fun `이벤트 저장이 정상적으로 이루어진다`() {
    given -> 주문이벤트 발행(mock)
    when -> 주문이벤트 수집실행
    then -> 주문이 저장되었는가?
}
배송 관리 서버의 통합테스트 코드

@Test
fun `주문건을 조회할  있다`() {
  given -> 주문이벤트 저장(mock)
  when -> 주문 조회 api 호출
  then -> 주문이 조회되었는가?
}

테스트는 무사히 통과할 것으로 기대됩니다.



버그발생

그런데 배송관리 시스템에서 주문을 조회할 때는 배송 가능한 지역의 주문인지가 중요하기 때문에 개발자는
배송 가능한 지역의 배송건 이란 제약사항을 조회 조건에 추가하였습니다.
테스트 케이스는 배송관리 서버의 통합테스트에 추가되기 때문에 여전히 모든 테스트는 통과되고 QA 단계에 이르러 서야 어떤 주문은 보이고 어떤 주문은 보이지 않는 버그가 발견될 것입니다. 개발자는 이벤트 수집 서버에도 제약사항을 추가하여 배송 가능한 지역의 배송건 만 주문을 생성하고 그 외 주문은 별도의 배송수단 혹은 보상트랜젝션을(주문취소등) 발행해야 합니다.

이렇게 서버 간 서로 다른 제약조건으로 인해 발생하는 버그들은 기존의 통합테스트로는 쉽게 찾기가 어렵습니다.
만약 서버의 구별 없이 하나로 통합된 테스트였다면 어땠을까요?

합쳐진 통합테스트 코드

@Test
fun `고객이 주문한 주문건을 조회할  있다`() {
  given -> 주문이벤트 발행(mock)
  when -> (주문이벤트 수집), 주문 조회 api 호출
  then -> 주문이 조회되었는가?
}

이런 구조에서는 조회 쪽의 제약사항이 변경되면 테스트가 실패할 가능성이 생기기 때문에 조기에 발견했을 수도 있습니다. 현실에서 이와 비슷한 문제는 빈번히 발생합니다.

그래서 저희는 testContainers를 사용해 실제 QA 분이 진행하는 테스트와 최대한 비슷한 자동화 테스트 코드를 작성해 보기로 했습니다.



Testcontainers

About Testcontainers for Java

Testcontainers for Java is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

google 번역 > Testcontainers for Java 는 JUnit 테스트를 지원하는 Java 라이브러리로, 일반 데이터베이스, Selenium 웹 브라우저 또는 Docker 컨테이너에서 실행할 수 있는 모든 항목의 경량 일회용 인스턴스를 제공합니다.
너무 짧은 설명…

간단히 'testcontainers는 테스트환경에서 코드로 도커 컨테이너를 실행시킬 수 있다' 로 볼 수 있습니다.
도커만 설치되어있다면 어떤 환경에서나 테스트를 실행 시키고 동일한 결과를 얻을 수 있는 것입니다.



테스트시작

통합테스트는 아래와 같이 진행합니다.

이 시나리오를 위해선 4개의 컨테이너가 필요합니다.

  1. 이벤트 브로커 (kafka)
  2. 이벤트 수집 서버 (consumer - spring application)
  3. 주문저장 DB (postgresql)
  4. 주문조회 서버 (api - spring application)

먼저 testContainer를 수행할 환경을 세팅해보도록 하겠습니다.
프로젝트를 만들고 의존성을 추가해 줍니다.

integration-test추가

dependencies {
        testImplementation "org.testcontainers:testcontainers:1.17.6"
        testImplementation "org.testcontainers:postgresql:1.17.6"
        testImplementation "org.testcontainers:junit-jupiter:1.17.6"
        testImplementation "org.testcontainers:kafka:1.17.6"
        testImplementation "org.junit.jupiter:junit-jupiter:5.8.1"
    }

이벤트 수집과 조회 서버는 멀티프로젝트로 구성되어 있어서 같은 프로젝트에 모듈을 추가하였습니다. (해당 프로젝트의 test code는 kotlin으로 작성되어있습니다.) 테스트는 필요한 시스템 이미지를 컨테이너로 실행해 api 호출하는 방식으로 진행되기 때문에 별도 프로젝트로 생성해도 무방합니다.
이제 각각의 컨테이너 인스턴스를 만들어 보도록 하겠습니다.

testcontainers제공되는 여러 모듈

이벤트 브로커 인스턴스

class KafKaContainerExtension : Extension, BeforeAllCallback {
    companion object {
        val kafkaContainer = KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1"))
    }
    override fun beforeAll(context: ExtensionContext) {
        if (kafkaContainer.isRunning) {
            return
        }
        kafkaContainer.start()
    }
}

주문 저장 DB 인스턴스

class PostgresContainerExtension : Extension, BeforeAllCallback {
    companion object {
        val postgisContainer = PostgisContainerProvider().newInstance()
            .withDatabaseName("디비명")
            .withUsername("사용자")
            .withPassword("비번")
            .withExposedPorts(5432)
    }

    override fun beforeAll(context: ExtensionContext) {
        if (postgisContainer.isRunning) {
            return
        }
        postgisContainer.start()
    }

}

withExposedPorts 는 컨테이너가 사용할 내부포트를 설정합니다. 기본값으로 각 모듈의 기본 포트를 사용합니다.

withInitScript 를 이용해 RDB 인스턴스 생성시 수행될 기본스크립트를 삽입할 수 도 있습니다. 또 필요시 생성된 DB컨테이너의 정보로 커넥션을 직접 생성하여 쿼리를 수행할 수 도 있습니다.

val hikariConfig = HikariConfig()
hikariConfig.setJdbcUrl(postgisContainer.jdbcUrl)
hikariConfig.setUsername(postgisContainer.username)
hikariConfig.setPassword(postgisContainer.password)
hikariConfig.setDriverClassName(postgisContainer.driverClassName)
val statement: Statement = HikariDataSource(hikariConfig).getConnection().createStatement()
statement.execute("select * from table.name")
var resultSet = statement.resultSet

spring application 이미지는 내부적으로 배포할 때 사용하는 Container Registry 에서 불러와 사용합니다.

class ApiContainerExtension : Extension, BeforeAllCallback {
    companion object {
        var api = GenericContainer(
            DockerImageName.parse("이미지명:버전")
                .withRegistry("저장소경로")
        )
            .withExposedPorts(8080)
            .withImagePullPolicy(PullPolicy.alwaysPull())

    }

    override fun beforeAll(context: ExtensionContext) {
        if (api.isRunning) {
            return
        }
        api.start()
    }
}

withImagePullPolicy 를 사용해 테스트가 실행될 때마다 새로 이미지를 다운로드 하도록 할 수 있습니다.
(이 설정을 하지 않으면 실행할 이미지 명이 동일할 경우 코드를 수정해도 이전 이미지를 사용하게 됩니다.)
이처럼 이미 생성된 이미지를 사용하거나 런타임에 직접 이미지를 생성할 수 있는 방법도 제공합니다.

이미지생성

이제 만들어진 컨테이너를 테스트 코드 실행 시 로딩하고 내부 네트워크를 이용해 연결해 보도록 하겠습니다.
도커 내부의 컨테이너끼리 연결은 여러 가지 방법이 있지만 이렇게 공통네트워크를 설정해주면 컨테이너끼리는 alias로 통신할 수 있습니다. (꼭 내부로 연결해야 하는 것은 아닙니다. 컨테이너에 연결된 외부 포트를 사용해 localhost로 연결도 가능합니다. 하지만 전 삽질을 2박 3일로…)

@Testcontainers
@ExtendWith(
    KafKaContainerExtension::class,
    PostgresContainerExtension::class,
    ApiContainerExtension::class,
    ConsumerContainerExtension::class
)
class IntegrationTestByContainers {
  companion object {
    val network: Network = Network.newNetwork()
    val restTemplate: RestTemplate = RestTemplate()
  }
}
  • @Testcontainers: testContainers임을 명시합니다.
  • @ExtendWith: 해당 애노테이션에서 로딩할 순서대로 클래스를 배치해 줍니다.
  • Network: 전역으로 선언해 각 컨테이너를 로드 할 때 참조할 수 있도록 해줍니다.
    이제 각 컨테이너에 withNetwork 로 네트워크를 설정하고 alias를 지정해 줍니다.
companion object {
        const val KAFKA_AS: String = "kafka"
        val kafkaContainer = KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1"))
            .withNetwork(network)
            .withNetworkAliases(KAFKA_AS)

    }

spring application에서 연결할 내부 시스템들은 프로필 파일을(yml) 별도로 만들거나 환경변수로 런타임에 설정이 가능하도록 각 application 을 수정해야 합니다. 저희는 환경변수를 사용하였습니다.

각 spring application의 application-local.yml

spring:
  api:
    datasource:
      master:
        driver-class-name: org.postgresql.Driver
        jdbc-url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/
        username: 사용자명
        password: 비밀번호
        
  kafka:
      producer:
        bootstrap-servers: ${KAFKA_HOST:localhost}:${KAFKA_PORT:9092}

코드를 변경했으니 다시 이미지를 한번 생성해주고요,
이제 환경변수에 연결한 컨테이너의 alias를 넣어줍니다. (포트는 기본 포트를 사용하였습니다)

class ApiContainerExtension : Extension, BeforeAllCallback {
    companion object {
        var api = GenericContainer(
            DockerImageName.parse("이미지명:버전")
                .withRegistry("이미지저장소")
        )
            .withEnv("RUN_ENV", "local")
            .withNetwork(network)
            .withEnv("POSTGRES_HOST", POSTGRES_AS)
            .withEnv("KAFKA_HOST", KAFKA_AS)
            .withExposedPorts(8080)
            .withImagePullPolicy(PullPolicy.alwaysPull())

    }

    override fun beforeAll(context: ExtensionContext) {
        if (api.isRunning) {
            return
        }
        api.start()
    }
}

이제 설정은 모두 끝났고 연결이 잘되었는지 테스트해 보도록 하겠습니다.

테스트 코드에서 application서버 호출은 컨테이너 외부에서 접근하기 때문에 포워드 된 포트를 지정해주어야 합니다.
포워드 포트는 직접 지정해줄 수도 있지만 충돌의 위험이 있으니 자동으로 지정되게 하고 getFirstMappedPort로 값을 받아와 사용합니다.

@Test
fun `고객이 주문한 주문건을 배송관리서비스에서 확인할  있다`() {
    try {
        println(
            restTemplate.getForEntity(
                "http://localhost:" + api.getFirstMappedPort() + "/ping",
                java.util.List::class.java
            )
        )
    } catch (e: Exception) {
        e.printStackTrace()
        println(api.getLogs())
    }

}  

제공되는 모듈로딩

이미지로 직접 로딩

내부 시스템 연결이 되지 않아 spring application start에 실패했다면 아래와 같은 시스템 로그가 남게 됩니다. (컨테이너의 로그는 getLogs()로 불러올 수 있습니다.)

/bin/bash: connect: Cannot assign requested address
/bin/bash: /dev/tcp/localhost/8080: Cannot assign requested address
/bin/sh: 1: nc: not found

연결은 잘 되었지만… 권한이 없습니다.

401!!

아마 대부분의 서버 어플리케이션들은 사용을 위해 인증 절차를 거쳐야 할 것입니다.
실제로 인증 서버를 컨테이너로 띄워 동작시켜도 되지만 저희는 편의를 위해 local 프로필 환경에서는 특별한 토큰값으로 인증을 우회하도록 하였습니다.

@Test
fun `고객이 주문한 주문건을 배송관리서비스에서 확인할  있다`() {
    try {
        var headers: MultiValueMap<String, String> = HttpHeaders()
        headers.set("Authorization", "Bearer magic-token")
        var httpEntity: HttpEntity<MultiValueMap<String, String>> = HttpEntity(headers);
        val exchange = restTemplate.exchange(
            "http://localhost:" + api.getFirstMappedPort() + "/ping",
            HttpMethod.GET,
            httpEntity,
            java.util.List::class.java
        )
    } catch (e: Exception) {
        e.printStackTrace()
        println(api.getLogs())
    }
}

이제 모든 준비가 끝났고 스토리 시나리오대로 코드를 작성하고 확인하도록 하겠습니다.

@Testcontainers
@ExtendWith(
    KafKaContainerExtension::class,
    PostgresContainerExtension::class,
    ApiContainerExtension::class,
    ConsumerContainerExtension::class
)
class IntegrationTestByContainers {
    companion object {
        val network: Network = Network.newNetwork()
        val restTemplate: RestTemplate = RestTemplate()
    }

    inline fun <reified T> typeReference() = object : ParameterizedTypeReference<T>() {}

    @Test
    fun `고객이 주문한 주문건을 배송관리서비스에서 확인할  있다`() {

        //given - 주문 메시지 발행
        val props = Properties()
        props["bootstrap.servers"] = kafkaContainer.bootstrapServers
        props["key.serializer"] = StringSerializer::class.java.canonicalName
        props["value.serializer"] = StringSerializer::class.java.canonicalName

        var producer = KafkaProducer<String, String>(props);
        var TOPIC_NAME = "토픽명"
        var orderNo = "12345"
        var message = "{\"주문번호\":\""+orderNo+"\", ...메시지내용... }"

        val futureResult = producer.send(ProducerRecord(TOPIC_NAME, message))
        println(futureResult.get());

        // when - 주문조회
        var headers: MultiValueMap<String, String> = HttpHeaders()
        headers.set("Authorization", "Bearer magic-token")
        var httpEntity: HttpEntity<MultiValueMap<String, String>> = HttpEntity(headers);
        val exchange: ResponseEntity<List<DeliveryRequestTaskPointRes>> =
            restTemplate.exchange(
                "http://localhost:" + api.getFirstMappedPort() + "/orders?"+queryString,
                HttpMethod.GET,
                httpEntity,
                typeReference<List<DeliveryRequestTaskPointRes>>()
            )

        //then - 주문이 조회되는가?
        assertEquals(orderNo, exchange.body.get(0).orderNo)
    }
}

성공!!

이제 지속적인 통합 테스트를 위해 트리거를 걸어보도록 할게요.
서버에 배포할 이미지가 생성된 후 그 이미지를 사용해 스테이지에 배포하여 QA도 진행하고 저희가 만든 자동화 통합 테스트도 진행합니다. 저희는 git action을 사용 중 이어서 git work flow에 yml파일을 추가해 트리거를 걸도록 하겠습니다.

name: Integration Test

on:
  workflow_run:
    workflows: [ "deploy dev automatically" ]
    types:
      - completed

workflows 에 배포 이미지를 생성하는 workflow 명을 적어주고 types를 completed로 해주면 배포를 위한 이미지 생성 액션이 완료된 후 통합 테스트가 돌게 되고 결과를 리포팅 받을 수 있습니다.



좋다좋아~

이렇게 한층 더 개선된 통합테스트가 완성되었습니다.
물론 그럼에도 불구하고 사람이 할 수밖에 없는 테스트들은 항상 존재합니다.
하지만 이런 노력으로 기본적인 케이스의 시나리오들을 테스트한다면 QA 분들은 더 많은 비정상 케이스에 대해 테스트가 가능하고 서비스는 더욱 안정적으로 제공될 것입니다. 또 인프라 요소의 버전이 바뀌었을 때(데이터베이스의 버전 변경 등) 서비스 영향도를 파악하기도 수월할 것입니다.
장점이 많고 구축하기도 쉬운 testcontainers를 여러분의 통합테스트에도 도입해보는 건 어떨까요?
다음에 더 좋은 내용으로 만날 수 있도록 노력하겠습니다~

감사합니다~





참고자료

TestContainers for Java
Miro blog