4 분 소요

테스트를 작성하는건 작성하지 않는 것보다 장점이 더 많은 것 같다. 그치만 코드 수정을 할 때마다 돌리게 될 많은 테스트들을
많게는 하루에도 여러번 실행하게 되는데, 코드의 안정성을 검증하는 용도도 중요하지만 자주 실행해도 부담이 되지 않게끔 빠르게
끝나도록 잘 작성하는 법을 아는 것도 중요하다.

테스트를 느리게 하는 요인이 뭘까?
개인적으로 스프링 애플리케이션 컨텍스트 로딩이 컸고, 프로젝트 규모가 큰 경우 Mockito도 문제가 될 수 있다고 한다.
토스 SLASH 21 - 테스트 커버리지 100%에서도 큰 원인으로 꼽고 있는 점들이었다.

  • MockK에서 Byte Buddy, 리플렉션 초기화 작업이 오래 걸린다고 언급하고 있음

스프링 애플리케이션 컨텍스트 로딩을 1번으로 줄이는 방법을 통해서 영향을 줄일 수도 있겠지만, 반드시 컨텍스트 로딩이 필요하지
않은 상황이라면 굳이 적용해서 테스트 시간을 늘릴 이유는 없다. 예를 들면 Repository에 작성한 쿼리를 테스트하고 싶은 이유가
DB에 데이터가 잘 들어갔는지/잘 가져오는지를 확인하기 위해서일 수 있다. 이때는 DB를 통하지 않고 목 오브젝트를 구현해서 메모리
위에서 처리한다면 더 빠른 시간에 테스트를 완료할 수 있을 것이다.

OCP(Open-Closed Principle)

OCP는 개방폐쇄 원칙을 말한다. 확장에는 열려있고, 수정에는 닫혀있어야 한다.
객체 지향 프로그래밍을 한다면 객체는 재사용성이 뛰어나게끔 설계해야 할거고, 객체 간 관계가 중요할 것이다.

예를 들어서, 음료수가 있다고 해보자!
당장 떠오르는 음료수가 되게 많을텐데(콜라, 사이다, 티, 탄산수 등), 우리는 이런 음료수의 구체적인 대상이 관심을 갖지말고
음료수가 제공하는 기능에 집중해보자. 탄산이 들어갔다면 청량감을, 설탕이 들어갔다면 달콤함을, 그리고 갈증을 해소할 수 있다.
이런 기능들을 제공하는 음료수에 집중하고 실제로 마시는 음료수(구체 클래스)는 무엇이 들어오든 상관없다고 할 때, 확장에
열려있다고 할 수 있다!

음료수의 행위들이 반영된 로직이 정말정말 복잡하고 거대하다고 해보자. 마시려는 음료수가 달라질 때마다 이 로직이 수정돼야 한다면
정말정말 끔찍할 거다. 그치만 다행히도 음료수의 행위에 집중했으므로 어떤 음료수가 들어와도 이 로직을 수정하지 않아도 된다는
특징이 있다. 이걸 수정에 닫혀있다고 한다.

테스트 짜는데 이런게 왜 필요하지? 라고 생각할 수 있는데,
기존 로직을 변경하지 않고(=수정에 닫혀있음) 테스트 코드를 짜려고(=확장에 열려있음) 하기 위함이다!

위에서 설명한 특징들을 잘 지키려면 기능만을 제공하는 음료수 interface가 필요하다!
헷갈린다면 일단 interface가 필요하다는 것만 알고 넘어가자. 예시 코드를 본다면 이해할 수 있을 것이다.

구체 클래스 직접 사용하지 말고, interface 사용하기

테스트를 작성하다보면 다른 레이어의 함수 응답을 원하는 값을 내보내도록 설정해줄 필요가 있다.
하지만 비즈니스 로직을 interface 없이 클래스 간에 직접 참조(강한 결합)가 돼있는 상태라면 Mockito 같은 라이브러리를 사용
하지 않았을 때 구현은 어려울 것이다. Mockito를 사용한다면 내부 로직을 타지 않고 원하는 값을 바로 내보내도록 강제하는 거라서
유의해서 작성해야 하고, 관리 포인트가 추가되기도 한다. 게다가 테스트 코드가 많아지면 위에서 말한 것처럼 전체 로직 수행 시간이
길어지는 주요 원인이 된다.

또한 DB(h2, mysql 등)를 띄워야만 수행할 수 있는 테스트는 DB에 의존하는 테스트가 된다.
(💡 DB에 의존하는게 나쁜게 절대 아니다! 꼭 필요한 경우라면 의존하는 로직을 짜는게 맞다.)

아무튼 OCP 원칙을 지키기 위해 레이어 간 참조 대상은 구체 클래스가 아닌 interface가 된다.
이렇게 되면 런타임에 구체 클래스를 참조하는데, 테스트 환경에선 테스트용 구체 클래스를 참조하게 만들면 된다.
아래 예시를 참고해보자.

예제

위에서 음료수로 예를 들었으니 여기서도 같은 예시로 작성해보겠다.
아래는 BeverageRepository를 참조하는 서비스 BeverageService 코드다.

class BeverageService(
    private val beverageRepository: BeverageRepository,
) {
    fun create(name: String): Beverage {
        return beverageRepository.save(Beverage.from(name))
    }

    fun getByName(name: String): Beverage {
        return beverageRepository.getByName(name)
    }
}


Service에서는 Repository 클래스가 아닌 Repository interface를 상속한다!
JPA를 사용한다면 이 interface에 JpaRepository를 구현하려는 욕망이 있을텐데.. 그러면 있다가 테스트용 구현 클래스를
만들 때 JpaRepository에서 제공하는 모든 함수를 구현해여한다는 불편함을 감수해야 한다😥

interface BeverageRepository {
    fun save(beverage: Beverage): Beverage
    fun getByName(name: String): Beverage
}
  • 모든 함수를 구현하지 않아도 되도록 테스트용 JpaRepository를 만들어봤다! 2편 참조!

테스트 패키지에서는 BeverageRepository를 구현한 테스트용 클래스 TestBeverageRepository를 구현한다.
테스트에서는 이렇게 테스트용 구현 클래스(=목 오브젝트)를 구현해서 사용한다!

class TestBeverageRepository : BeverageRepository {

    private val autoGeneratedId = AtomicLong(0)
    private val beverages = ArrayList<Beverage>()
    
    override fun save(beverage: Beverage): Beverage {
        return Beverage(
            id = autoGeneratedId.incrementAndGet(),
            name = beverage.name,
        ).also { beverages.add(it) }
    }

    override fun getByName(name: String): Beverage {
        return beverages.firstOrNull { it.name == name }
            ?: throw IllegalArgumentException("해당 음료가 존재하지 않습니다.")
    }
}


테스트 코드에서는 BeverageRepository 초기화할 때 TestBeverageRepository를 사용하면 된다.

class BeverageServiceTest {

    private lateinit var beverageService: BeverageService

    @BeforeEach
    fun setUp() {
        beverageService = BeverageService(TestBeverageRepository()) // 직접 주입시켜줌
    }

    @Test
    fun `음료를 생성할  있다`() {
        // given
        val name = "아메리카노"

        // when
        val beverage = beverageService.create(name)

        // then
        assertThat(beverage.id).isNotNull()
        assertThat(beverage.name).isEqualTo(name)
    }

    @Test
    fun `음료를 이름으로 조회할  있다`() {
        // given
        val name = "카페라떼"
        val beverage = beverageService.create(name)

        // when
        val result = beverageService.getByName(beverage.name)

        // then
        assertThat(result.id).isEqualTo(beverage.id)
        assertThat(result.name).isEqualTo(beverage.name)
    }
}
  • TestBeverageRepository를 BeverageService가 의존하는 파라미터에 직접 넣어줌으로써 DI 없이도 테스트할 수 있게 됨 → 스프링 애플리케이션 컨텍스트 로딩을 할 필요가 없어짐
  • 물론 편의를 위해 profile을 분리해서 테스트 환경에서는 TestBeverageRepository가 주입되도록 정의할 수 있음 의존성 관련 로직이 많아질 수록 관리 포인트가 늘어나기 때문에 더 좋은 선택일 수 있음

단점도 있음!

그치만 장점만 존재하는건 아니다. 어쩌면 테스트를 위한 로직을 작성하는 것이기 때문에 시간이 더 들기도 하고, interface를
참조하는 로직이 되는거기 때문에 내부 구현을 찾으러 들어갈 때 뎁스가 1단계 올라가므로 이해하는데 시간이 더 걸릴 수 있다.

로직 규모가 작거나, 테스트 코드를 많이 짤 생각이 없다면 오히려 Mockito를 사용하는게 테스트 작성 시간을 아낄 수 있고
로직 이해 측면의 복잡도도 낮출 수 있다. 상황에 맞춰 선택하는게 좋을거 같다!

References

카테고리:

업데이트:

댓글남기기