【Swift】ゼロからのCombineフレームワーク - ユニットテストを書いてみる

# Combineを使ったユニットテストの方法

２つの方法を試してみました。

1. ライブラリなしでやる
2. [Entwine](https://github.com/tcldr/Entwine)というテスト補助用のライブラリを使う

# テスト対象コード

`incrementCounter: PassthroughSubject`の`send`メソッドが呼ばれたら、自身の`counter: Int`に数値を加えて、`counterStr: CurrentValueSubject`を更新する単純なモデルです。

テストコードでは、`incrementCounter`の`send`メソッドの呼び出しにたいして、`counterStr`が正しく更新されていることをテストします。

```swift:CounterViewModel.swift
import Combine
import Foundation

protocol CounterViewModelProtocol {
    var incrementCounter: PassthroughSubject<Int, Never> { get }
    var counterStr: CurrentValueSubject<String, Never>! { get }
}

class CounterViewModel: CounterViewModelProtocol {
    var incrementCounter: PassthroughSubject<Int, Never> = .init()
    var counterStr: CurrentValueSubject<String, Never>!
    
    private var counter: Int = 0
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        counterStr = CurrentValueSubject("\(counter)")
        incrementCounter
            .sink(receiveValue: { [weak self] increment in
                if let self = self {
                    self.counter += increment
                    self.counterStr.send("\(self.counter)")
                }
            }).store(in: &cancellables)
    }
}
```

# ライブラリなしでテストする

[How to Test Your Combine Publishers](https://medium.com/better-programming/testing-your-combine-publishers-8ccd6bd151b)を参考にしました。
テスト補助用の`expectValue`というメソッドに`Publisher`と期待される値の配列を渡して、`wait`します。

```swift:CounterViewModelTests.swift
func testCounterStr() {
    let viewModel = CounterViewModel()        
    let expectValues = ["0", "2", "5"]
    let result = expectValue(of: viewModel.counterStr, equals: expectValues)
    viewModel.incrementCounter.send(2)
    viewModel.incrementCounter.send(3)
    wait(for: [result.expectation], timeout: 1)
}
```

テスト補助用のメソッド

```swift
extension XCTestCase {
    typealias CompetionResult = (expectation: XCTestExpectation, cancellable: AnyCancellable)
    func expectValue<T: Publisher>(
        of publisher: T,
        timeout: TimeInterval = 2,
        file: StaticString = #file,
        line: UInt = #line,
        equals: [T.Output]
    ) -> CompetionResult where T.Output: Equatable {
        let exp = expectation(description: "Correct values of " + String(describing: publisher))
        var mutableEquals = equals
        let cancellable = publisher
            .sink(receiveCompletion: { _ in },
                  receiveValue: { value in
                    if value == mutableEquals.first {
                        mutableEquals.remove(at: 0)
                        if mutableEquals.isEmpty {
                            exp.fulfill()
                        }
                    }
            })
        return (exp, cancellable)
    }
}
```

# Entwineを使ってテストする

テスト用に用意された`TestScheduler`を使って、テスト対象の`Subject`の`send`メソッド呼び出しのタイミングを設定したあと、`resume`メソッドを呼び出します。

`TestableSubscriber`をテスト対象の`Publisher`に`receive`することで、`TestableSubscriber`の`recordedOutput`にイベントが記録されます。

```swift
func testCounterStrWithEntWine() {
    let scheduler = TestScheduler(initialClock: 0)
    let incrementCounter = viewModel.incrementCounter
    scheduler.schedule(after: 100) { incrementCounter.send(2) }
    scheduler.schedule(after: 200) { incrementCounter.send(3) }
        
    let subscriber = scheduler.createTestableSubscriber(String.self, Never.self)
    viewModel.counterStr.receive(subscriber: subscriber)
        
    scheduler.resume()
        
    let expected: TestSequence<String, Never> = [
        (000, .subscription),
        (000, .input("0")),
        (100, .input("2")),
        (200, .input("5")),
    ]
        
    XCTAssertEqual(subscriber.recordedOutput, expected)
}
```

# 参考

[How to Test Your Combine Publishers](https://medium.com/better-programming/testing-your-combine-publishers-8ccd6bd151b)
[EntwineTest Reference](https://tcldr.github.io/Entwine/EntwineTestDocs/)