Test double in iOS

InTaek Cho·2022년 1월 10일
0
post-thumbnail

얼마전 프로젝트의 커버리지를 위해 TC를 작성하는 업무를 했었다. 그러면서 든 생각은 단순히 메소드의 예상 동작에만 집중하는 듯한? 약간 유닛 테스트 느낌으로만 한다고 생각이 들었다. 이건 아닌 것 같아 TC를 짜는 진정한 목적이 무엇인지, 더 나아가 TDD에 대한 관심이 생겼고, 관련 공부를 하던 중 test double이라는 용어를 처음 접하게 되었다.

딱 접하자마자 완벽한 정의와 함께 적절한 예제가 필요하다고 생각이 들었다. 그래서 여러 소스를 찾아봤지만 뭔가 하나로 좁혀지는 기분이 들지 않았다. 누군가에게 설명하고 그 용어를 실제로 쓸 때 제대로 했는지에 대한 확신이 없을 것 같았다. 따라서 오늘은 이에 대해 다뤄보려고 한다.

Test double이란?

일단 위키디피아를 참고하자면, 대개 소프트웨어 개발을 하면서 자동화 테스트를 하는데 이를 위해 리얼과는 다르게 단순하게 정의해서 사용하는 객체나 로직 같은 것이 있고, 이들을 모아 test double이라고 정의한다. 모아서 정의한 용어다 보니 하위 항목이 당연히 있다. 일단 내가 공부할 때 사용했던 raywenderlich 교재에 따르면 아래와 같이 있다고 한다.

  • Stub
  • Fake
  • Mock

그리고 뭔가 더 있지 않을 까 싶어서 더 찾아보니 추가적으로 아래 2가지가 더 있었다.

  • Spy
  • Dummy

이렇게 총 5가지로 이루어져있다.

이해를 돕기 위해..!

본격적으로 넘어가기 전에 짚고 넘어가야 할 추가 용어가 있다. 여러 자료를 찾아보다가 해당 사이트에서 발견한 용어인데, 이를 알고나면 test double의 각 목적에 대해 잘 이해할 수 있다.

  • indirect input
  • indirect output

일단 전자는 메소드의 파라미터 같은 입력(direct input)이 아닌 로컬 파일로부터 읽거나 DB로부터 읽는 작업등을 말한다. 그리고 후자는 메소드 리턴 값(direct output)이 아닌 print문으로 찍거나 파일쓰기, DB write 작업등을 말한다. 앞으로 필요하면 이 용어들도 사용해가면서 설명할 예정이니 확실히 이해하고 넘어가자!

Dummy

Dummy는 필요는 하지만 실제로는 쓰진 않는 녀석이라고 볼 수 있다. 예를 들면 아래처럼 어떤 인스턴스가 필요한데 그 안의 프로퍼티 값은 어떤 값이 들어가도 상관없을 때의 경우가 있다.

func testExample() throws {
    let challenge = Challenge(title: "", time: Date(), startTime: Date(), goal: 0, now: 0)
    // ...
}

위의 예에서 빈 스트링 값이나 Date() 값등이 Dummy라고 볼 수 있는 것이다. 물론 challenge가 어디선가 어느 인스턴스의 생성 목적으로만 사용된다면, challenge도 Dummy라고 볼 수 있다. 또 다른 예로는 어떤 메소드를 호출 할 때 파라미터에 어떤 값을 전달해도 상관없다면 그 값도 Dummy라고 보면 된다. 어떤 블로그에서는 굳이 클래스를 구현해서 dummyXXX 느낌으로 인스턴스를 만들어서 설명하던데, 그런 개념까지 갈 필요없이 단순히 채우기용이라고 보면 좋겠다.

Stub

Stub은 하드코딩 된, 미리 가공된(canned response) 데이터를 제공하는 녀석을 말한다. 여기서 데이터에는 빈 값이나 nil 값도 포함될 수 있다. 목적은 indirect input을 검증하기 위함이다. 예를 들어, TC를 작성하다 보면 리얼 DB에 직접 접근하지 않고 이미 데이터를 제공받은 상태, 즉 indirect input이 준비되어야하는 경우가 있다. 우리의 SUT(System Under Test)이 그 상태로 가게 만들기 위한 test double인 것이다. 코드로 예를 들어보면 아래와 같다.

protocol ChallengeFetchProtocol {
    func fetch() -> [Challenge]
}

class ChallengeTestFetchHandler: ChallengeFetchProtocol {
    func fetch() -> [Challenge] {
        return [Challenge(title: "test1", time: Date(), startTime: Date(), goal: 10, now: 0),
                Challenge(title: "test2", time: Date(), startTime: Date(), goal: 5, now: 0)]
    }
}

class SomeThingTests: XCTestCase {
    var sut: SomeThing!

    func testExample() throws {
        sut.fetchHandler = ChallengeTestFetchHandler()
				sut.fetchHandler.fetch()
        // ...
    }
}

fetch()라는 메소드가 DB에 접근 후 데이터를 가지고 오는 역할을 한다고 가정하면, TC에서는 이를 처리할 필요없이 하드코딩된 데이터를 가져오도록 구현하면 된다. 결과적으로 SUT이 데이터를 가져온 상태로 만든 것이다! 이 때문에 Stub을 상태 기반 테스트에 사용한다고 말하는 것 같다. 추가적으로 indirect output에 대한 검증은 신경쓰지 않아도 된다. 이는 추후에 설명할 Mock이나 Spy의 역할이다.

Fake

Fake는 구현된 로직은 있지만, 리얼 코드에 비해선 매우 단순한 로직을 가진 녀석이라고 보면 되겠다. 몇가지 예제를 찾아보면 Stub과 굉장히 유사해보이는데, 차이점이 있다. Stub은 indirect input에 대한 통제권을 가져오는 것이 목적이라면, Fake는 그저 단순한 로직을 제공하는 것에 목적을 두고 있다는 것이다. SUT의 전체적인 동작에는 영향없이 말이다.

class ChallengeTestFetchHandler: ChallengeFetchProtocol {
    func fetch() -> [Challenge] {
        // from test.json
        let data: Data = ...
        let result = try! JSONDecoder().decode([Challenge].self, from: data)
        
        return result
    }
}

위 예시는 Stub 예시에 이어 fetch() 구현부만 다르게 했다. DB fetch라는 복잡하고 오래 걸리는 로직을 단순화하여 미리 준비된 json 파일로부터 읽어오는 로직으로 바꿔 구현한 것이다. 이름답게 흉내냈다고 보면 이해하기 편할 것 같다.

Spy

Spy는 테스트하려는 메소드가 얼마나 호출되었는지, 누가 호출했는지 등 여러 정보를 기록하는 녀석이다. 따라서 목적은 어떤 메소드가 호출됨으로써 발생할 수 있는 사이드 이펙트를 조사하는 것이라고 할 수 있다. 기록이라는 성격 때문에 SUT의 indirect output을 검증할 필요가 있을 때 사용하면 된다. 해당 페이지의 예제를 swift로 표현해보자면 다음과 같다.

protocol AuditLog {
    func logMessage(with challenge: Challenge)
}

class AuditLogSpy: AuditLog {
    var numberOfCalls = 0
    var challenge: Challenge?
    
    func logMessage(with challenge: Challenge) {
        self.numberOfCalls += 1
        self.challenge = challenge
    }
    
    func getNumberOfCalls() -> Int {
        return self.numberOfCalls
    }
    
    func getChallenge() -> Challenge? {
        return self.challenge
    }
}

class MovieDialogTests: XCTestCase {
    func testExample() throws {
        let spy = AuditLogSpy()
        sut.logger = spy
        
        sut.doSomething()
        XCTAssertEqual(spy.numberOfCalls, 1)
        // ...
    }
}

SUT의 indirect output인 로거 시스템을 Spy로 테스트한 내용이다. 현재 호출이 제대로 되었는지 체크하고 있고 더나아가 필요하다면 세부적인 내용을 기록할 수도 있다.

Mock

Mock은 행동 기반 테스트에 사용하는 녀석이다. 나는 이를 전체적인 로직에 대한 검증이라고 해석했다. 기본적으로 Mocking을 할 수 있는 라이브러리를 사용하는데, iOS의 경우 서드파티 라이브러리를 이용해야하므로 보통 protocol을 선언해두고 리얼 코드/mock 코드를 분리하거나, 상속을 통해 테스트할 메소드를 override 하는 케이스를 많이 봤다. 그래서 사실 코드들을 보면 지금까지 본 test double의 특징을 모두 갖고 있는 느낌도 든다.

결론

이렇게 정리하다보니 명확히 뭐는 뭐다라고 정의내리기 매우 힘들었다. Mock 같은 경우는 아까도 말했지만 모두를 아우르고 있는 느낌이 강해서 글을 정리하면서도 이게 의미가 있나 싶었다. 그래서인지 해당 사이트에서 언급하고 있는 아래 그래프가 가장 와닿았던 것 같다.

스펙트럼 마냥 모두 비슷한 성질을 조금씩 가지고 있다는 것이다. 앞으로도 목적에 집중하면서 지금 사용하는 test double이 어떤 용어에 가까운지 생각하면서 사용하는 것이 좋을 것 같다.

참고

0개의 댓글