2016년 11월 8일 화요일

[Xcode] 비동기 루틴 유닛 테스트 (Asynchronous Unittest)

Xcode 의 유닛테스트 기능은 동기(Sync)코드 테스트에 최적화 되어있다. 당연하게도 비동기 루틴의 경우 해당 테스트 컨텍스트가 종료된 뒤에 비동기 결과가 들어오니 쉽게 판단할 수는 없을 것이다.

다행히도 Xcode 의 XCTest 모듈은 이런 비동기 콜(Asynchronous Call)에 대비하기 위한 약간의(?) 기능이 제공되고 있어서 소개해 본다.

테스트 대상

아래 코드를 테스트 하려고 한다.
class GoodModule {
  func hardWork(input: Bool, result: @escaping (Bool) -> ()) {
    DispatchQueue.global().async {
      Thread.sleep(forTimeInterval: 2)
      
      DispatchQueue.main.async {
        result(input)
      }
    }
  }
}
테스트 코드이기 때문에 별로 하는 일이 없는 것에 딴지는 없었으면 좋겠다. 하여간, 이 코드는 input 으로 들어온 값을 2초 뒤에 result 클로져를 통해 다시 돌려주는 지극히 단순무식한 비동기 동작을 수행하는 hardWork() 라는 메소드가 구현하고 있다.

굳이 DispatchQueue 를 쓸 필요는 없었을지도 모르겠지만 확실하게 스레드를 분리하고자 하는 불필요한[...] 의도가 숨어있다.

유닛 테스트 시도

XCTAssert류 함수를 이용해 hardWork() 메소드를 바로 테스트 할 수 있는 방법은 없다. 메소드 자체가 리턴하는 값도 없고 비동기로 호출되는 클로져가 언제 호출될 지도 모른다.

이런 경우 아래와 같은 방식을 생각해 볼 수 있다.
func testHardWorkAsync() {
  let module = GoodModule()
  var finish = false
  var result = false
  module.hardWork(input: true) { (r) in
    finish = true
    result = r
  }

  // finish 값이 true가 될 때 까지 무한정 루프를 돌면서 기다린다.
  while finish == false {}

  XCTAssertTrue(result)
}
위 코드는 그냥 아이디어 스케치라고 생각하자. 실제로 실행시켜 보지도 않은 날(raw)코드다. -_-;;

코드 내용은 매우 단순하다. hardWork()를 실행시키고 비동기로 호출되는 클로져에서 finish 값을 true로 바꿔주는데, 그 아래에서 while 문을 통해 finish 값이 true 로 바뀔 때 까지 무한정 기다리고 있다.

이 방식대로 한다면 의도대로 테스트는 가능하리라 생각된다. 다만 while 문에서 멈춰있는 동안 시스템이 마비될 가능성이 매우 크다. 즉 위험하다. (물론 main thread만 마비가 되겠지만 이렇게 되면 UI가 굳어버린다! 무한바람개비!)

만약 이렇게 수동으로 비동기 루틴을 테스트 하고자 한다면 Thread.sleep 등을 이용해 좀 더 컴퓨터가 편안(?)하게 테스트 할 수 있게 해주자.

필요하다면 Date를 이용해 혹은 NSRunLoop 를 동원해 타임아웃도 체크할 수 있을 것이다.

XCTest 의 Expectation 을 이용해 보자

사실 위의 방법은 지극히 단순하고 위험하면서도 귀찮다. 고쳐야 할 필요성이 많다.

물론 서두에서 이야기 했듯이 XCTest 에서 관련 기능을 제공한다. 위의 테스트 코드를 좋은(?) 방법으로 고쳐봤다.
func testHardWorkAsyc() {
  let expt = expectation(description: "Waiting done harkWork...")
  let module = GoodModule()
  module.hardWork(input: true) { (result) in
    XCTAssertTrue(result)
    expt.fulfill()
  }

  // fulfill() 을 기다린다.
  waitForExpectations(timeout: 5.0, handler: nil)
  withExtendedLifetime(module) {}
}
제일 처음에 expectation() 함수를 이용해 expt를 만들었다. 이 녀석이 이번 테스트의 핵심 역활을 하게 된다.
참고로 expectation(description:) 함수는 유닛테스트 컨텍스트에서만 쓸 수 있는 특수한 함수다. 이걸 유닛테스트 함수 내부에서 사용하게 되면 해당 테스트 코드들은 expectation 기능이 동작하게 된다. 단, 구조 상 테스트용 메소드 당 하나의 expectation 만 가능하다고 생각하자.
그 다음, hardWork() 를 실행시키는 코드가 동일하게 등장한다. 차이가 있다면 비동기로 호출되는 result 클로져를 통해 값의 정상 유무를 체크하고 expectation(expt) 에다 fulfill() 이라는 신호를 던져주는 일을 한다.

waitForExpectations 는 timeout 시간 동안 시간을 잠깐 멈추고 expectation(expt)에 fulfill() 신호가 올 때 까지 대기한다. 따라서 위의 hardWork의 비동기 호출이 완료되면 이 waitForExpectations 작업이 끝나게 된다.

마지막의 withExtendedLifetime 가 쓰였는데 이 코드는 테스트 자체와는 무관하다. 단지, 비동기 호출인 만큼 module 인스턴스가 언제 ARC를 통해 해제될 지 알수가 없기 때문에 이 module의 lifetime 을 보장해 줄 목적으로 waitForExpectations 아래쪽에 위치하고 있다. 물론 다른 방식으로 module 의 접근을 이후에도 한다면 필요없는 코드이다.

당연히 앞서 봤던 코드에 비해 가독성 면이나 편리성 면에서 좋은 코드이다. 하지만 잘 대입해 보면 앞의 귀찮은(?) 코드와 동일한 디자인임을 알 수 있다. finish 를 true로 만드는 코드가 fulfill() 이랑 매칭되고, while 문을 이용해 기다리는 코드는 waitForExpectations 와 매칭된다.
waitForExpectations 의 두 번째 인자인 handler 는 expectation 이 타임아웃이 발생하면 호출되는 클로져다. 상황에 따라 다르겠지만 별로 쓸 일이 없어보여서 상세한 내용은 생략한다.

타임아웃이 발생하면?

위의 테스트 코드에서 waitForExpectations 의 timeout 을 5.0 즉 5초로 잡았는데 이를 2초 보다 짧게 잡으면 타임아웃이 발생할 수 밖에 없다. hardWork 는 무조건 2초가 걸리기 때문이다.

만약 타임아웃이 발생한다면 해당 테스트는 실패한 것으로 간주된다.


타임아웃이 발생하는걸 정상으로 간주하는 방법을 찾고자 했으나 Expectation 을 이용한 방법에서는 제공되지 않는 것 같았다. 이거야 사상(?)에 따라 당연한 것일지도 모른다. 그냥 참고만 하자.

마무리

유닛테스트 코드는 모델 단이나 산술함수 등 동기 모델 테스트가 모범(?)이 될 가능성이 높다. 이런건 동기코드일 가능성이 높고 신뢰성을 위해 테스트가 필수불가결 하기도 하다.

하지만 애플은 GCD 등을 통해 편리한(?) 분산 처리 환경(병렬 프로그래밍)을 제공하고 있으니 이에 맞게 비동기 병렬 계산 로직 등도 충분히 등장하기 좋은 환경이니 만큼 [...] 비동기 테스트 방법도 알아두면 좋을 것 같다. 아 근데 편리하긴 한건가? 잘 모르겠다. 큐에 쌓인거 취소도 안되는데 -_- 음...

[관련글] Xcode 6 유닛테스트(Unit Test) 기초가이드
[관련글] Swift 프로젝트의 유닛테스트(Unit Test)

댓글 없음 :