단위 테스트 가능한 코드를 작성하는 방법과 코드 품질을 개선하는 방법

단위 테스트 가능한 코드를 작성하는 방법과 코드 품질을 개선하는 방법

원문: Cem Tüver, "How to write unit testable code and how it improves code quality"

저는 단위 테스트가 구현되지 않은 안드로이드 라이브러리 프로젝트에서 작업하고 있습니다. 클래스가 5개밖에 없는 아주 작은 라이브러리입니다. 하지만 일부 클래스는 수학적 연산을 수행하여 다른 파일을 가지고 메타데이터 파일을 생성하는 등 중요한 역할을 하며 라이브러리의 출력에 직접적인 영향을 미칩니다. 한 줄 또는 하나의 정수 값을 변경하는 것만으로도 전체 기능이 동작하지 않을 수 있습니다. 저는 이렇게 생각했습니다.

새로운 기능을 추가하거나 버그를 수정할 때 기존 기능이 잘못될 위험을 줄이기 위해 라이브러리에 단위 테스트를 구현하고 싶었습니다. 처음에는 클래스 수가 제한되어 있어 "쉬운 일"처럼 보였지만 단위 테스트를 작성하기 시작하자 코드를 테스트할 수 없다는 것을 깨달았습니다.

이 글에서는 라이브러리 코드를 어떻게 리팩토링하여 테스트 가능하게 만들었고 이 과정에서 얻은 교훈에 대한 저의 경험을 이야기합니다.

전제 조건

이 글은 특정 플랫폼이나 프로그래밍 언어가 아니라 개념에 초점을 맞추고 있습니다. 하지만 몇 가지 예제를 제공하는 것은 항상 도움이 된다고 생각합니다. 그래서 코틀린 예제 코드를 추가했습니다. 가능한 단순하게 만들려고 노력했으며 코틀린에 익숙하든 익숙하지 않든 쉽게 이해할 수 있을 것이라고 꽤 확신합니다.

단위 테스트인 이유

일반적으로 테스트는 소프트웨어 회사가 고품질 소프트웨어를 제공하고 개발 비용을 절감하는데 도움이 됩니다. 특히 단위 테스트는 개발의 모든 단계에서 빠르고 안정적인 방법으로 쉽게 버그를 찾을 수 있도록 도와줍니다. 단위 테스트를 작성하는 가장 큰 이점은 개발자가 테스트 가능한 코드를 작성하도록 강제한다는 점입니다.

단위 테스트 가능한 코드

보통 단위 테스트를 작성하기 전까지는 코드가 얼마나 나쁜지 깨닫지 못합니다. 코드가 이해하기 쉽다고해도 코드의 의존성을 숨길 수 있기 때문에 순환 복잡도(cyclomatic complexity)를 간과할 수 있습니다. 그 결과로 코드가 얼마나 복잡한지 깨닫지 못합니다.

단위 테스트를 작성하는 것은 단일 책임과 의존성 역전을 갖도록 코드를 리팩토링하는 것에 초점을 맞추게 합니다. 이는 이미 다섯 가지 SOLID 원칙 중 두 가지 입니다. 그렇기 때문에 단위 테스트가 가능한 코드를 작성하면 테스트를 작성하는 것이 쉬워지고 코드베이스가 덜 복잡하고 이해하기 쉬우며 유지 관리가 더 쉬워집니다.

결정적 코드

단위 테스트를 작성하는 것은 매우 직관적이지만 비결정적 코드를 테스트하는 것은 비실용적입니다. 함수는 매번 동일한 입력에 대해 동일한 출력을 생성해야 단위 테스트에서 동작을 올바르게 테스트할 수 있습니다. 그렇지 않으면 단위 테스트가 없거나 비결정적인 단위 테스트를 작성하게 되는데 이는 정의상 테스트가 결정적이어야 하므로 이상적이지 않습니다.

아래 예제 코드를 살펴보겠습니다. "shuffleQueue" 함수는 하나의 인자, 즉 1차원 배열을 받아 셔플된 버전을 반환합니다.

fun shuffleQueue(queue: IntArray): IntArray {
    val shuffledQueue = queue.copyOf()
    shuffledQueue.shuffle()
    return shuffledQueue
}

테스트하는 것은 꽤 간단하겠죠? 그렇지 않습니다. 동일한 입력에 대해 실행할 때마다 다른 결과가 나오기 때문입니다. 하지만 이것은 예상되는 일입니다. 셔플링의 특성을 고려할 때 이 함수는 일종의 무작위 결과를 생성해야 합니다. 그렇다면 어떻게 테스트할 수 있을까요? 가장 먼저 떠오르는 것은 아래와 같이 출력과 입력이 다른지 확인하는 것입니다.

@Test
fun testShuffleQueue() {
    val queue = intArrayOf(3, 5, 9, 11)
    val response = tested.shuffleQueue(queue)        
    assertNotEqual(response, queue)
}

그러나 "IntArray.shuffle" 함수의 구현을 제어할 수 없으며 기술적으로 출력은 임의의 시간에 입력과 동일할 수 있습니다. 이은 테스트를 불안정하고 비결정적으로 만듭니다. 더 나쁜 것은 "shuffleQueue" 함수가 큐(queue)를 섞어서 정적 값만 반환하지 않는다는 점입니다. 이 함수를 백만 번 실행한 다음 결과가 다른지 확인하는 것과 같은 다양한 방법으로 단위 테스트를 시도할 수 있습니다. 해당 테스트 방식은 작동할 수도 있지만 테스트 실행에 너무 많은 시간이 소요될 수 있습니다. 더 나은 접근 방식 중 다른 하나는 코드의 비결정적 부분을 다른 컴포넌트에 위임하는 것인데 다음 섹션에서 설명하겠습니다.

단일 책임

단위 테스트의 과정은 매우 간단합니다. 먼저 전제 조건을 설정한 다음 테스트 단위를 호출하고 마지막으로 생성된 출력을 예상 출력과 비교하여 확인하면 됩니다. 간단해 보이지만 복잡한 함수는 복잡한 전제 조건과 입력을 요구하고 복잡한 결과를 생성합니다. 결과적으로 단위 테스트는 가능한 모든 사용 사례를 확인해야 하기 때문에 테스트할 단위의 복잡성이 증가함에 따라 단위 테스트의 복잡성도 기하급수적으로 증가합니다.

중첩된 블록 수, 코드 줄 수 또는 순환적 복잡성 등 함수의 복잡성을 결정하는 방법에는 여러 가지가 있습니다. 코드 줄 수가 적은 함수는 일반적으로 더 깔끔하고 이해하기 쉬운 코드베이스를 만듭니다. 그러나 여러 책임을 가지게 되면 작은 함수도 매우 복잡하게 만들 수도 있습니다. 더 나쁜 것은 하나의 기능을 업데이트하면 다른 기능이 손상될 수 있기 때문에 여러 책임이 있는 코드가 불안정해진다는 것입니다.

아래의 "logToFile" 함수에 대한 단위 테스트를 작성한다고 가정해 보겠습니다. 세 가지 기능을 테스트해야 합니다.

  • 로그 줄 생성

  • 올바른 로그 파일 열기

  • 파일에 로그 줄 쓰기

fun logToFile(log: String, vararg args: String) {
    val timeStamp = SimpleDateFormat("yyyy-MM-dd:HH").format(Date())
    val logLine = timeStamp + log.format(args)
    val logFileName = SimpleDateFormat("$timeStamp.log").format(Date())
    val logFile = File(logFileName)
    logFile.appendText(logLine)
}

입력에 따라 함수의 출력이 변경되는 동안 로그 함수에 대한 단위 테스트는 세 가지 다른 경우를 테스트해야 합니다.

  • 인자가 0인 경우

  • 인자가 하나만 있는 경우

  • 여러 인자가 포함하는 경우

그러나 각 경우에 대해 단위 테스트는 함수가 올바른 로그 행을 생성하는지, 올바른 로그 파일에 로그 행을 기록하는지, 로그 파일 끝에 로그 행을 추가하는지 확인해야 합니다. 로그 줄의 timeStamp에 분과 초를 포함하도록 업데이트하는 변경 요청을 받는다고 상상해보세요. 함수는 아래와 같이 변경됩니다.

fun logToFile(log: String, vararg args: String) {
    val timeStamp = SimpleDateFormat("yyyy-MM-dd:HH:mm:ss").format(Date())
    val logLine = timeStamp + log.format(args)
    val logFileName = SimpleDateFormat("$timeStamp.log").format(Date())
    val logFile = File(logFileName)
    logFile.appendText(logLine)
}

이제 단위 테스트가 실패하고 로그 줄의 새로운 포맷을 확인하도록 업데이트해야 합니다. 그러나 로그 함수를 테스트하려면 3개의 단위 테스트가 필요하므로 1개가 아니라 3개의 단위 테스트를 업데이트해야 하며 이는 시간과 비용이 소요됩니다. 이것은 더 복잡한 단위 테스트가 필요한 여러 기능을 가진 함수의 좋은 예입니다.

그럼에도 불구하고 이것은 가장 큰 문제는 아닙니다. 아시다시피 timeStamp를 변경하면 로그 파일 이름이 변경됩니다. 이제 이 함수는 시간당 로그 파일을 생성하는 대신 초당 로그 파일을 생성합니다. 기존 단위 테스트에서 파일 이름을 확인하지 않으면 소프트웨어에 악의적인 버그가 발생하게 됩니다. 확인하면 해당 부분도 업데이트해야 하므로 더 많은 시간과 비용이 소요됩니다.

이 로그 함수를 재구현하고 쉽게 테스트할 수 있도록 하는 더 좋은 방법은 로그 행 생성, 로그 파일 가져오기, 파일에 행 추가의 세 가지 기능을 서로 다른 구성 요소로 분산하는 것입니다. 따라서 함수는 아래와 같이 됩니다.

fun logToFile(log: String, vararg args: String) {
    val logLine = logLineGenerator.generate(log, args)
    val logFile = logFileStorage.getCurrentLogFile()
    fileWriter.appendText(logFile, logLine)
}

이제 단 하나의 단위 테스트만으로 함수의 새 구현을 테스트할 수 있습니다. 단위 테스트에서는 다음을 확인해야 합니다.

  • "logLineGenerator.generate" 함수에 인수를 전달하여 로그 라인을 생성

  • "logFileStorage.getCurrentLogFile"을 호출하여 현재 로그 파일을 가져오기

  • 현재 로그 파일과 생성된 로그 행을 가지고 "fileWriter.appendText"를 호출

로그 행의 timeStamp 업데이트 요구 사항을 다시 살펴볼 때 "logLineGenerator" 클래스와 해당 단위 테스트만 업데이트하면 충분할 것입니다. 실제 로그 함수를 업데이트할 필요는 없습니다. 또한 업데이트하는 동안 다른 기능이 손상될 위험도 없습니다.

의존성 역전

로그 함수를 떠올려 봅시다. 최신 버전은 로그 라인 생성 등 일부 기능을 다른 컴포넌트에 위임합니다. 로그 함수가 올바르게 호출하는지 확인하기만 하면 되기 때문에 이 함수를 테스트하는 것이 얼마나 쉬운지 이야기했습니다. 하지만 기술적인 문제가 있습니다. 로그 함수가 "logLineGenerator", "logFileStorage", "fileWriter"에 어떻게 액세스하는지 알 수 없습니다. 또한 이들에 대한 제어 권한이 없기 때문에 로그 함수가 다른 컴포넌트를 올바르게 호출하는지 여부를 쉽게 테스트할 수 없습니다.

class Logger {
    private val logLineGenerator = LogLineGenerator()
    private val logFileStorage = LogFileStorage()
    private val fileWriter = FileWriter()

    fun logToFile(log: String, vararg args: String) {
        val logLine = logLineGenerator.generate(log, args)
        val logFile = logFileStorage.getCurrentLogFile()
        fileWriter.appendText(logFile, logLine)
    }
 }

위의 클래스는 로그 클래스의 가능한 구현 중 하나입니다. 이 클래스는 서브컴포넌트의 인스턴스를 생성하고 로그 함수가 이를 사용합니다. 그러나 이 함수를 테스트하려면 이러한 서브컴포넌트와 상호작용하는 방법이 없습니다. 다행히도 인스턴스 생성을 다른 곳으로 옮기고 그 인스턴스를 로그 클래스와 공유함으로써 로그 클래스와 서브컴포넌트 사이의 강한 연결을 끊을 수 있습니다.

class Logger {
    private val logLineGenerator: ILogLineGenerator
    private val logFileStorage: ILogFileStorage
    private val fileWriter: IFileWriter

    constructor(logLineGenerator: ILogLineGenerator,
            logFileStorage: ILogFileStorage,
            fileWriter: IFileWriter) {
        this.logLineGenerator = logLineGenerator
        this.logFileStorage = logFileStorage
        this.fileWriter = fileWriter
    }

    fun logToFile(log: String, vararg args: String) {
        val logLine = logLineGenerator.generate(log, args)
        val logFile = logFileStorage.getCurrentLogFile()
        fileWriter.appendText(logFile, logLine)
    }
 }

새로운 버전의 로그 클래스를 사용하면 단위 테스트를 실행하는 동안 서브컴포넌트의 구현을 수정하고 손쉽게 검사할 수 있습니다. 미리 정의된 출력을 생성하는 테스트 구현으로 서브컴포넌트를 모킹하거나 대체할 수 있습니다. 그러나 일부 서브컴포넌트는 상속을 위해 닫혀 있을 수 있으므로 테스트 구현 클래스를 대체하도록 만들 수 없습니다. 의존성 역전의 도움으로 이 문제를 극복할 수 있습니다. 의존성 역전은 모듈이 구현에 의존해서는 안되고 추상화에 의존해야 한다는 SOLID 원칙 중 하나입니다. 추상화를 사용하면 모든 서브컴포넌트에 대해 원하는 구현을 생성하고 단위 테스트에 해당 구현을 사용할 수 있습니다.

class Logger {
    private val logLineGenerator: ILogLineGenerator
    private val logFileStorage: ILogFileStorage
    private val fileWriter: IFileWriter

    constructor(logLineGenerator: ILogLineGenerator,
            logFileStorage: ILogFileStorage,
            fileWriter: IFileWriter) {
        this.logLineGenerator = logLineGenerator
        this.logFileStorage = logFileStorage
        this.fileWriter = fileWriter
    }

    fun logToFile(log: String, vararg args: String) {
        val logLine = logLineGenerator.generate(log, args)
        val logFile = logFileStorage.getCurrentLogFile()
        fileWriter.appendText(logFile, logLine)
    }
 }

이제 로그 클래스에 대한 테스트가 모두 완료되었습니다. 서브컴포넌트의 테스트 구현을 주입하고 로그 함수가 올바른 매개 변수로 호출하는지 확인합니다.

class LoggerTest {
    private lateinit var logLineGenerator: ILogLineGenerator
    private lateinit var logFileStorage: ILogFileStorage
    private lateinit var fileWriter: IFileWriter
    private lateinit var logger: Logger

    @Before
    fun setup() {
        logLineGenerator = TestLogLineGenerator()
        logFileStorage = TestLogFileStorage()
        fileWriter = TestFileWriter()
        logger = Logger(logLineGenerator, logFileStorage, fileWriter)
    }

    @Test
    fun testLogToFile() {
        val log = "some log %s %s"
        val logArgs = arrayOf("arg1", "arg2")

        logger.logToFile(log, logArgs)

        verify(logLineGenerator.generate(log, logArgs))
        verify(logFileStorage.getCurrentLogFile())
        verify(fileWriter.appendText(logLineGenerator.peekTestLogLine,         logFileStorage.peekTestLogFile)
    }
 }

새로운 로그 함수는 서브컴포넌트를 제공해야 하므로 코드에서 이를 편리하게 수행할 수 있는 패턴이 필요합니다. 일부 소프트웨어 패턴, 프레임워크 및 라이브러리에서는 의존성 역전을 적용하기 위해 사용하기 쉬운 API를 제공합니다. 예를 들어 의존성 주입 및 서비스 로케이터 패턴을 찾아볼 수 있습니다.

요약

코드를 단위 테스트할 수 있게 만들면 더 이해하기 쉽고 더 유연하고 보다 유지 관리하기 쉽다는 것을 알 수 있듯이 단위 테스트의 한계는 더 나은 코드를 작성하도록 강요합니다. 그렇기 때문에 코딩을 하거나 테스트 중심 개발과 같은 테스트 중심 프로세스를 따르는 동안 항상 테스트 가능성에 대해 생각하고 "이것을 어떻게 테스트할 수 있을까?"라는 질문을 하면 코드 복잡성을 명확하게 파악하고 줄이는 데 도움이 된다고 생각합니다.