원래는 이전 글에서 Combine Scheduler에 대해 좀 더 알아보는 글을 쓰려고 했는데, 방향에 가닥이 잡히질 않아 다른것부터 쓰기로 했다. 뭔고 하니, 바로 Combine 코드를 단위 테스트하는 방법이다. 현재 개발중인 프로젝트에서는 많은 코드를 Combine 기반으로 작성하고 있기 때문에 테스트 코드 역시 Combine에 맞춰서 구현하게 된다. 그렇다면 어떻게 Combine 코드를 테스트할 수 있을까? 공통점 하나와 차이점 하나로 설명해보겠다.
공통점: setUp과 tearDown
Combine 테스트라면 당연하게도 Publisher를 구독하고 값을 받거나 종료 이벤트를 받는 시점에 받은 값을 검증해야 할 것이다. 그렇다면 결과적으로 Publisher를 구독했을 때 반환되는 Cancellable에 대한 처리가 필요하다는 것이다. 만약 이런 부분을 간과한다면, 어딘가에서부터 이유를 알 수 없이 깨지는 테스트 케이스가 발생하게 될 것이다.
import XCTest
import Combine
class CombineTest: XCTestCase {
// 초기화
var subscriptions = Set<AnyCancellable>()
override func tearDown() {
// 종료된 테스트 케이스의 Cancellable에 대해 초기화 처리
subscriptions = []
}
}
차이점: 동기냐 비동기냐
사실 Combine이나 XCTest 프레임워크 상에서 별도로 Combine만을 위한 테스트 도구를 제공하지는 않는다. 예를 들어, RxSwift에는 TestScheduler
가 있어 가상으로 이벤트의 시간을 조정할 수 있다. 그렇기 때문에 특히나 비동기로 이루어지는 작업에 대한 테스트에 대한 기대값을 조정하기에 편하다. 하지만 Combine에는 그런게 없다. 기존에 제공하는 도구로도 충분히 모든 것을 테스트할 수 있다는 자신감이 아닌가 싶다(물론 실제로도 기존 도구로 모든 테스트가 가능).
동기 테스트
동기적인 동작에 대한 테스트는 간단하다. 간략하게 순서로 얘기하자면 다음과 같다.
- 테스트할 결과값을 받아와 저장하는 변수와 테스트하고자 하는 성공/실패 기대값을 준비한다.
- 테스트하고자 하는 Publisher를 준비한다.
- Publisher를 구독하고 값을 받는 클로져(receiveValue or receiveCompletion)에서 결과값을 저장하는 변수에 값을 저장한다.
- 테스트하고자 하는 기대값과 비교한다.
만약 생성자를 통해 주어진 인자를 소문자화하는 커스텀 Publisher인 LowerCased가 있다고 하자. 그렇다면 이 Publisher에 대해서는 어떻게 테스트해야할까? 다음 코드가 아마 힌트가 될 것이다.
// Publisher.LowerCased<String, Never>가 제대로 주어진 값을 소문자화하는지 테스트
func test_lowerCased() {
// 1 - 결과값을 저장할 변수와 기대값 변수 선언
var result: String = ""
let expect: String = "foo"
// 2 - 테스트할 Publisher
Publisher.LowerCased("FOO")
.sink(receiveValue: {
// 3 - 결과값 저장
result = $0
})
.store(in: &subscriptions)
// 4 - 결과 검증
XCTAssertEqual(result, expect)
}
위에서 얘기한 순서에 해당하는 부분에 주석으로 숫자를 남겨놨다. 이 테스트케이스의 경우 모든 것이 동기적으로 동작할 것이라는 것이 기대되기 때문에 결과값을 클로져 내부에서 받아온다고 하더라도 신경쓸 필요가 없다. 그저 순차적으로 동작할 것을 기대하고 테스트 코드를 작성하면 되는 것이다.
그렇다면 만약 ViewModel과 같은 역할을 하는 클래스를 테스트한다면 어떻게 해야할까? 이 경우에도 크게 다르지 않다. 아래와 같은 클래스가 있다고 생각해보자. 아래 클래스는 문자열을 입력하면 lowerCased 변수에 입력한 문자열을 소문자로 변환해 저장하는 클래스이다.
class LowerCaseViewModel: ObservableObject {
@Published private(set) var lowerCased: String = ""
func setValue(_ text: String) {
lowerCased = text.lowerCased()
}
}
이 경우 다음과 같은 테스트가 가능할 것이다.
class LowerCaseViewModelTest: XCTestCase {
var viewModel: LowerCaseViewModel!
var subscriptions = Set<AnyCancellable>()
override func setUp() {
viewModel = LowerCaseViewModel()
}
override func tearDown() {
subscriptions = []
}
func test_lowerCased() {
var result: String = ""
let expected: String = "foo"
viewModel.$lowerCased
.sink(receiveValue: {
result = $0
})
.store(in: &subscriptions)
viewModel.setValue("FOO")
XCTAssertEqual(result, expected)
}
}
viewModel.$lowerCased는 실제로는 ["", “foo”]의 두 개 값이 방출되고, 딱히 종결이벤트가 발생하지 않지만 해당 테스트 케이스는 동기적 이벤트 발생에 대한 흐름을 쫓고 있기 때문에 결과값이 기대값인 “foo"와 동일하기 때문에 의도한대로 성공하는 테스트 케이스가 되는 것이다.
그렇다면 이번엔 Combine의 비동기 동작은 어떻게 테스트할 수 있는지 알아보자.
비동기 테스트
위의 동기 동작에 대한 테스트 코드를 보면 사실 일반적인 테스트 코드를 작성하는 것과 그렇게까지 큰 차이가 없다는 것을 알 수 있다. 그렇다면 비동기 동작에 대한 테스트는? 그렇다. 흔히 활용하는 completionHandler에 대한 테스트와 비슷하게 처리할 수 있다. 바로 XCTestExpectation
을 사용하는 것이다. 그 전에, 일단 아래와 같은 ViewModel 클래스가 있다고 가정해보자.
class FooViewModel: ObservableObject {
private let fetcher = BarFetcher()
@Publisher private(set) var fetchedData: String = ""
func fetch(for keyword: String) {
fetcher.fetchAsync(for: keyword)
.assign(to: &$fetchedData)
}
}
어떤 키워드를 입력하면 어딘가로부터 키워드에 맞는 데이터를 가져와 fetchedData에 해당 데이터를 넣어주느 동작을 하는 ViewModel이다. 이 경우에는 키워드에 맞는 데이터를 비동기적으로 가져오는 동작밖에 할 수 없기 때문에 위의 동기 테스트 코드로는 처리할 수 없다. 하지만 이를 위해 위에서 언급한 XCTestExpectation이 존재하는 것이다. 간략한 사용방법은 아래 코드로 알 수 있으나, 자세하게 알고 싶다면 애플 개발자 문서를 찾아보자.
func test_fetchAsync() {
var result = [String]()
let expected = ["some text data for keyword"]
// 비동기 작업에 대해 대기하도록 하는 XCTestExpectation 생성
let expectation = self.expectation(description: #function)
viewModel.$fetchedData
.dropFirst()
.prefix(1)
.sink(
receiveCompletion: { _ in
// 이벤트 종료와 함께 expectation에 대기 종료할 것을 알린다
expectation.fulfill()
},
receiveValue: {
result.append($0)
}
)
.store(in: &subscriptions)
viewModel.fetch(for: "keyword")
// XCTestExpectation의 종료 메시지를 대기하도록 하는 명령. 최대 5초간 기다리도록 지정.
waitForExpectations(timeout: 5, handler: nil)
XCTAssertEqual(result, expected)
}
XCTestExpectation의 사용법은 주석으로 대강 알아볼 수 있을 것이다. 여기서 확인해야하는 것은 viewModel.$fetchedData의 뒤에 달라붙어있는 dropFirst()와 prefix(1)이다. 그렇다면 왜 이런 연산자를 사용해야 하는걸까?
첫번째로 dropFirst를 쓴 이유는 다음과 같다. viewModel.$fetchedData의 경우 초기값이 있는 Publisher다. 이 경우 dropFirst가 없으면 result가 초기값까지 받아 배열에 넣기 때문에 expected 값과 비교하면 테스트 실패라고 나올 것이다. 물론 최종 결과값만 보고 비교해도 상관없다면 기대값과 결과값을 [String]이 아닌 String 타입으로 놓고 비교하면 될 일이다. 하지만 Combine이라는 것은 결국 이벤트 스트림에 대한 것이고, 이에 대해 테스트하기 위해서는 이벤트 스트림 자체에 대한 검증이 필요해지는 것이다(그래서 적절한 TestScheduler가 있으면 더 좋다). 이런 것들은 명시적/암시적으로 환기해가면서 주지하지 않으면 금방 까먹을 수 있기 때문에 개인적으로 위와 같은 패턴을 즐겨 쓰는 편이다.
두번째로 prefix를 쓴 이유는 다음과 같다. viewModel.$fetchedData는 ViewModel 자체가 deinit되지 않는 한 이벤트 스트림이 종결될 일이 없다. 이벤트 스트림이 종결되지 않는다면 적절한 시점에 expectation.fulfill이 호출되지 않기 때문에 waitForExpectations로 지정한 시간제한에 걸리고 만다. 이런 일을 피하기 위해서는 prefix와 같은 연산자를 통해 이벤트 스트림에 대한 추적을 얼마만큼 할 것인지 지정해야 한다.
위의 두 연산자를 반드시 써야한다는 것이 아니다. 비동기 동작에 대한 테스트를 할때는 이벤트 스트림에 대한 검증을 해야한다는 것을 잊으면 안된다는 것이다. 특히나 ViewModel과 같은 클래스는 뷰의 상태에 직접적으로 관여하는 경우가 많기 때문에 더더욱 이를 유념해야 한다. 예를 들어 뷰 안에 스위치가 있고, 이 스위치를 전환할때 어떤 비동기 동작을 진행한 후 스위치와 바인딩된 Bool 값이 변경된다고 생각해보자. 비동기 동작을 한 번 진행할때 스위치에 바인딩된 Bool값이 두번 세번 네번 변경된다? 그러면 사용자는 단 한번의 상호작용을 했을 뿐인데 그에 대한 결과를 두번 이상 목격하게 되고, 결과적으로 혼란을 겪게 될 것이다. 이 역시 중요한 버그라고 할 수 있으니 꼭 검증해야 하는 요소라고 할 수 있다.
결론
오늘은 Combine의 스케쥴러가 아닌 테스트에 대해 알아봤다. 일반적인 테스트 코드 작성법과 크게 다르지 않지만, 어떤 경우에는 단순 값에 대한 검증이 아니라 이벤트 스트림에 대한 검증을 해야한다는 차이가 있었다. 이것만 유념한다면 Combine 코드에 대한 테스트 케이스 작성 및 검증에 큰 어려움을 겪지는 않을 것이다.