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


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

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

  1. ライブラリなしでやる
  2. Entwineというテスト補助用のライブラリを使う

テスト対象コード

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

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

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を参考にしました。
テスト補助用のexpectValueというメソッドにPublisherと期待される値の配列を渡して、waitします。

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)
}

テスト補助用のメソッド

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を使って、テスト対象のSubjectsendメソッド呼び出しのタイミングを設定したあと、resumeメソッドを呼び出します。

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

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
EntwineTest Reference