깨알 개념/기타

Unit Testing - Test double

interfacer_han 2024. 6. 30. 08:33

#1 Test double

fun main() {
    // Dependencies 인스턴스 선언
    val originalDependency = OriginalDependency()
    val testDouble = TestDouble()

    // Dependents 인스턴스 선언
    val dependent1 = Dependent(originalDependency)
    val dependent2 = Dependent(testDouble)

    // (n값으로 10이 들어간다고 가정 후) Unit test 진행
    if (dependent1.doMathOperation(10) == dependent2.doMathOperation(10)) {
        println("테스트 성공")
    } else {
        println("테스트 실패")
    }
}

class Dependent(private val mathCalculator: OriginalDependency) {
    fun doMathOperation(n: Int): Int {
        return mathCalculator.sumFromOneToN(n)
    }
}

open class OriginalDependency {
    open fun sumFromOneToN(n: Int): Int {
        var sum = 0
        for (i: Int in 1..n) {
            sum += i
        }
        return sum
    }
}

class TestDouble : OriginalDependency() { // Dependency class for testing.
    override fun sumFromOneToN(n: Int): Int {
        return 55 // n이 10일 때의 결과만을 반환하지만, 애초에 n == 10인 경우를 가정하고 테스트하므로 문제되지 않는다.
    }
}

어떤 프로젝트든 Dependent - Dependency 관계로 이루어져 있을것이다. 생성자 주입으로 의존성 주입이 구현된 위 코드의 Dependent 클래스처럼 말이다. Unit Test의 많은 방식 중에는 Dependent가 요구하는 원래의 Dependency를 사용한 결과와 테스트용 Dependency를 사용한 결과를 비교하여, 원래의 Denpendent 또는 원래의 Dependency를 검증하는 방식이 존재한다. 이때 이 모의 종속성을 제공하는 테스트용 Dependency를 Test doubles이라 부른다 (참고로, 위 코드에서 사용한 Test double의 종류는 #3-3에 있는 Stub이다).
 

#2 Test doubles의 종류

#2-1 개요

class Math(private val mathCalculator: Calculation) {
    fun doMathOperation(radius: Double): Double {
        return mathCalculator.circleArea(5.0)
    }
}

open class Calculation {

    companion object {
        const val PI = 3.141592
    }

    open fun circleArea(radius: Double): Double {
        println("로그: circleArea(${radius}) 수행")
        return piMultiply(square(radius))
    }

    open fun square(operand: Double): Double {
        println("로그: ${operand}의 제곱 연산 수행")
        return operand * operand
    }

    open fun piMultiply(operand: Double): Double {
        println("로그: ${operand} 곱하기 파이 연산 수행")
        return operand * PI
    }
}

프로젝트에 이러한 구조가 있다고 치자. 내가 Unit test할 클래스는 Math 클래스와 Calculation 클래스다. #2-2 ~ #2-4에서 이 클래스에 대한 Test Double을 종류별로 만들어 테스트를 진행해 보겠다.
 

#2-2 Fake (실제 구현의 단순화)

// Fake 객체를 이용한 Unit test
fun main() {
    // Dependency 인스턴스 선언
    val testDouble = FakeCalculation()

    // Dependent 인스턴스 선언
    val dependent = Math(testDouble)

    // (radius값으로 5.0이 들어간다고 가정 후) 값이 그럭저럭한 근사값으로라도 에러없이 출력되는지만 확인
    val result = dependent.doMathOperation(5.0)
    println("Fake 출력 결과 확인: ${result}")
}

class FakeCalculation : Calculation() {
    override fun circleArea(radius: Double): Double {
        return 5 * 5 * 3.0
    }
}

 주로 Dependency에 대한 검증이 딱히 필요없을 때 쓴다. 필요한 것은 그럭저럭 나오는기만 하면 되는 출력값이다. 원래 클래스인 Calculation에선 circleArea()함수가 square() 및 piMultiply() 함수를 호출하는 데 이 절차도 지키지 않는다. 절차뿐만 아니라 심지어 출력값조차 정확할 필요가 없다. Fake Denpendency로부터 적당히 근사한 출력값이 나온다는 것만 확인하고, Dependent의 검증에 집중하면 된다.
 
예를 들어, 어떤 Repository를 종속성으로 가지는 ViewModel이 있다고 해보자. 그리고 해당 프로젝트를 짠 프로그래머는 ViewModel을 위주로 Unit test하려고 한다. 이 때, Repository를 Fake형식의 Test double로 둘만하다. Fake Repository에서는 원래 Repository에서와는 달리 Room이나 Retrofit을 거친다는 절차도 따르지 않고, 해당 클래스의 메소드가 반환하는 객체도 대충 하드 코딩해버린다. 어차피 목적은 Dependent인 ViewModel의 검증이기 때문이다.
 

#2-3 Stub (정확한 결과를 반환하는 Fake)

// Stub 객체를 이용한 Unit test
fun main() {
    // Dependencies 인스턴스 선언
    val originalDependency = Calculation()
    val testDouble = StubCalculation()

    // Dependents 인스턴스 선언
    val dependent1 = Math(originalDependency)
    val dependent2 = Math(testDouble)

    // (radius값으로 5.0이 들어간다고 가정 후) Unit test 진행
    if (dependent1.doMathOperation(5.0) == dependent2.doMathOperation(5.0)) {
        println("테스트 성공")
    } else {
        println("테스트 실패")
    }
}

class StubCalculation : Calculation() {
    override fun circleArea(radius: Double): Double {
        return 5 * 5 * 3.141592
    }
}

class Math(private val mathCalculator: Calculation) {
    fun doMathOperation(x: Double): Double {
        return mathCalculator.circleArea(x)
    }
}

Fake는 출력 그 자체에 의미를 둔다면, Stub는 출력값도 검증 대상으로 본다. 즉, 출력값이 정확해야 한다는 것이다. 위 코드의 StubCalculation()은 #3-2의 FakeCalculation과는 달리 파이값을 부모 클래스인 Calculation()와 동일한 값으로 두고 있다. 물론, radius값은 여전히 하드 코딩되어있지만 말이다. Stub에서 중요한 것은 Input으로 뭔가를 넣었을 때, 그 Output이 Original 클래스의 Output과 동일해야 한다는 점이다.
 

#2-4 Mock (상호작용을 검증하는 Stub)

// Mock 객체를 이용한 Unit test
fun main() {
    // Dependencies 인스턴스 선언
    val originalDependency = Calculation()
    val testDouble = MockCalculation()

    // Dependents 인스턴스 선언
    val dependent1 = Math(originalDependency)
    val dependent2 = Math(testDouble)

    // (radius값으로 5.0이 들어간다고 가정 후) Unit test 진행
    if (dependent1.doMathOperation(5.0) == dependent2.doMathOperation(5.0)) {
        println("출력값은 정확함")
        println("후속 작업 필요: 메소드 호출 횟수, 순서, 방식 등 Original과 같은지 확인")
    } else {
        println("테스트 실패")
    }
}

class MockCalculation : Calculation() {

    override fun circleArea(radius: Double): Double {
        println("로그: circleArea(5.0) 수행")
        return piMultiply(square(5.0))
    }

    override fun square(operand: Double): Double {
        println("로그: 5.0의 제곱 연산 수행")
        return 25.0
    }

    override fun piMultiply(operand: Double): Double {
        println("로그: 25.0 곱하기 파이 연산 수행")
        return 78.5398
    }
}

이젠 상호작용까지 검증한다. Input의 값은 여전히 하드 코딩되지만, 그 값이 처리되는 방식(메소드 호출 횟수, 순서, 방식 등)이 Original과 똑같아야 한다. Output도 당연히 Original의 Output과 같아야 하고 말이다. 하지만, 모든 Mock을 위에 있는 코드처럼 사용자 정의 클래스로 만들어 사용하긴 쉽지 않다. 특히 안드로이드에서는 안드로이드 프레임워크까지 고려를 해야하기 때문에 사실상 불가능하다. 하지만 오히려 이 단점에 의해 수 많은 Mock 생성 라이브러리들이 출범했다.
 

#3 요약

Test double은 테스트용 Dependency다.
 

#4 전체 소스 코드

testDoubleType.zip
0.00MB

Test double의 3가지 유형