HCN DEV

Read in English

UnitTest 개념 소개

UnitTest 개념 소개 feature image

1. What is UnitTest?

소프트웨어 개발에서 테스트라는 것은 실제 기능을 배포하기 이전에 해당 기능이 요구사항에 맞게 동작하는지를 확인하는 다양한 행위입니다. 그 중에서 UnitTest는 어떤 Unit에 대한 테스트를 수행하는 테스트입니다. 여기서 Unit이 무엇인가에 대한 것은 언어마다, 사람마다 다르다고 얘기합니다. 객체 지향 관점에서 Unit은 하나의 class가 될 수 있고, 절차 지향 관점에서는 하나의 function이 Unit이 될 수 있습니다. 아니면 어떤 class의 집합, funtion의 집합, 혹은 class와 function의 혼용된 집합도 Unit으로 정의될 수도 있습니다. Unit을 어떻게 정의하든지 UnitTest에 대한 특징에는 아래와 같은 것들이 있습니다.

  1. Unit Test는 low level에 집중하고, 전체 소프 트웨어의 작은 부분을 담당한다.
  2. Unit Test는 개발자가 일반적인 테스팅 툴을 통해 작성한다.
  3. 다른 테스트에 비해 빠르다.
  4. Unit을 무엇으로 정의할 것인가에 대해 사람마다 생각하는 것이 다르다.
    • 객체 지향 - 하나의 class
    • 절차지향 - 하나의 function
    • 때때로 여러 개의 class, function 집합이 하나의 unit이 되기도 한다.

Solitary vs Sociable

어떤 UnitTest를 수행할 때 경우에 따라서 해당 테스트가 실제 객체를 사용할 경우 다른 테스트의 결과에 영향을 주는 경우가 있습니다. 예를 들어서 두 개의 테스트가 공유된 외부 리소스(데이터베이스, 파일 시스템 등)를 사용하는 경우 앞서서 수행된 테스트가 리소스 상태를 변경하여 뒤에 수행되는 테스트의 결과에 영향을 줄 수 있습니다.

이런 상황에서 테스트 목적으로 정의한 객체(Test Double)을 사용하면 문제를 해결할 수 있는데, 이런 형태의 테스트를 Solitary Test라고 합니다. Test Double을 사용하면 테스트 수행시 외부 리소스(데이터베이스, 파일 시스템 등) 접근을 제거할 수 있는 장점이 있습니다. 반면, 각각의 테스트가 같은 외부 리소스를 사용하더라도 리소스의 write 작업을 수행하지 않는다면, 여러 개의 테스트는 공통의 외부 리소스를 그대로 사용해도 무방합니다. 또한, 외부 리소스가 충분히 안정적이고, 빠르다면 외부 리소스 접근을 무조건적으로 제한할 필요는 없습니다. 어처럼 Unit 사이의 의존성을 제거하지 않고 수행하는 테스트를 Sociable Test라고 부릅니다.

  • Solitary - 테스트하는 Unit이 다른 Unit에 영향을 받지 않도록 수행하는 테스트
  • Sociable - 테스트하는 Unit이 다른 Unit에 의존하여 행동이 결정되는 테스트

2. UnitTest의 목적

표면적으로 UnitTest는 Unit에 대한 테스트를 작성하고, 이를 검증합니다. 그런데 생각해보면 객체나 함수를 정의할 때는 항상 목적, 쓰임새가 있습니다. 그렇기 때문에 UnitTest는 Unit이 요구사항에 맞게 제대로 동작하는지를 테스트할 때 목적에 맞게 테스트를 진행했다고 이야기 할 수 있습니다. 이런 UnitTest의 목적성은 UnitTest가 UseCase를 테스트할 때, 명확히 드러납니다. 이야기를 더 진행하기 전에 UseCase가 무엇인지부터 간단히 살펴보도록 하겠습니다.

UseCase

A use case is a list of actions or event steps typically defining the interactions between a role (known in the Unified Modeling Language (UML) as an actor) and a system to achieve a goal. The actor can be a human or other external system.

UseCase는 요구사항 달성을 위해서 소프트웨어 내에서 일어나는 여러가지 시나리오를 의미합니다. 클린 아키텍처에서는 UseCase를 아래 3가지 구성요소로 정의합니다.

  1. 사용자가 제공해야 하는 입력
  2. 사용자에게 보여줄 출력
  3. 해당 출력을 생성하기 위한 처리 단계를 기술한다.

예시를 하나 살펴보겠습니다.

UseCase: HTTP 요청을 통해 획득한 계좌 정보를 앱에서 사용 가능한 형태로 변환하기

- 입력: URL에서 제공하는 데이터
- 출력: Result<Success(Model), Error>
- 과정
    1. HTTP API 호출을 통해 URL에 접근한다.
    2. 해당 URL에서 적절한 포멧의 응답 데이터를 획득한다.
    3. 응답 데이터를 파싱한다.
    4. 응답이 성공일 경우에, 데이터를 모델 객체로 매핑한다.
    5. 응답이 실패일 경우에, 에러를 리턴한다.

위의 예시는 클라이언트에서 서버의 정보를 HTTP API를 통해 조회할 때 수행하는 일반적인 과정이자, UseCase의 한 예시입니다.

UnitTest의 목적과 UseCase 테스트

모든 Unit(객체 혹은 함수)은 적어도 하나의 UseCase를 달성하기 위해 사용됩니다. 이를 바꿔서 말하면 UnitTest는 UseCase 안에서 해당 Unit이 어떻게 동작하는지, UseCase 충족을 위해 적절히 동작하는지 테스트하는 것이 중요합니다. 위의 예시로 이를 살펴보겠습니다. HTTP 응답을 앱에서 사용하기 위해는 클라이언트 앱은 HTTP 응답 데이터를 적절히 serialize 해야 합니다. 이 때, 일반적으로 serialize를 담당하는 객체를 정의하게 됩니다. 여기서 개발자는 이 객체에 대해 UnitTest를 작성할 때 UseCase에 대해서 제대로 동작하는지(HTTP 응답을 적절히 serialize할 수 있는지)를 검증해야 합니다.

클린 아키텍처에서는 UnitTest가 UseCase에 대해 테스트를 할 수 있어야 한다고 언급하고 있습니다.

- 아키텍처가 유스케이스를 최우선으로 한다면, 그리고 프레임워크와는 적당한 거리를 둔다면, 프레임워크를 전혀 준비하지 않더라도 필요한 유스케이스에 대해 단위 테스트를 할 수 있어야 한다.
- 따라서 시스템과 테스트를 설계할 때, GUI를 사용하지 않고 업무 규칙을 테스트할 수 있게 해야 한다.
- 유스케이스는 에플리케이션에 특화된 업무 규칙을 설명한다.
  • 출처: 클린 아키텍처

3. UnitTest에서의 테스트 대상

UnitTest에서 각각의 Test Suite은 무엇을 테스트할 것인지 그 대상을 명확히 해야 합니다. 이 때 하나의 테스트에서 테스트하고자 하는 주요 대상이 되는 Unit을 SUT - System Under Test라고 부릅니다. 또한, SUT는 용어 그대로 시스템의 일부이기 때문에 다른 Unit과 상호 작용을 합니다. 그 중 SUT는 자신이 의존하고 있는 객체가 있을 수 있는데, 이를 DOC - Depended On Component라고 부릅니다.

Testing-oriented people like to use terms like object-under-test or system-under-test to name such a thing. Either term is an ugly mouthful to say, but as it’s a widely accepted term I’ll hold my nose and use it. Following Meszaros I’ll use System Under Test, or rather the abbreviation SUT.

Test Isolation과 Test Double

SUT를 테스트할 때, DOC가 문제가 되는 경우가 종종 있습니다. 특히 DOC가 외부 리소스에 의존적인 경우, SUT 테스트는 어려워집니다. 예를 들어서 음료 자판기의 거스름돈을 계산하는 기능을 테스트한다고 생각해보겠습니다. 그런데 이 음료 자판기는 우리가 흔히 아는 자판기처럼 동작하지만, 추가적으로 원격으로 음료 가격을 업데이트할 수 있는 기능이 추가되어 있습니다. 즉, 자판기 소유 회사에서 원하면 언제든 원격으로 자판기의 음료 가격을 갱신할 수 있습니다.

protocol Beverage {
    var price: Int { get }
}
struct BeverageImpl: Beverage {
    let price: Int
}

struct VendingMachine {
    private var onSaleBeverages: [Beverage]
    private var selectedBeverages: [Beverage] = []
    private var userInput: Int

    mutating func select(beverage: Beverage) {
        selectedBeverages.append(beverage)
    }

    func change(userInput: Int) -> Int {
        let totalPrices = selectedBeverages.reduce(0, { $1.price })
        return userInput - totalPrices
    }
}

이 상황에서 우리는 자판기의 거스름돈 계산이 올바른지 아닌지를 판단하는 테스트를 작성하고 싶습니다. 즉, 위 코드에서 VendingMachinechange(userInput:) 메소드가 올바르게 동작하는지 체크를 하고 싶은 상황입니다. 그런데 여기에는 큰 문제가 하나 있습니다. 바로 서버를 통해서 업데이트 되는 음료의 가격이 문제가 됩니다. change(userInput:) 메소드의 결과 값은 자판기 내의 컴파일된 바이너리의 변경이 없어도 서버에서 음료 가격을 변경할 경우 언제든 바뀔 수 있습니다. 즉, 거스름돈 결과가 올바른지 체크하는 테스트는 서버의 음료 값 정보(외부 리소스)에 의존적인 상황입니다.

이러한 상황에서 UnitTest 작성자는 상황에 맞추어 테스트 방식을 선택할 필요가 생깁니다. 사실 위와 같은 이슈는 음료 가격이 자주 바뀌는 경우에는 문제가 되지만, 그렇지 않다면 큰 문제가 되지 않습니다. 하지만, 음료 가격이 자주 바뀐다면, 혹은 가끔이라도 가격이 바뀌는 상황도 대응하고 싶다면 어떻게 해야 할까요?

Test Double

이런 상황에서 활용할 수 있는 것이 Test Double입니다. Test Double은 테스트 목적으로 만드는 객체 산출물을 지칭합니다. 대표적인 Test Double로는 Mock, Stub, Spy 같은 것이 있습니다.

Test Double is a generic term for any case where you replace a production object for testing purposes.

위의 자판기 예시에서 우리는 서버의 가격 변경이 테스트에 주는 영향력을 차단하고 싶은 상황입니다. 이 영향력은 음료의 가격 정보를 가지고 있는 Test Double(BeverageStub) 객체를 정의하면 제거할 수 있습니다. 즉, 테스트에서 서버의 영향을 받는 객체 대신 Test Double을 사용하고, Test Double의 가격은 테스트 내에서 직접 설정하면 문제가 해결됩니다.

struct BeverageStub: Beverage {
    var price: Int
}

이처럼 SUT를 테스트할 때, DOC 때문에 테스트가 어려운 경우 이 DOCTest Double로 교체하여 SUT가 외부 환경 변화에 영항을 받지 않도록 만드는 과정을 Test Isolation이라고 부릅니다.

Note: 테스트를 진행할 때, SUTTest Double로 변경하고 테스트를 진행하는 실수를 많이 합니다. 이는 의미 없는 테스트를 작성하게 만드므로 SUT를 명확히 정의하고, Test Double로 전환할 DOC를 결정하는 것이 좋습니다.

4. 일반적인 UnitTest 과정

UnitTest는 일반적으로 4단계로 진행됩니다.(setup, exercise, verify, teardown) 여기서는 각 과정에서 어떤 작업을 하는지 알아보겠습니다.

Four Phase Test

Note: UnitTest 수행 단계는 given, when, then으로 표현되기도 합니다. 이는 Behavior Driven 관점이 반영된 단계 표현 방식으로, 일반적인 방식과 큰 차이는 없습니다.

1. Setup phase

Setup 단계에서는 테스트를 수행하기 위한 환경을 구성합니다. xUnitTestPattern에서는 이를 Test Fixture를 구성한다고도 표현하는데, 핵심은 SUT를 테스트하기 위한 상태를 구성하는 것입니다. 이 단계에서 적절한 인스턴스를 생성하기도 하고, 테스트를 위한 Input Data를 구성하기도 합니다.

Mock을 사용한 테스트를 진행할 경우, Setup 단계에서 expectation도 함께 구성합니다. Mock을 사용할 경우, 테스트는 행동(메소드 호출 과정)을 통해 검증됩니다. 그러므로 SUT에 대한 테스트를 수행하기 전에 어떤 식으로 동작할 것인지를 기록할 필요가 있는데, 이를 Setup 단계에서 수행합니다.

2. Exercise Phase

Exercise 단계에서는 소프트웨어에서 테스트하고자 하는 동작을 수행합니다. Exercise 단계에서 SUT의 상태를 바꾸고, 이를 명시적으로 확인할 수 있어야 합니다.

3. Verify Phase

Verify 단계에서는 수행한 테스트가 예상한 결과를 도출하였는지를 체크합니다.(State Verification) 만약 Mock을 사용해서 테스트를 진행하였다면, 이 과정에서 setup 단계에서 명시한 Expectation에 맞추어서 메소드가 호출되었는지를 확인합니다.(Behavior Verification)

4. Teardown Phase

Teardown 단계에서는 해당 테스트가 수행되기 이전 상태로 환경을 다시 돌려놓는 작업을 수행합니다.

참고자료