최근 다양한 테스트 방식을 시도해보던 중, 재밌으면서도 아찔한 경험을 했습니다.
조금 생소할 수 있습니다.
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()
를 가능하면 사용하지 않는 것이 최선으로 보입니다. 이럴 때 해결할 수 있는 방법이 크게 두 가지가 있습니다.
테스트에 관한 강의나 글을 읽을 때면, 현재 시간을 파라미터로 받는 방식을 통해 위의 문제를 해결하고 있습니다. 이 방식대로 간단하게 문제점을 해결해보겠습니다.
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를 넘겨받음으로써, 원하는 시간을 설정할 수 있게 되었습니다. 이렇게 되면 언제 어떤 시간에 테스트를 수행하더라도 문제가 없습니다.
두 번째 방법은 현재 날짜를 받는 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하는 이유에 대해서 살펴보았으니, 이제 주의해야할 점에 대해 살펴보겠습니다.
사실 어쩌면 당연한 얘기일 수도 있습니다. 테스트 로직 중에 있는 다른 메서드에서 문제가 발생할 수 있습니다.
자동차 클래스에 보증기한 만료 여부를 판단하는 메서드를 테스트하겠습니다.
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을 피하는 선택을 하고자 합니다.