[Spring/Test] Java Util을 Mocking 할 때 주의점 (feat. Mocking을 안 하는 게 낫지 않을까?)

민찬기·2023년 11월 2일
0

Test

목록 보기
2/2
post-thumbnail

최근 다양한 테스트 방식을 시도해보던 중, 재밌으면서도 아찔한 경험을 했습니다.

Java Util을 Mocking?

조금 생소할 수 있습니다.

Java Util을 Mocking 하는 건 어떤 이유에서 하는 걸까요?

시간에 대한 테스트

약간은 억지스러우면서도 간단한 예시를 들어보겠습니다.

class Car(
    brand: String
) {
    val brand = brand
    val warrantyAt = now.plusYears(2)
}

자동차가 구매일로부터 약 2년의 보증기한이 주어진다고 해보겠습니다. 그리고 보증기한이 만료됐는 지에 대한 응답을 돌려주는 메서드가 있습니다.

간단하게 보증기한이 제대로 설정되는 지에 대한 테스트부터 해보겠습니다.

class CarTest : DescribeSpec({
    describe("자동차 테스트") {
        context("자동차를 구매하면") {
            it("2년의 보증기한이 주어진다.") {
                val now = LocalDate.now()
                val car = Car("BMW")

                car.warrantyAt shouldBe now.plusYears(2)
            }
        }
    }
}

위 테스트의 문제점

그런데 위의 테스트는 문제가 좀 있습니다.

테스트 코드에서 호출하는 LocalDate.now()의 시점이 2023년 11월 2일 23시 59분 59초 999...라면, Car 인스턴스를 생성하면서 LocalDate.now()의 값이 2023년 11월 3일로 나오면서 테스트는 실패하게 될 겁니다.

테스트는 어떤 환경에서도 성공해야 합니다. 즉, 환경에 독립적이어야 하므로, 환경에 따라 때때로 실패할 수 있는 테스트는 문제가 있습니다. 현재 시간에 의존하는 LocalDate.now()를 가능하면 사용하지 않는 것이 최선으로 보입니다. 이럴 때 해결할 수 있는 방법이 크게 두 가지가 있습니다.

1. 시간을 파라미터로 받기

테스트에 관한 강의나 글을 읽을 때면, 현재 시간을 파라미터로 받는 방식을 통해 위의 문제를 해결하고 있습니다. 이 방식대로 간단하게 문제점을 해결해보겠습니다.

class Car(
    brand: String,
    now: LocalDate
) {
    val brand = brand
    val warrantyAt = now.plusYears(2)
}
class CarTest : DescribeSpec({
    describe("자동차 테스트") {
        context("자동차를 구매하면") {
            it("2년의 보증기한이 주어진다.") {
                val now = LocalDate.of(2023, 5, 2)
                val car = Car("BMW", now)

                car.warrantyAt shouldBe now.plusYears(2)
            }
        }

생성자의 파라미터로 now를 넘겨받음으로써, 원하는 시간을 설정할 수 있게 되었습니다. 이렇게 되면 언제 어떤 시간에 테스트를 수행하더라도 문제가 없습니다.

2. 시간을 Mocking 하기

두 번째 방법은 현재 날짜를 받는 LocalDate.now()를 Mocking 하는 것입니다. 긴말하지 않고 바로 코드로 확인해보겠습니다.

class Car(
    brand: String
) {
    val brand = brand
    val warrantyAt = LocalDate.now().plusYears(2)
}
class CarTest : DescribeSpec({
    describe("자동차 테스트") {
        context("자동차를 구매하면") {
            it("2년의 보증기한이 주어진다.") {
                val now = LocalDate.of(2023, 5, 2)
                val plusYears = now.plusYears(2)

                mockkStatic(LocalDate::class)
                every { LocalDate.now() } returns now
                every { LocalDate.now().plusYears(2) } returns plusYears

                val car = Car("BMW")
                car.warrantyAt shouldBe plusYears

                unmockkStatic(LocalDate::class)
            }
        }
    }
}

Car 클래스에서 LocalDate.now().plusYears() 메서드를 사용하고 있으므로, 이에 대한 Mocking도 진행해야 합니다. 따라서 적은 코드양에도 불구하고 과하다고 느껴질만큼 Mocking이 필요하긴 합니다.

그럼 뭘 주의해야?

Java Util을 Mocking하는 이유에 대해서 살펴보았으니, 이제 주의해야할 점에 대해 살펴보겠습니다.

원치 않는 Mocking

사실 어쩌면 당연한 얘기일 수도 있습니다. 테스트 로직 중에 있는 다른 메서드에서 문제가 발생할 수 있습니다.

자동차 클래스에 보증기한 만료 여부를 판단하는 메서드를 테스트하겠습니다.

class Car(
    brand: String
) {

    val brand = brand
    val warrantyAt = LocalDate.now().plusYears(2)

    fun isExpiredWarranty(): Boolean {
        return this.warrantyAt < LocalDate.now()
    }
}

isExpiredWarranty() 로직에 따르면 현재 날짜가 보증기한 날짜 이후인 지를 판단합니다. 이 메서드 테스트가 성공하도록 만들기 위해 현재 날짜를 2019년 5월 2일로 Mocking 하도록 하겠습니다.

class CarTest : DescribeSpec({
    describe("자동차 테스트") {
        context("자동차를 3년 전에 구매했다면") {
            it("보증 기한이 만료된다.") {
                val now = LocalDate.of(2019, 5, 2)
                val plusYears = now.plusYears(2)

                mockkStatic(LocalDate::class)
                every { LocalDate.now() } returns now
                every { LocalDate.now().plusYears(2) } returns plusYears

                val car = Car("BMW")
                car.isExpiredWarranty().shouldBeTrue()

                unmockkStatic(LocalDate::class)
            }
        }
    }
})

하지만 위의 테스트는 실패합니다.

isExpiredWarranty() 메서드 내에서 호출하는 LocalDate.now() 역시 Mocking의 영향을 받습니다. 따라서, warrantyAt은 2021년 5월 2일이고, 2019년 5월 2일과 비교를 하게 됩니다.

이럴 수 있을까?

사실 위의 예시는 조금은 억지스럽습니다. 뻔히 자동차 테스트 내부에 LocalDate.now()를 사용하고 있고, 이 역시도 Mocking 될 것이라는 생각을 할 수 있기 때문이죠.

하지만, 조금은 유쾌하지 못한 상황이 있을 수 있습니다.

@Entity
class User {
    
    @Id
    val id = UUID.randomUUID().toString()
}

유저의 ID를 어플리케이션에서 랜덤으로 만들어 넣고 있습니다. 테스트를 위해 두 명의 유저가 필요한데, UUID.randomUUID()를 Mocking하고 Repository.save()를 하게 되면 id가 중복되기 때문에 테스트가 실패할 수 밖에 없는 상황이 됩니다.

에러 메세지도 친절하게 나오지만은 않기 때문에 "왜?????" 하게 되는 상황에 직면하게 됩니다. (제가 그랬기 때문에..)

주의할 상황을 안 만드는 건 어떨까?

위에서 살펴보았듯이, Java Util을 Mocking 하는 것은 꽤나 신경 쓸 일이 많은 부분입니다. 즉, 많은 주의가 필요한 부분이고, 이를 놓치면 꽤나 시간을 쓰게 될 부분입니다.

주의해야 할 점만 있는 것은 아닙니다. Mocking 자체가 꽤나 비용이 들어가는 작업입니다. 단, 몇 줄의 코드라고 볼 수도 있지만, 매 테스트에서 Mocking을 반복하는 것도 꽤나 귀찮은 작업이 됩니다. 또한, Static을 Mockking하는 경우에 unmockk을 해주지 않으면 다른 테스트에 영향을 줄 수 있다는 위험부담도 있습니다.

하지만, 모킹의 대안인 파라미터를 이용한 테스트가 항상 좋은 것만은 아닙니다. 크게 두 가지의 문제가 있을 수 있는데, 첫번째는 누군가의 오용 가능성, 두번째는 어색함입니다.

fun isWarrantyExpired(now: LocalDate)으로 메서드를 작성했을 때, 누군가 LocalDate.now()가 아닌 다른 값을 넣는 방식으로 오용할 수 있다는 문제점이 있습니다.

다음은 어색함입니다. fun isWarrantyExpired(now: LocalDate)을 보면, 함수 이름만 보았을 때 굳이 now를 파라미터로 넘겨받을 이유가 없어보입니다. warranyAt을 파라미터로 넘겨받는 거면 몰라도요.

위와 같은 이유들로 파라미터로 넘겨받는 방식의 메서드 작성을 반대할 수도 있지만, 두 가지를 비교한다면 저는 파라미터 방식으로 메서드를 작성해 Mocking을 피하는 선택을 하고자 합니다.

profile
https://github.com/devmizz

0개의 댓글