글 작성자: HEROHJK

앞 포스트에서 단위테스트가 무엇이고, 왜 해야하는지 알아봤습니다.

 

단기성 프로젝트가 아닌 이상, 지속적인 유지보수를 해야하고,

리팩토링은 대표적인 유지보수 방법입니다.

하지만 그 리팩토링이 과연 효과가 있었는가? 를 확인하기 위해 단위테스트를 이용한다고 설명을 했었는데요,

 

그렇다면 단위테스트는 어떻게 만들고 어떻게 평가를 해야할까? 에 대해 포스팅합니다.

 

1. 단위테스트의 구조

1.1 AAA 패턴

단위테스트는 대표적으로 AAA패턴을 사용하여 진행합니다.

AAA 패턴이란, 준비(Arrange) - 실행(Act) - 검증(Assert)의 순서로 진행되는 패턴입니다.

public class CalculatorTests {
    public void test_두_수의_합을_검증() { // 알아보기 쉽게, 읽기 쉽게 작성하는걸 추천하기에, 한글 사용을 권장합니다.
        /// 준비
        let first: Double = 10
        let second: Double = 20
        var calculator = Calculator()

        /// 실행
        let result = calculator.sum(first, second)

        /// 검증
        XCAssertEqual(30, result)
    }
}

 

 

준비 구절에서는 테스트를 진행하려는 테스트 대상 시스템과, 의존성들을 원하는 상태로 세팅합니다.

(이후 테스트 대상 시스템은 SUT(System Under Test)라고 표현하도록 합니다.)

 

실행 구절에서는 SUT에서 테스트가 필요한 메서드를 호출하고, 미리 준비한 의존성을 전달하여 출력이 있는 경우 출력값을 저장합니다.

 

검증 구절에서는 저장된 출력값과 예상하는 값을 비교하여 검증합니다.

 

이 때, AAA 항목들이 여러개로 나누어지면, 단위테스트가 아닌 통합테스트가 됩니다.
따라서 여러개의 동작단위를 검증하는것은 지양하도록 합니다.

1.2 구절의 크기?

테스트코드에서 if문을 사용한다면, 위에서 말한 여러개의 동작 단위를 검증하게 될 수도 있기에 지양합니다.

 

준비구절이 보통 가장 큽니다만, 너무 커진다면 오브젝트 마더, 테스트 데이터 빌더 등 빌더 패턴을 사용하여 테스트 코드에서 분리하도록 합니다.

 

실행 구절은 되도록이면 한번만 실행하며, 한번이 넘어간다면 베이스코드를 의심해 리팩토링을 준비하는게 좋습니다.

 

'하나의 기능'만 테스트한다는 가정 하에, 검증구절은 몇개가 있어도 상관 없습니다 만..

그래도 많아진다 싶으면 리팩토링을 준비합니다.

(추상화, 비교 메서드 등을 점검 해 봅시다.)

 

2. 좋은 단위 테스트의 4대 요소

단위테스트를 평가하는 네가지 지표가 있습니다.

2.1 회귀 방지

만약 내가 리팩토링을 통해 코드를 수정했는데, 의도대로 작동하지 않는 경우 다시 원상복귀를 시키는 경우가 있습니다.

이것을 회귀라고 하며, 단위테스트를 평가할 때 해당 코드가 회귀를 해야하나도 평가하게 됩니다.

 

코드의 양이 많아지거나, 코드가 복잡해지는 경우 회귀가 발생할 가능성이 높습니다.

더군다나 복잡한 비즈니스 로직은 시스템의 핵심과도 같은데, 여기서 발생하는 버그는 시스템에 가장 큰 피해를 입힙니다.

 

또 SUT의 해당 코드가 얼마나 도메인에 적합한지도 확인을 해봐야 합니다.

2.2 리팩터링 내성

단위테스트를 진행하다보면, 분명 리팩터링을 한 코드는 잘 작동하는데.. 단위테스트에서 실패하는 경우가 있습니다.

이것을 거짓 양성(false positive)이라고 합니다.

 

SUT가 구현의 세부사항과 강하게 결합이 되어있을 때 주로 발생합니다.

 

개인적으로 이 리팩터링 내성, 거짓양성이 가장 악질적인 케이스라고 생각을 하는데요.. 제가 생각하는 이유는 다음과 같습니다.

 

두가지 케이스가 있는데요..

 

1. 일단은 일정이 중요하다. 마감이 얼마 남지 않았고, 기능작동에는 문제가 없으니 테스트는 제거하고 나중에 다시 들여다보자.

2. 아니다.. 테스트에 통과하지 못하는것은 내가 아직 발견하지 못한 무언가의 이유가 있을것이다.. 일단 코드를 원상복귀 시킨 후 나중에 다시 들여다보자..

 

네.. 이렇게 둘이서 러시안 룰렛을 하게 됩니다.

결국 둘중 하나만 살아남는(테스트 코드 or 리팩터링된 코드) 비극이 발생합니다..

2.3 빠른 피드백

더 많은 테스트를, 더 자주 수행할수 있기 때문에 테스트는 빠를수록 좋습니다.

2.4 유지보수성

테스트코드는 이해하기 쉬울수록 좋습니다.

가독성을 부숴트리고 라인을 억지로 줄인다거나.. 변수명을 억지로 짧게만드는등 억지로 압축을 하지 않는 선에서 간결하고 잘게 쪼개서 진행하도록 합니다.

2.5 이상적인 테스트?

위의 네가지 요소를 지키면서 리팩터링을 진행하고, 테스트코드를 작성하는건 이상에 가깝습니다.

결국 리팩터링 내성을 최대한 유지하면서, 회귀 방지와 빠른 피드백사이에서 절충하는 방법을 일반적으로 추천하더라구요.

 

3. 대역?

테스트를 진행하는데 SUT가 아닌 SUT에 필요한 의존성들은 대역으로 처리하는 경우가 많습니다.

상황에 따라 단위테스트 코드안에 모든 의존성들을 준비하기가 어렵기 때문입니다.

 

3.1 종류들

단위테스트에 관심이 있다면 Mock, 목 이라는 말을 많이 들어보셨을텐데요..

목은 대역의 일부입니다.

그리고 아래처럼 세분화가 가능합니다.

목 (Mock)
스파이
스텁 (Stub) 스텁
더미
페이크

3.1.1 목

목은 SUT에서 외부로 나가는 상호작용을 대체 하는데 도움이 됩니다.

보통 명령의 역할을 가지고 있습니다.

3.1.2 스텁

스텁은 외부에서 SUT의 내부로 들어오는 상호작용을 모방하는데 도움이 됩니다.

보통 조회의 역할을 가지고 있습니다.

 

/// Mock의 예시
func test_이메일전송() {
    var mock = Mock<EmailGateway>()
    var sut = Controller(mock.object)

    sut.greetUser("user@email.com")

    // SUT에서 사이드 이펙트를 발생시킬 때(이메일 전송), 미리 만들어둔 Mock 대역으로 사용.
    XCTAssertEqual(mock.sendGreetingsEmail("user@email.com"), true) 
}

/// Stub의 예시
func test_report_생성() {
    var stub = Mock<Database>()

    // dummy의 개념으로, 미리 10명의 유저를 생성
    stub.setUp { $0.GetNumberOfUsers() }.returns(10)

    var sut = Controller(stub.object)

    let report = sut.createReport()

    XCTAssertEqual(10, report.numberOfUsers)
}

 

두가지는 보통 단위테스트 라이브러리에서 묶여있는 경우가 많습니다.

하지만 이를 잘 구분할줄 알아야 합니다.

 

5. 스타일

4.1 출력 기반

입력을 넣고 생성되는 출력을 점검하며, 불변성을 가진 함수형 프로그래밍에서 검증하기가 좋습니다.

가장 간결하고 명확하지만, 조건을 만들기가 쉽지는 않습니다..

4.2 상태 기반

테스트 실행 후 시스템의 상태들을 변화를 점검합니다.

4.3 통신 기반

목을 사용하여 SUT와 협력자(목)간의 통신을 검증합니다.

 

4.4 단위테스트의 4대 요소를 기반으로 비교.

위에서 설명한 좋은 단위테스트의 4대요소를 기반으로 스타일마다 어떤 특성이 있는가를 비교해봅니다.

4.4.1 회귀방지, 빠른 피드백의 지표로 비교

  • 실행되는 코드의 양, 도메인 유의성
    • 셋 모두 관계 없습니다.
  • 코드 복잡도
    • 테스트 시 SUT외의 모든것을 Mock으로 사용할 가능성이 있지만..
    • 이는 각 스타일의 문제점이 아닌 테스트 코드를 좋지 않게 작성한 경우입니다.
    • 따라서 복잡도도 크게 관계가 없습니다.
  • 빠른 피드백
    • 외부 의존성과 연결되어있지 않는 한, 세 스타일 모두 유의미한 차이는 없습니다.

따라서, 테스트코드를 정상적으로 작성한다는 가정 하에, 회귀방지와 빠른 피드백의 경우 세 스타일 모두 큰 차이는 보이지 않습니다.

4.4.2 리팩터링 내성 지표로 비교

리팩터링 내성은 곳 거짓 양성이 얼마나 적은가에 대한 척도입니다.

구현 세부사항과 결합이 강할수록 거짓 양성의 발생 확률이 올라가는데요..

  • 출력 기반
    • 코드 자체가 불변성을 띄고있기 때문에, 거짓 양성 방지에 가장 우수합니다.
  • 상태 기반
    • 테스트와 SUT코드의 결합도가 높을수록, 구현 세부사항과 얽매일 가능성이 높아, 상대적으로 위험해집니다.
  • 통신 기반
    • Mock을 상대적으로 많이 사용하기 때문에, 세가지 스타일중 거짓양성 발생에 가장 취약합니다.
    • 따라서 Mock이 되는 협력 의존성을 잘 설계할 필요가 있습니다.

4.4.3 유지보수성 지표로 비교

유지보수성은 얼마나 크고 얼마나 복잡한가를 확인하는데요..

  • 출력 기반
    • 대부분의 코드가 짧고 간결하기에, 유지보수가 가장 쉽습니다.
  • 상태 기반
    • 테스트를 위해 더 많은 코드를 짜야하기 때문에.. 출력 기반에 비해 상대적으로 유지보수가 어렵습니다.
    • 헬퍼 메서드를 사용해 완화할수는 있지만, 결국 이로 인해 비용이 더 증가하게 됩니다.
  • 통신 기반
    • Mock과의 상호작용 검증이 필요하므로, 가장 유지보수가 어렵습니다.

 

4.4.4 결론

지표 출력 기반 상태 기반 통신 기반
리팩터링 내성을 지키기 위한 노력 낮음 중간 중간
유지보수 비용 낮음 중간 높음

 

결국 출력 기반 -> 상태기반 -> 통신기반의 순으로 4대요소에 부합하게 됩니다.

반응형