서버사이드 테스트 파랑새를 찾아서

Nov.21.2023 박재현
clipboard facebook twitter

Backend QA/Testing

테스트는 끊임없이 흘러들어오는 비즈니스 지식과 요구사항을 개발 과정에서 개발자가 잘 인지하고 명세하게 도움을 줄 뿐만 아니라 이후에도 시스템이 이를 안정적으로 유지하는 것을 보장하게 합니다.

여러분들이 속한 조직에서는 좋은 테스트 코드를 작성하고 있으신가요?

저는 위 질문에 그렇다고 답변하진 못할 것 같아요. 좋은 테스트라는 것은 참 추상적인 개념이니까요. 명쾌한 답은 아직 찾지 못했지만, 서버 사이드 테스트를 더 잘하기 위해 환경을 개선해 나가는 과정에서의 경험을 공유하면 비슷한 고민을 하는 사람과 조직에 도움이 될 수 있지 않을까 싶어 글을 써 보려 합니다.

들어가기 앞서

  1. 이 글은 테스트 환경을 어떻게 구성하고, 테스트 코드를 어떻게 작성할 것인지에 대한 구체적 방법을 제시하기보다는 테스트 환경을 갖출 수 있는 사람이거나 이미 테스트 환경이 갖춰진 시스템에서 더 나아지길 원하는 사람들에게 도움이 될 수 있도록 선물하기 시스템에 테스트 환경 개선을 시도하면서 겪은 몇 가지 경험을 전달하는 데 초점이 맞추어져 있습니다.

  2. 글 내의 코드 예시는 모두 Kotlin, Spring, Kotest, Mockk를 사용하는 개발환경 기준으로 작성되었습니다.

테스트 환경 개선의 필요성

선물하기 시스템은 테스트가 꽤 존재했지만 혼란스러운 상태였습니다. 사실은 모두가 테스트 코드를 읽고 쓰는 데 불편함을 느끼고 있는 상황이었죠.

테스트의 궁극적인 목표는 지속 가능한 소프트웨어 입니다. 소프트웨어는 개발이 계속될수록 엔트로피(시스템 내의 무질서한 정도)는 증가하게 됩니다. 리팩토링과 같은 코드 정리 과정들이 없다면 이는 더욱 증가하며, 계속 내버려둔다면 소프트웨어 회귀의 발생 가능성이 더욱 높아집니다.

테스트는 이런 엔트로피 증가를 효율적으로 관리할 수 있는 도구로써 활용됩니다. 새로운 기능을 도입하거나, 더 좋은 구조로의 리팩토링을 진행하는 과정에서 테스트는 개발자에게 소프트웨어 회귀에 대한 안정감을 얻을 수 있게 하죠. 이런 이유로 좋은 테스트 환경을 갖춘 개발 조직은 높은 생산성을 기대할 수 있습니다. 개발조직의 생산성은 비즈니스의 신속함에도 적지 않은 영향을 주죠.

결과적으로 '우리의 테스트 환경 개선은 곧 비즈니스의 신속함으로 이어질 것이다'라는 기대 하에 더 좋은 테스트 코드를 작성하기 위한 개선은 분명히 필요한 과정이라 생각했습니다. 개선을 위해서 우선 테스트의 혼란스러움을 유발하는 원인을 찾는 게 급선무였는데요. 기존의 테스트 코드와 환경을 분석해보니 아래와 같은 원인이 있다는 것을 알아내었습니다.

  • 테스트 이해도 차이에서 오는 혼란
    • 테스트의 모호한 개념들
    • 좋은 테스트 코드에 관한 합의된 정의와 작성 사례가 없었음.
  • 효율적인 테스트 환경의 부재로 인한 혼란
    • 이렇다 할 테스트 기본 환경을 제공하고 있지 않았음.
    • 테스트 프레임워크를 섞어 사용하는 둥 테스트 환경과 작성 패턴이 제각각이었음.
    • 테스트 피드백이 느렸음.

그래서 이 문제점들을 하나씩 해결해나가기로 합니다.

테스트의 모호함과 현실에서 마주한 문제들

테스트는 어느 정도 통념이 된 개념들에 대해서 아직도 매번 새로운 시각의 방법론들이 제시될 만큼 좋은 테스트를 명확하게 정의하기가 매우 까다롭습니다. 그렇기에 조직마다, 개발자마다 테스트 개념을 이해하는 뉘앙스가 너무 많이 존재 하기도 합니다. 여기서는 제가 마주했던 모호한 개념들에 대한 정의현실적으로 구현할 때 마주하는 문제들에 대해 어떤 해결책을 냈는지 이야기해보려 합니다.

유닛테스트

유닛테스트는 하나의 클래스 또는 밀접하게 연관된 클래스들을 유닛이라는 단위로 표현하고 이를 SUT로 테스트하는 것을 뜻합니다. 유닛테스트를 작성하다 보면 종종 고민이 생기게 되는데요. 제가 마주했던 고민 중 기억나는 몇 가지를 적어보자면 아래와 같습니다.

  • 유닛테스트의 유닛은 어디까지인가
  • 테스트 대역을 사용하는 것은 리팩터링 내성을 낮추기 때문에 좋지 않은데, 테스트 대역을 사용하지 않으면 테스트를 할 수 없을 때 어떻게 할까?
  • 유닛 테스트는 빠른 피드백을 제공해야 하지만 그렇지 않을때

이것들은 유닛테스트에서 정의하는 원칙들을 모두 지키려고 하다 보면 당면하곤 하는 문제입니다. 현실적으로 이런 원칙들은 각자가 처한 개발 환경마다 당장 지킬 수 없는 경우도 있습니다. 그럼에도 최선을 찾아야만 하겠죠. 그 과정을 얘기해보겠습니다.

사실 위 고민들의 근본적인 문제는 유닛테스트의 격리 수준과 많은 연관이 있었습니다.

유닛테스트의 격리 수준

유닛테스트의 격리에 관한 대표적 구분은 아래와 같이 두 가지로 나뉩니다.

협동테스트와 단독테스트

이미지 출처 – 유닛테스트
  • Sociable Test (협동 테스트)
    • 테스트 대상 유닛이 다른 유닛과 협동하는 관계라면 다른 유닛과 함께 테스트한다.
    • 협동 테스트는 테스트 대상 유닛뿐만 아니라 협동하는 다른 유닛과 함께 테스트하므로 테스트 대상 유닛의 버그가 아닌 다른 유닛의 버그로 테스트 대상 유닛의 실패가 발생할 수 있다.
  • Solitary Test (단독 테스트)
    • 테스트 대상 유닛만 테스트한다.
    • 단독 테스트는 테스트 대상 유닛만 테스트하기 때문에 다른 유닛과의 협동이 있다면 테스트 대역(Test double)을 이용한다.

이는 런던파(London school) 와 고전파(Classical school)의 견해에 따른 테스트 스타일과도 관련이 있는데요. 고전파는 협동 테스트를 선호하지만, 런던파는 단독테스트를 선호하는 성향이 있습니다.

단독테스트는 기본적으로 SUT와의 의존 관계를 갖는 클래스에 테스트 대역을 이용하기 때문에 테스트 대역을 최대한 사용하지 않는 협동 테스트와 비교하면 구현이 테스트코드에 침투한다는 느낌을 받게 됩니다. 예시를 한번 들어볼까요?

얘기하기 앞서 선물하기 서버 애플리케이션은 Kotlin, Spring 환경을 사용하고 있으며, 대략적인 아키텍처는 아래와 같습니다.

우리는 상품권의 만료 처리를 담당하는 서비스의 만료 처리 함수를 구현하려고 하고, 이 함수는 ID를 인자로 받아 ID에 해당하는 상품권을 만료 처리하고 성공 여부를 반환합니다. TDD의 생명주기에 따라 이 요구사항에 대한 협동테스트와 단독테스트 개발 과정을 간단하게 보겠습니다. 먼저 협동테스트입니다.

  1. 테스트 코드를 작성한다

    class GiftcardExpireServiceTest : FeatureSpec({
        lateinit var sut: GiftcardExpireService
    
        beforeTest {
            sut = GiftcardExpireService()
        }
    
        feature("상품권 만료") {
            scenario("상품권 만료에 성공한다.") {
                // Act
                val actual = sut.expire(id = 1L)
    
                // Assert
                actual.shouldBeTrue()
            }
        }
    })
  2. SUT를 생성한다

    class GiftcardExpireService {
        fun expired(id: Long): Boolean = TODO("상품권이 만료 처리된다.")
    }
  3. 테스트를 실행하고 테스트가 실패하는것을 확인한 뒤, 테스트를 통과하는 구현 과정에서 Repository가 필요하다는 설계적 의사결정이 발생하여 생성자를 수정한다.

    class GiftcardExpireService(private val giftcardRepository: GiftcardRepository) {
        fun expired(id: Long): Boolean = TODO("상품권이 만료 처리된다.")
    }
  4. 생성자가 수정되었기 때문에 테스트의 SUT 초기화 부분만 수정하고 테스트가 다시 실패하는것을 확인한다.

    class GiftcardExpireServiceTest : FeatureSpec({
        lateinit var sut: GiftcardExpireService
        lateinit var repository: GiftcardRepository
    
        beforeTest {
            repository = GiftcardRepositroy()
            sut = GiftcardExpireService(repository)
        }
    
        feature("상품권 만료") {
            scenario("상품권 만료에 성공한다.") {
                // Act
                val actual = sut.expire(id = 1L)
    
                // Assert
                actual.shouldBeTrue()
            }
        }
    })
  5. 테스트를 통과하기위해 구현하고 테스트를 통과시킨다.

    class GiftcardExpireService(private val giftcardRepository: GiftcardRepository) {
        fun expired(id: Long): Boolean {
            val giftcard = giftcardRepository.findBy(id) ?: NoSuchElementException("Does not exist giftcard (id: $id)")
            return giftcard.expired()
        }
    }

협동테스트는 중간의 설계적 의사결정으로 테스트 코드 상에서 생성자의 추가라는 수정사항이 생겼지만, 테스트 스위트에는 자체에는 변화가 전혀 없습니다.

이제 같은 요구사항을 단독테스트로 구현해볼까요?

  1. 테스트 코드를 작성한다

    class GiftcardExpireServiceTest: FeatureSpec({
        lateinit var sut: GiftcardExpireService
    
        beforeTest {
            sut = GiftcardExpireService()
        }
    
        feature("상품권 만료") {
            scenario("상품권 만료에 성공한다.") {
                // Act
                val actual = sut.expire(id = 1L)
    
                // Assert
                actual.shouldBeTrue()
            }
        }
    })
  2. SUT를 생성한다

    class GiftcardExpireService {
        fun expired(id: Long): Boolean = TODO("상품권이 만료 처리된다.")
    }
  3. 테스트를 실행하고 테스트가 실패하는 것을 확인한 뒤, 테스트를 통과하게 구현한다. 이 구현 과정에서 동일하게 Repository가 필요하다는 설계적 의사결정이 발생한다.

    class GiftcardExpireService(private val giftcardRepository: GiftcardRepository) {
        fun expired(id: Long): Boolean = TODO("상품권이 만료 처리된다.")
    }
  4. 생성자 수정 외에 테스트 대역을 이용하기 위해 테스트 스위트 또한 수정한다 그리고 테스트의 실패를 확인한다.

    class GiftcardExpireServiceTest: FeatureSpec({
        lateinit var sut : GiftcardExpireService
        lateinit var repository: GiftcardRepository
    
        beforeTest {
            repository = mockk<GiftcardRepositroy>() 
            sut = GiftcardExpireService(repository)
        }
    
        feature("상품권 만료"){
            scenario("상품권 만료에 성공한다."){
            // Arrange
            every { repository.findBy(1L) }.returns(true)
    
            // Act
            val actual = sut.expire(id=1L)
    
            // Assert
            actual.shouldBeTrue()
        }
    }
    })
  5. 테스트를 통과하기위한 구현을 하고 테스트를 통과시킨다.

    class GiftcardExpireService(private val giftcardRepository: GiftcardRepository) {
        fun expired(id: Long): Boolean {
            val giftcard = giftcardRepository.findBy(id) ?: NoSuchElementException("Does not exist giftcard (id: $id)")
            return giftcard.expired()
        }
    }

협동테스트와 달리 단독테스트는 중간에 발생한 설계적 의사결정으로 테스트 대역이 추가되면서 테스트를 수정하는 시점에 Repository의 어떤 함수를 호출해야 할지 그 반환 값은 어떤 것인지 구체적으로 생각하게 되며 구현 시점이 아니라 테스트 작성 시점으로 의사 결정이 앞당겨지고 테스트 스위트에도 이 의사결정이 일부 투영됩니다. 이는 앞으로의 수정에서도 마찬가지이겠죠.

이렇듯 단독테스트는 협동테스트에 비해 테스트 코드에 구현이 침투하는 느낌이 들거나, 리팩토링 내성이 낮아진다는 부작용이 존재합니다. 즉, 협동테스트가 비교적 유지보수에 용이합니다. 또한 단독 테스트는 테스트 대역을 사용함으로써 발생하는 '~~할 것이다'와 같은 가정에서 오는 불확실함도 생기게 되죠.

그래서 저는 모든 테스트를 협동 테스트로 작성할까요? 아쉽게도 아닙니다.

출처 – MBC 복면가왕

그 이유는 Spring 환경과 현재의 아키텍처에서 모든 유닛테스트를 협동 테스트로 구현하려면 서비스가 의존하는 다른 클래스(위 예시에서는 Repository)와 거기서 의존하는 또 다른 클래스까지 모두 테스트 런타임에서 필요로 하게 되는데 이를 나이스하게 제공하기 위한 과정이 상당히 불편하거나 어렵습니다.

‘Spring의 IoC 컨테이너를 사용하면 되는 거 아니야?’ 하는 생각도 드실 겁니다. SUT에 대한 협동테스트가 마법처럼 쉽게 가능해지니까요. 하지만 이것은 또 다른 문제를 만들어 내는데요.

테스트 피드백 속도와 @SpringBootTest

테스트 환경에서 아래와 같이 @SpringBootTest 애너테이션을 이용해 IoC 컨테이너와 그 안에 초기화된 Bean을 사용하는 테스트 코드를 자주 보았는데요.

@SpringBootTest
class GiftcardExpireServiceTest(
    private val sut: GiftcardExpireService
) : featureSpec() {
    init {
         feature("상품권 만료"){
            // test code
        }
    }
}

@SpringBootTest를 사용해 Spring IoC 컨테이너를 사용한 경우는 그렇지 않은 경우에 비해 테스트 피드백이 치명적으로 느려집니다.

느린 테스트 피드백은 TDD의 생명주기에도 영향을 끼칩니다. 또 테스트가 프레임워크에 강하게 의존하는 느낌을 받기도 하고요. 더해서 격리되지 않은 DB를 프로세스 외부 의존성으로 사용하게 되면서 이는 유닛테스트 조건 중 격리된 방식으로 처리 라는 조건을 어기게 되고 동시 다발적으로 유닛테스트를 실행할 수 없는 환경에 가까워집니다.(물론 테스트별 데이터가 겹치지 않도록 엄격하게 관리한다면 동시에 여러 테스트를 실행시킬 수 있습니다만, 테스트 데이터 관리에 더 많은 부담을 느끼게 됩니다.)

지금 선물하기 시스템에는 테스트의 피드백 속도가 더 중요했기에 이런 트레이드오프의 결과로 ‘유닛 테스트 작성 시 테스트 대상 유닛과 다른 유닛의 협동, 위임 관계가 존재하는 테스트는 단독 테스트와 테스트 대역을 적극 사용한다.’ 라는 원칙이 합의되고 @SpringBootTest 애너테이션을 제거하게 됩니다.

그렇다고 둘 중 하나의 테스트 스타일만 고집하는 것이 아니라, 모두 사용하기로 했으며 이를 상황에 맞게 선택하는 기준을 두었습니다.

  • 기본적으로 격리된 방식으로 쉽게 테스트할 수 있는 유닛에 대한 테스트는 협동 테스트를 적극 사용한다.
  • 테스트 대상 유닛과 다른 유닛의 협동, 위임 관계가 존재하는 테스트는 단독 테스트와 테스트 대역을 사용할 수 있다.

따라서 격리된 방식으로 쉽게 테스트할 수 있는 유닛(예를 들면, ORM Managed Entity-JPA Entity)은 협동 테스트를 통해 구현하고 격리된 방식으로 쉽게 테스트할 수 없는 유닛(예를 들면, 위에서 예시 들었던 서비스)에 대해서는 테스트 대역을 이용한 단독 테스트가 사용되게 됩니다.

통합테스트

통합테스트는 단위테스트보다 더 넓은 범위가 대상이며 여러 모듈로 구성된 시스템 또는 여러 시스템과 함께 수행되는 협력 동작들도 대상에 포함되는 테스트입니다.
통합테스트 범위

이는 선물하기 애플리케이션 내부 구조에 따른 통합 테스트를 표현한 그림입니다. 기본적으로 사용자의 시나리오로 표현 영역부터 시작되어 기능 단위로 테스트를 진행하며, 기능에 필요한 모든 모듈이 협동 됩니다. (Domain이 대상이거나 Application 영역을 진입점으로 하는 통합테스트가 유용한다면 그것도 괜찮습니다) 선물하기 시스템의 통합 테스트에서는 RDBMS, Message Queue 등 외부 의존성을 테스트 환경으로 대체할 수 있게 LocalStack, TestContainers, Docker 등의 도구를 사용하고 있었습니다.

통합테스트에서 가장 고민되었던 점은 테스트 대역의 사용 여부인데요.

외부 통신에 테스트 대역 사용

통합테스트는 운영 환경에 준하는 환경에서 테스트 되어야 하지만 이것이 굉장히 곤란한 경우가 더러 있습니다. 그 대표적인 사례가 바로 외부 통신에 대한 부분인데요 외부 통신이 있는 경우 외부 API가 통합테스트용 환경 또는 앤드 포인트를 제공하고 있지 않다면(현실적으로 대부분 존재하지 않겠죠) 통합테스트에서 매번 라이브 환경의 외부 시스템을 호출하거나 테스트 대역을 이용하는 두 가지 선택지가 생기게 됩니다.

통합테스트에서 매번 라이브 환경의 외부 시스템을 호출하는 것은 실제 외부 시스템의 협동 기능까지 실제로 테스트할 수 있지만, 통합테스트에서 발생시킨 호출로 말미암은 잘못된 데이터 누적 등의 이유로 문제가 생긴 시스템의 비정상 기능이 테스트의 결정성을 떨어트릴 수 있습니다.

이는 테스트 대역이라는 선택지에서도 마찬가지입니다. 테스트 대역에 정의해둔 가정이 항상 지켜질 순 없으니까요. 하지만 두 선택지 중 테스트 대역을 이용하는 방식을 선택해 사용하고 있습니다. 우리 팀에서 사용하는 외부 통신의 대부분은 인터페이스 변화가 일어나지 않는다는 가정이 틀릴 일이 거의 없다고 판단했기 때문인데요.

만약 외부 통신의 인터페이스 변화가 시스템의 치명적 결함을 초래하는 모듈은 테스트 대역과 함께 계약 테스트를 사용할 수 있습니다. 저희도 일정 시간에 주기적으로 실행되며 테스트 스텁을 주기적으로 저장해 테스트하지는 않지만, 약식으로 외부 통신을 호출해볼 수 있는 계약 테스트가 존재합니다.

결과적으로 선물하기 시스템의 통합테스트는 IntergrationTest에서 얘기하는 Narrow integration test에 가깝습니다.

유닛테스트 VS 통합테스트 (테스트 비율)

이미지 출처 – 테스트 피라미드

테스트를 얘기할 때 빠지지 않곤 하는 개념이 있습니다. 바로 마틴 파울러의 테스트 피라미드 개념인데요.

  • UI – 사용자 UI를 통해 진행하는 테스트
  • Service – UI 바로 아래 수준(DDD의 서비스 개념과는 다름, 서버 사이드에서 예를 들면 API를 통한 테스트)에서 진행하는 테스트
  • Unit – 유닛 테스트

테스트 피라미드에서 아래로 갈수록 빠르고 비용이 저렴하기에 UI 테스트로 수행되는 BroadStack 테스트 보다 유닛 테스트가 자연스레 많아진다는 내용입니다. 이와 대비되는 내용으로 최근엔 통합테스트를 더 많이 작성하는 것이 좋다는 뉘앙스의 Spotify의 Testing of Microservices 라는 글에서 제시한 Testing Honeycomb 같은 주장이 나오면서 테스트 비율에 관련된 논쟁은 끊임없이 일어나고 있어요.

이런 여러 가지 주장에 마틴 파울러는 관련된 글에서 이런 논쟁을 끝내기 위해 어떤 개발자가 남긴 글을 공유 했습니다.

People love debating what percentage of which type of tests to write, but it's a distraction. Nearly zero teams write expressive tests that establish clear boundaries, run quickly & reliably, and only fail for useful reasons. Focus on that instead.
(의역) 사람들은 어떤 종류의 테스트를 어느 비율로 작성해야 하는지에 대한 논쟁을 좋아합니다. 하지만 이러한 논쟁은 중요한 것이 아닙니다. 사실 팀 대부분은 명확한 경계가 확립되어있고, 빠르고, 믿을 수 있으며, 유용한 이유로만 실패하는 잘 작성된 테스트를 작성하고 있지 않습니다. 따라서 이것을 목표로 집중하는 것이 더 중요합니다.

저 또한 이 말에 공감하며. 테스트의 비율에 대해서 고민하기보다는 상황에 따라 빠르고 신뢰할 수 있으며, 실질적으로 도움이 되는 테스트를 작성하는 것이 더 중요하다고 생각했습니다. 그래서 테스트의 비율에 대해서는 신경을 크게 쓰지 않기로 했습니다. 유용하고, 필요한 테스트를 계속 작성하다 보면 자연스럽게 적절한 비율이 설정되리라 생각합니다.

테스트 커버리지

커버리지는 테스트 스위트가 운영 코드를 검증하는 코드 비율을 뜻하는데요. 지표로 표현되어 그런지 생각보다 많은 사람이 테스트 커버리지에 관해 오해하고 있습니다. 어떤 사람들은 테스트 커버리지 100%에 도달하는 것을 맹목적으로 쫓기도 하죠. 하지만 테스트 커버리지는 테스트 중 확인된 운영 코드의 비율을 나타낼 뿐 본질적으로는 테스트의 품질을 측정하는 단위가 아니라고 생각합니다.

따라서 커버리지가 높다고 유용한 테스트가 많이 작성되어 있다는 것을 보장할 순 없습니다.
즉, 커버리지는 테스트 지표로서 우리에게 훌륭한 피드백을 제공하지만, 테스트 스위트의 품질을 측정하는 데는 크게 도움이 되지 않습니다. 하지만 역설적으로 커버리지가 0%에 가까운 시스템은 코드 표준이나 테스트 수준에 대한 잠재적인 위험이 존재한다는 피드백 지표로 도움이 될 수 있습니다.

그래서 테스트 커버리지를 시스템의 테스트 품질에 대한 긍정 지표가 아닌 부정 지표로써 사용하기로 했습니다.커버리지를 통해 여러 도메인에 걸쳐 철저하게 테스트 된 고품질 코드가 얼마나 많이 존재하는지 확인하는 것이 아니라 커버리지가 생각한 수준보다 낮을 때 이 시스템의 테스트가 제대로 되지 않고 있다는 신호로서 받아들이겠다는 것입니다.

효율적인 테스트 환경으로의 개선

위에서는 테스트의 모호한 점, 그리고 그것들을 어떻게 해결해나갈지 정의하는 과정이 있었다면 이 장에서는 테스트 기본 환경과 테스트 사례의 부재로 인한 문제를 개선하기 위해 도입한 일반화된 테스트 작성 패턴을 소개하고 이를 실현하기 위해 만든 도구들, 작업들을 이야기해보려 합니다.

일반화된 테스트 작성 패턴의 이점

프로그래머마다 고유의 테스트 작성 스타일이 있지만 저는 팀원 모두 일반화된 테스트 작성 패턴을 기준으로 개발하는 것을 선호합니다.

eXtream Programming(XP)가 대두되기 이전 대부분의 회사에서는 프로그래머가 개발한 코드를 별도의 조직에서 또 다른 프로그래머가 테스트했다고 합니다. 이 시절에는 테스트의 피드백이 치명적으로 느렸고 이 구조는 고객이 원하는 양질의 소프트웨어를 빠른 시간안에 전달 하자는 XP의 취지에 완전히 반대되었기 때문에 XP 진영에서 이에 대해 많은 비판을 하기 시작했습니다.

지금의 개발 문화는 이에 영향을 적지 않게 받아왔고 대부분의 개발 조직에서는 자체적으로 테스트를 작성하고 있죠. 개별 스타일로 테스트를 작성하면 프로그래머 간 작성된 테스트 코드를 읽고 쓰는 것이 상대적으로 느려집니다. 자신에게 익숙한 패턴의 코드가 아니기 때문이죠, 팀의 구성원이 더 많아질수록 테스트해야 할 로직이 복잡할수록 이 차이는 더 벌어지게 되겠죠. 이는 테스트의 복잡함과 지루함을 만들어내고, 이에 따라 테스트 피드백을 늦게 받게 되어 개발 중 테스트에 사용하는 시간을 더 길어지게 만듭니다. (어쩌면 테스트를 포기하고 약속한 개발 일정을 지키려고 하겠죠.)

반면에 이렇게 모두에게 합의된 공통 테스트 작성 패턴을 사용한다면 팀 내의 다른 프로그래머가 작성한 테스트 코드를 마치 자신이 작성한 테스트 코드를 보는 것처럼 느낄 수 있고 전반적으로 테스트 코드를 이해하고 작성하는 시간이 확연히 줄어들게 됩니다. 테스트에 녹여진 모듈의 요구사항을 더 빨리 알 수 있고 테스트 수정을 더 빨리할 수 있으며 이를 통과시키기 위한 운영 코드 작성 또한 더 빨리할 수 있습니다.

그렇게 만들어진 선물하기 시스템의 테스트 패턴

테스트 작성 패턴 중 유닛테스트에는 AAA(Arrange-Act-Assert) 패턴을 많이들 사용하고 GWT(Given-When-Then) 패턴은 통합테스트에서 사용자의 시나리오 기반으로 많이 작성되곤 하죠.

하지만 테스트마다 두 패턴을 번갈아 가며 사용하고 싶지 않았고, 모두 준비-실행-검증의 3단계 구조로 되어 있기에 유닛테스트/통합테스트 모두 기본적으로 단순하고 균일한 구조를 갖는 데 도움을 주는 GWT 테스트 작성 패턴을 공통으로 사용하기로 하고 통합테스트에서는 사용자 시나리오를, 유닛테스트에서는 모듈의 기능에 따른 시나리오를 기반으로 작성하고 있습니다. 이는 테스트 하고자 하는 SUT의 의도와 목적을 최대한 표현하게끔 자연스레 유도합니다.

Controller 유닛 테스트

컨트롤러 유닛 테스트는 mockMvc를 사용하고, Service는 테스트 대역으로 대체합니다.

    class GiftcardControllerTest : FeatureSpec({
        lateinit var mockMvc: MockMvc
        lateinit var giftcardService: GiftcardService
        lateinit var sut: GiftcardController

        beforeTest {
            giftcardService = mockk(relaxed = true)
            sut = GiftcardController(giftcardService)
            mockMvc = sut.initTestMockMvc(ApiExceptionAdvice::class)
        }

        feature("상품권 조회") {
            scenario("상품권 조회에 성공한다") {
                // GIVEN
                every {
                    giftcardService.find(483L)
                }.returns(
                    Giftcard(
                        id = 483L,
                        name = "배민 상품권",
                        amount = 10000L,
                        createdAt = LocalDateTime.of(2023, 1, 3, 12, 0)
                    )
                )

                // WHEN
                val actual: ApiResponse = mockMvc
                    .get(
                        url = "/giftcard/483",
                        header = ("Authorization", "memberId-1-Token"
                    )
                ).expectStatus(HttpStatus.OK)
                .andReturnAs<ApiResponse>() // mockMvc Response를 예상하는 타입으로 변환해주는 custom function

                // THEN
                val expect = ApiResponse(
                    status = 200,
                    result = Giftcard(
                        id = 483L,
                        name = "배민 상품권",
                        amount = 10000L,
                        createdAt = LocalDateTime.of(2023, 1, 3, 12, 0)
                    ),
                    serverDateTime = "1970-01-01 00:00:00"
                )
                actual.shouldBe(expect)
            }
        }
    })

Service 유닛 테스트

서비스 유닛 테스트는 단독테스트로 작성됩니다.

    class GiftcardServiceTest : FeatureSpec({
        lateinit var giftcardRepository: GiftcardRepositroy
        lateinit var purchaseRepository: PurchaseRepository
        lateinit var ProductRepository: ProductRepository
        lateinit var sut: GiftcardService

        beforeTest {
            giftcardRepository = mockk()
            purchaseRepository = mockk()
            ProductRepository = mockk()
            sut = withContext(Dispatchers.IO) {
                GiftcardService(giftcardRepository, purchaseRepository, ProductRepository)
            }
        }

        feature("상품권 발급") {
            scenario("구매가 완료되지 않은 상품권에 대한 발급은 예외(NotPurchasedGiftcardException)가 발생한다") {
                // GIVEN
                every { purchaseRepository.getById(1L) }.retruns(Purchase(id = 1L, status = PurchaseStatus.WAIT_PAYMENT))

                // WHEN
                val actual = kotlin.runCatching { sut.createGiftcard(purchaseId = 1L, productId = 2L, memberId = 9L) }
                    .exceptionOrNull()

                // THEN
                verify(exactly = 0) {
                    productRepository.findById(any())
                    giftcardRepositroy.create(any())
                }
                actual.shouldBeTypeOf()
            }
        }
    })

Domain(ORM Managed Entity) 유닛 테스트

ORM Managed Entity에 대한 테스트는 협동테스트로 작성됩니다.

     class GiftcardTest : FeatureSpec({
        feature("유효기간 만료") {
            scenario("마스킹 대상 정보가 마스킹된다") {
                // GIVEN
                // WHEN
                val sut = Giftcard(
                    id = 1L,
                    giftcardNumber = "98348238",
                    memberNumber = "AABB",
                    status = GiftcardStatus.ACTIVATE
                )
                val actual = sut.expired()

                // THEN
                actual.giftcardNumber.isMasked().shouldBeTrue()
                actual.memberNumber.isMasked().shouldBeTrue()
            }

            scenario("사용할 수 없는 상태가 된다.") {
                // GIVEN
                // WHEN
                val sut = Giftcard(
                    id = 1L,
                    giftcardNumber = "98348238",
                    memberNumber = "AABB",
                    status = GiftcardStatus.ACTIVATE
                )
                val actual = sut.expired()

                // THEN
                actual.status.isUsable().shouldBeFalse()
            }
        }
    })

API 통합 테스트

통합테스트는 협동테스트로 작성되며, 통합테스트 환경 제공을 위해 자체적으로 만들어서 사용하고있는 ApiIntegrationTestContext 를 상속하고, 외부 통신에 대해서만 테스트 대역을 사용합니다.

    class GiftcardControllerIntegrationTest : ApiIntegrationTestContext() {
        init {
            context("상품권 수락") {
                test("수락에 성공한다") {
                    // GIVEN
                    mockApiClient(BAEMIN_MEMBER_API)
                        .request {
                            get("/members/{memberId}") {
                                pathParameter { Parameter("memberId", 1234L) }
                            }
                        }.response {
                            status(HttpStatusCode.OK_200) {
                                body { Member(id = 1234L, name = "member-1", nickname = "nickname-1") }
                            }
                        }

                    // WHEN
                    val actual: ApiResponse = mockMvc
                        .put(
                            url = "/giftcards/XKC93D/accept", 
                            header = ("Authorization", "memberId-1234-Token") 
                        )
                    ).expectStatus(HttpStatus.OK).andReturnAs<ApiResponse>()

                    // THEN
                    val expect = ApiResponse(
                        status = 200,
                        result = GiftcardAcceptResponse(giftcardNumber = "XKC93D", amount = 10000L),
                        serverDateTime = "1970-01-01 00:00:00"
                    )
                    actual.shouldBe(expect)
                }
            }
        }
    }

이렇게 테스트 작성 패턴이 일반화되었고 이로 인해 각 영역과 테스트 구분 별 테스트를 작성하는 기준이 되어주어 테스트 도구에 익숙하지 않은 개발자가 테스트를 작성하더라도 일정한 품질을 보장할 수 있게 유도합니다. 일반화된 패턴과 환경을 만들고 유지하기 위해서는 별도의 도구들을 만들거나 기존의 도구들을 확장해야 하는데요. 아래에서 그 과정을 얘기해보려 합니다.

테스트를 도와주는 도구의 가치

기능비용시간테스트비용

위는 소프트웨어가 개발된 이후 시간이 지나면서 테스트를 작성하는(초록) 경우와 작성하지 않는 경우(주황) 간 테스트 대비 기능개발 비용을 나타내는 표입니다.

소프트웨어가 작성되기 시작한 초기에는 테스트가 그리 효과적이지 않다고 느껴지고, 기능개발에 사용할 수 있는 시간을 뺏어가는 행위로 보일 수 있죠. 하지만 소프트웨어 규모가 커졌을 때는 기능개발에 힘을 실어주는 든든한 조력자가 되어줄 수 있습니다.

잘 작성된 테스트가 우리의 조력자가 되는 시점이 초록 선과 주황 선이 교차하는 지점입니다. 다만 이 교차점은 이는 각 조직의 테스트 역량에 따라 그리고 사용하는 개발환경과 생태계의 지원에 따라 아래처럼 더 가까워지거나, 더 늦어질 수 있습니다.

기능비용시간테스트비용2
그렇기에 더 효율적인 테스트를 작성할 수 있는 도구들을 개발하고, 사용할수록 테스트 작성에 들어가는 비용은 더 낮아지게 되고 그 덕분에 테스트가 우리의 조력자가 되어주는 교차점 또한 가까워질 수 있기에 효율적인 테스트를 작성할 수 있는 도구를 개발하거나, 기존에 불편한 도구를 개선하는 것은 장기적으로 우리 조직의 개발 비용을 줄이는 데 큰 도움이 됩니다.

따라서 좋은 테스트 환경을 구축하기 위해서 테스트를 돕는 여러 가지 도구들을 연구하고 적극 활용하는 것은 꼭 필요합니다. 테스트를 도와주는 오픈 소스 라이브러리들을 사용하다 보면 기능이 부족하다고 느껴지는 경우가 꽤 있습니다.

이럴 때 매번 필요한 기능을 지원하는 다른 도구를 찾아 이것저것 합쳐서 사용하기보다는 사용자가 많은 오픈 소스를 사용하되 여기서 부족하다고 느껴지는 기능을 직접 만들어서 사용하는 것이 장기적으로 사용하기에 좋습니다.

Kotlin의 Extensions 기능을 사용하면 기존 라이브러리의 기능을 확장해서 사용하기 너무 편리합니다.

MockMVC Extension

테스트 코드가 읽기 쉽고 간결한 것은 분명히 도움이 됩니다. 테스트에 활용할 수 있는 도구들을 여러 가지 사용하면서 아무래도 자주 사용하는 코드임에도 코드가 수다스럽게 느껴진다는 느낌을 많이 받곤 했기에 테스트를 작성할 때 자주 사용하는 함수들이나 패턴을 더 간결하고 쉽게 사용하기 위한 도구들을 만들었습니다.

MockMvc를 이용해 테스트케이스를 대체로 호출 부에 아래와 같은 테스트 코드를 작성하곤 합니다.
(여기서 추가로 ObjectMapper를 사용하는 경우도 있습니다)

    mockMvc.perform(
        MockMvcRequestBuilders.post("/giftcards")
            .header("Authorization", "test")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body)
        ).andExpect(status().isOk)
            .andExpect(jsonPath("$.giftcardNumber").value("GIFJKVF211"))
            .andExpect(jsonPath("$.memberNumber").value("1234567"))
            .andExpect(jsonPath("$.type").value("NORMAL"))
            .andExpect(jsonPath("$.price").value(10000L))
            .andExpect(jsonPath("$.status").value("CREATED")
    )        

저는 이렇게 MockMvc가 기본적으로 제공하는 Builder를 통해 요청과 예상하는 응답을 설정하는 과정의 코드가 수다스럽다고 느껴졌기 때문에 요청/응답을 만들어내고 응답에 대해 검증하는 과정에서 발생하는 일반적인 패턴들을 쉽고 간단하게 작성할 수 있게끔 아래 예시처럼 DSL을 만들었습니다.

    fun <BODY : Any> MockMvc.post(
        url: String,
        body: BODY,
        headers: HttpHeaders = defaultRequestHeaders,
        contentType: MediaType = MediaType.APPLICATION_JSON
    ): ResultActions =
        MockMvcRequestBuilders.post(url).headers(headers).contentType(contentType).content(body)
            .run { perform(this) }

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

    inline fun <reified T> ParameterizedTypeReference<T>.toJacksonTypeRef(): TypeReference<T> {
        val type: Type = this.type
        return object : TypeReference<T>() {
            override fun getType(): Type = type
        }
    }

    inline fun <reified T> ResultActions.andReturnAs(): T =
        kotlin.runCatching {
            this.run {
                MockMvcConfig.defaultTestObjectMapper.readValue(
                    andReturn().response.getContentAsString(java.nio.charset.StandardCharsets.UTF_8),
                    typeReference<T>().toJacksonTypeRef()
                )
            }
        }.onSuccess {
            this.andDo(MockMvcResultHandlers.print())
        }.onFailure {
            this.andDo(MockMvcResultHandlers.print())
        }.getOrElse {
            throw IllegalArgumentException("failed response mapping string to object", it)
        }

이 DSL을 이용해 테스트 코드를 작성하면 아래와 같아집니다.

    val actual = mockMvc.post(url="/giftcards", body = body)
        .andExpect(status().isOk)
        .andReturnAs<GiftcardCreateResponse>()

    actual.shouldBe(expect)

훨씬 덜 수다스러워 지지 않았나요? 🤔

MockServer 와 MockServer Extension

mock-server

이미지 출처 – Mock server 공식 document

저희는 외부 API 등을 호출-응답하는 기능에 실제와 같이 응답하는 테스트 대역이 필요했고 이때 Mock Server라는 도구를 이용했습니다. 가볍고, 사용성도 꽤 좋아서 그럴듯한 테스트 대역을 만들어내기에 많은 도움이 되었어요.

MockServer 또한 MockMvc의 DSL과 같은 이유로 DSL 을 만들었습니다. 기존에 MockServer를 이용해 테스트를 작성하면 아래 비슷한 코드를 작성하는데요.

    MockServerClient("localhost", 1080).`when`(
                HttpRequest
                    .request()
                   .withMethod("GET")
                   .withPath("/purchase/{purchaseNumber}/owner")
                   .withHeader("Content-Type", "application/json")
                   .withPathParameter("purchaseNumber", 1)
             ).respond(
                HttpResponse.response()
                   .withStatusCode(200)
                   .withBody("{\"name\": \"홍길동\" , \"memberNumber\" : 10, \"purchaseCount\" : 4}")
             )

꽤나 수다스러움이 느껴지죠. 이를 해결하기 위해 만든 DSL을 사용하면 코드가 아래와 같아집니다.

    val response = PurchaseOwnerResponse(name = "홍길동", memberNumber=10, purchaseCount=4)
    mockServer.request {
        get("/purchase/{purchaseNumber}/owner") { 
            pathParameter { Parameter("purchaseNumber", 1) }
        }.response {
            status(HttpStatusCode.OK_200) { body { response } }
        }
    }

겉으로 보기엔 작은 차이라고 느낄 수도 있겠지만, 매번 반복되는 과정에서의 이런 작은 개선은 테스트가 많아질 수록 훨씬 더 체감이 됩니다.

추가적으로 테스트 대역 대상 Client 가 새로 추가될 때마다 매번 초기화 과정에 추가해야하는 등의 환경 구성에 더는 신경을 쓰고 싶지 않았습니다. 그래서 아래와 같이 Mock Server 관리 용도의 클래스를 만들어 운영 코드에서 사용하는 설정으로 신규 API가 추가되더라도 자동으로 초기화가 가능한 기능을 만들어 사용하고 있습니다.

    @Component
    class MockServerManager(
        private val clientProperties: ExampleClientProperties
    ) {

        private val logger = logger()

        private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

        private val mockServerApiHolder: EnumMap<MockApiType, MockServerApi> = EnumMap(MockApiType::class.java)

        fun getMockServerClient(apiType: MockApiType): ClientAndServer = mockServerApiHolder[apiType]?.client
            ?: throw NoSuchElementException("does not exists mock server client")

        suspend fun resetAllClients() = flow {
            mockServerApiHolder.forEach { emit(it.value) }
        }.collect { mockServerApi ->
            scope.launch { mockServerApi.client.reset() }
        }

         /* MockServer 사용을 위한 초기화. client properties 를 참조하여 MockServer listener 를 등록한다. */
        @PostConstruct
        private fun initMockServer() = ClientProperties::class.memberProperties.run {
            filter { it.returnType.javaType.typeName == ClientProperties.Info::class.java.typeName }
                .map {
                    val instance = it.get(clientProperties) as ClientProperties.Info
                    val port = instance.url!!.extractPort()

                    mockServerApiHolder[MockApiType.of(it.name)] =
                        MockServerApi(it.name, port, ClientAndServer.startClientAndServer(port))
                    logger.info { "${it.name} has been initialized. url: ${instance.url!!}" }
                }
        }

        @PreDestroy
        fun releaseAllClients() = mockServerApiHolder.forEach { it.value.client.stop() }
    }

또한 반복되는 스터빙 코드의 재사용성을 위해 클래스, 함수를 공통으로 만들어 사용하고 있기도 합니다.

    fun stubbingGetMember(memberNumber: String, response: MemberResponse) {
        val mockServer = this.getMockServerClient(MockApiType.BAEMIN_MEMBER_API)
        mockServer.request {
            get("/members/{memberNumber}") {
                pathParameter { Parameter("memberNumber", memberNumber) }
            }
        }.response {
            status(HttpStatusCode.OK_200) {
                body { response }
            }
        }
    }

    fun stubbingGetMemberProfile(memberNumber: String, response: MemberProfileResponse) {
        val mockServer = this.getMockServerClient(MockApiType.BAEMIN_MEMBER_API)
        mockServer.request {
            get("/members/{memberNumber}/profile") {
                pathParameter { Parameter("memberNumber", memberNumber) }
            }
        }.response {
            status(HttpStatusCode.OK_200) {
                body { response }
            }
        }
    }

이렇게 팀의 생산성에 도움이 되는 도구를 만드는 과정에서 꽤 괜찮은 기능이라 느껴지면 오픈소스의 정식 기능으로도 Merge 하는 재미를 느낄 수 있습니다. 이 DSL은 아니지만 저도 이번 프로젝트를 진행하면서 메이저 오픈 소스인 KotestCore Matcher 내 기능을 추가 하며 동기부여를 얻었습니다. 😆

Mock 을 편하게 만들 수 있게 해주는 Object Mother 클래스

테스트에 Mock을 사용하다 보면 중복되는 코드가 많아지는 것 같은 느낌을 받곤 합니다. 또 Mock 대상이 크고 복잡한 경우 매번 다양한 경우에 대해 작성하는 것도 부담스러워지기 시작하죠. 이럴 때 이것들을 구조화시켜 Mock 객체 생성을 도와주는 Factory 역할을 하는 Object Mother 사용을 고려해볼 수 있습니다.

선물하기 시스템에서는 충분히 성숙한 Mock 대상 클래스에 일반적으로 사용되는 상태를 정의하고 상태에 맞는 기본 값이 설정되어 필요 시 손쉽게 만들어 낼 수 있도록 Object Mother를 사용하고 있습니다.

    object GiftcardMother {
        fun createIssued(): Giftcard {
            // 발급된 상태의 상품권 생성...
        }

        fun createUsed(): Giftcard {
            // 사용된 상태의 상품권 생성...
        }

        fun createExpired(): Giftcard {
            // 만료된 상태의 상품권 생성...
        }

        fun createRefunded(): Giftcard {
            // 환불된 상태의 상품권 생성...
        }
    }

참고로 Object Mother는 이처럼 상태를 정의할 수 있을 만큼 성숙한 클래스에 대해서만 정의해서 사용하는 것이 좋은 것 같습니다. 확실히 테스트 작성에 편리함을 느끼면서도 유지보수해야 하는 대상이 하나 더 늘어나게 된 거니까요. 운영 코드와 결합된 상태라 운영 코드가 변경되는 경우 매번 영향을 받기도 하고요. (쉼없이 그어지는 IDE의 빨간줄) 개인적으로는 이러한 단점보다 이점이 더 크다고 판단될 때 사용하는 것을 추천드립니다.

~fixture 와 같은 이름으로 프레임워크 생태계에서 지원하는 도구를 사용하지 않고 왜 Object Mother을 직접 사용했는지 의문이 들 수 있을겁니다. 아직 선물하기 테스트 환경에서는 Object Mother가 더 가볍고 관리하기 편리하다는 판단을 했습니다. 코드 수정 시 IDE에서 직관적으로 확인된다는 장점도 있고요.

너무 느린 통합테스트 DB 초기화 성능 개선

각 테스트 Context마다 별도의 DB를 구성한다면 방법이 다를 수 있겠지만, 만약 저희처럼 한 개의 DB를 이용해 통합테스트에 사용하고 있다면 아래처럼 테스트 실행 시점마다 DB 초기화를 매번 해주어야 하는데요, 여기에 쓰이는 시간이 생각보다 오래 걸립니다.

     fun truncateTableAll() {
            entityManager.createNativeQuery("TRUNCATE TABLE tb1").executeUpdate()
            entityManager.createNativeQuery("TRUNCATE TABLE tb2").executeUpdate()
            entityManager.createNativeQuery("TRUNCATE TABLE tb3").executeUpdate()
            entityManager.createNativeQuery("TRUNCATE TABLE tb4").executeUpdate()
            entityManager.createNativeQuery("TRUNCATE TABLE tb5").executeUpdate()
            // ...
     }

기존 통합테스트 환경에서는 이렇게 초기화에 쓰이는 시간이 테스트마다 약 3~500ms 정도였습니다 . 하지만 저는 이게 너무나 느리게 느껴졌고, 동시에 테스트 케이스가 더 많아 질수록 느린 문제는 중복되어 테스트의 총 실행시간 또한 늦추고 있었습니다.

이 문제는 근본적으로 DB 자체의 쓰기 성능이나 데이터가 많아서 비롯된 문제라기보단 여러 쿼리로 인해 발생하는 I/O가 문제였습니다. 어떻게 I/O를 줄일 지 고민하던 찰나 지금은 많이 쓰이지 않는 프로시저가 생각나더라고요.

프로시저에 이 초기화 과정을 담아버리면 한두 번의 I/O만으로 처리할 수 있으니까요. 그래서 아래와 같은 프로시저를 생성/삭제/실행하는 Native Query를 코드레벨로 정의하고 사용했습니다.

    object TestDBTruncateNativeQuery {
        // 초기화 과정에서 프로시저를 DROP 하는 쿼리
        const val DROP_PROCEDURE_QUERY = "DROP PROCEDURE IF EXISTS `truncate_if_data_exist_multiple`;"

        /**
        * 초기화 과정에서 테이블을 TRUNCATE 하는 프로시저를 CREATE 하는 쿼리
        * table_names 
        */
        const val CREATE_PROCEDURE_QUERY = """
            CREATE PROCEDURE `truncate_if_data_exist_multiple`(IN table_names VARCHAR(500))
            BEGIN
                SET FOREIGN_KEY_CHECKS = 0;
                SET @tables = table_names;
                SET @delimiter = ',';
                SET @index = 1;
                SET @length = 0;
                SET @table_name = '';

                WHILE @index > 0 DO
                    SET @length = INSTR(@tables, @delimiter);
                    IF @length = 0 THEN
                        SET @length = LENGTH(@tables) + 1;
                        SET @index = 0;
                    END IF;

                    SET @table_name = TRIM(SUBSTR(@tables, 1, @length - 1));
                    SET @tables = SUBSTR(@tables, @length + 1);

                    IF EXISTS(SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @table_name AND TABLE_SCHEMA = DATABASE()) THEN
                        SET @check_data = CONCAT('SELECT EXISTS(SELECT 1 FROM `', @table_name, '` LIMIT 1) INTO @data_exists;');
                        PREPARE stmt FROM @check_data;
                        EXECUTE stmt;
                        DEALLOCATE PREPARE stmt;

                        IF @data_exists THEN
                            SET @truncate_table = CONCAT('TRUNCATE `', @table_name, '`;');
                            PREPARE stmt FROM @truncate_table;
                            EXECUTE stmt;
                            DEALLOCATE PREPARE stmt;
                        END IF;
                    END IF;
                END WHILE;
                SET FOREIGN_KEY_CHECKS = 1;
            END;
        """

        // 테스트마다 실행되는 테이블 TRUNCATE 프로시저 호출 쿼리
        const val MULTIPLE_TRUNCATE_EXEC_QUERY = """CALL truncate_if_data_exist_multiple(:tblNames)"""
    }

이 프로시저를 통합테스트의 생명주기와 결합된 테이블을 정리해주는 클래스에서 활용합니다.

    @Profile(value = ["integration-test"])
    @Component
    @Transactional
    class IntegrationTestTableCleaner(
        private val entityManager: EntityManager
    ) {
        private val log = logger()

        val specificTableNames = "table_name_1, table_name2, table_name3"

        // Application Context Load 시 1회 실행
        fun initTruncateProcedure() {
            entityManager.createNativeQuery(TestDBTruncateNativeQuery.DROP_PROCEDURE_QUERY).executeUpdate()
            entityManager.createNativeQuery(TestDBTruncateNativeQuery.CREATE_PROCEDURE_QUERY).executeUpdate()
        }

       // 각 테스트 실행 전 실행
        fun truncateAllIfExist() {
            log.info { "truncate tables step start. [$specificTableNames]" }
            entityManager.createNativeQuery(TestDBTruncateNativeQuery.MULTIPLE_TRUNCATE_EXEC_QUERY).setParameter("tblNames", specificTableNames).executeUpdate()
            log.info { "truncate tables step done." }
        }
    }

개선 전 (총 17.7초)

개선 후 (총 7.6초)

이로 테스트의 총 실행 시간이 기존 대비 30~40% 수준으로 개선되어 느린 통합테스트에 대한 불편함을 어느정도 해소 할 수 있었습니다.

CI/CD와의 통합

기존에도 CI/CD 과정 내에 자동화된 테스트를 수행하는 기능은 적용되어 있었습니다. 하지만 두 테스트가 구분되어 CI/CD 과정에 통합되어 있진 않았습니다. 그래서 유닛테스트와 통합테스트의 실행 기준을 더 명확하게 나누어 유닛테스트만 해도 합리적인 과정에는 유닛테스트만 독립적으로 진행할 수 있게 변경하고. 통합테스트까지 진행해야 하는 단계에서는 유닛테스트와 통합테스트를 함께 진행할 수 있게 하여 상대적으로 무거운 통합테스트에 대한 부담을 줄였습니다.

자주 일어나는 개발 브랜치에 대한 병합에는 유닛테스트만 수행돼도 충분하다고 판단했고, 운영코드에 직접 병합될때에는 무조건 통합테스트를 통과해야만 병합을 할 수 있다고 판단했습니다. 배포 시에 통합테스트는 당연하다고 생각했고요. 현재 선물하기 CI/CD에서는 아래와 같은 형태로 자동화된 테스트가 이루어지고 있습니다.
ci/cd

  1. 개발 중인 기능에 대한 브랜치에 하위 브랜치들이 병합될 때에는 유닛테스트만 수행해서 CI 비용을 줄입니다.
  2. 운영코드가 실제로 반영되어있는(master 또는 main) 브랜치에 대한 코드 병합이 수행될 때에는 통합테스트도 함께 수행됩니다.
  3. 운영 배포 시 컴파일,빌드를 진행하고 유닛테스트/통합테스트를 통과해야만 운영 코드 배포가 가능합니다.

개선을 마치고

No Silver Bullet(어떤 기술이나 방법론도 소프트웨어 개발의 본질적인 어려움을 완전히 해결해주는 "마법의 탄환")은 없다. 라는 말이 있죠. 아직 테스트에 대한 명쾌한 해답을 모두 찾지 못했습니다만 테스트 개선을 진행하면서 당장 주어진 우리 조직의 문화나 환경에서 사용하기 가장 합리적인 방법을 찾고 개선을 하는 것을 무엇보다 중요한 목표로 잡았고 실제로 개선 이후 테스트 환경이 아주 편해졌다는 것을 느끼고 있습니다.

테스트에 대한 개념들을 찾거나 공부하는 것보다 이를 현실에서 팀과 소통해서 합의하고 코드로써 표현하는 과정은 참 어려운 것 같습니다. 이 글에서는 그 과정 중 몇 가지를 추려 정리해보았는데요. 아직도 부족한 점이 많기에 앞으로도 서버 사이드 테스트를 더 잘할 수 있는 환경을 찾기 위한 여정은 계속 이어질 예정입니다.

테스트가 있지만 그다지 유용하지 않게 느껴진다거나, 테스트에 대한 정의가 힘들다거나 등등 각자의 개발환경에 합리적인 테스트 환경 정착을 위해 고민하는 분들에게 도움이 되었으면 합니다. 긴 글 읽어주셔서 감사합니다.


(부록) Monkey testing 지원 도구

부록이라 쓰고 홍보라 읽는다
입력 데이터가 여러가지 일수록 더 좋은 테스트에 가까워질 확률이 높기에.사용자가 무작위 입력을 제공하는 Monkey testing 을 사용하곤 하는데요. 이때 Kotlin 환경을 사용중이시라면 Monkey testing 을 보다 쉽게 할 수 있게 임의의 입력값과 함께 클래스, 함수를 타입에 맞게 무작위로 자동완성 해주는 기능도 제공하는 Intellij Plugin 인 Kotlin Auto Fill을 사용해 보세요. 사실 제가 만들었답니다 😁