5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

CombineのPublisherをテストするためのexpect/expectErrorメソッドを作成した

Posted at

おそらく現在はユニットテストでCombineのPublisherの値を検査するためにはXCTestExpectationを駆使したりしないといけません(簡便な方法があったら教えて欲しいです。)。さらに私はSwiftCheckを利用しているので検査結果をBool値で取得したいという要求があります。

ですのでテストターゲットでexpectとexpectErrorという検査メソッドをPublisherに追加しました。

import XCTest
import Combine

public extension Publisher where Output: Equatable {
    @discardableResult
    func expect(_ expectedValue: Output,
                takesNewest: Bool = false,
                timeout: TimeInterval = 2.0,
                fulfillmentCount: Int = 1,
                file: StaticString = #file,
                function: StaticString = #function,
                line: UInt = #line) -> Bool {
        var result                 = false
        var actualValues           = [Output]()
        var actualFulfillmentCount = 0
        var cancellables           = Set<AnyCancellable>()
        let exp                    = XCTestExpectation()
        exp.expectedFulfillmentCount = fulfillmentCount
        exp.assertForOverFulfill = true

        let waiter = XCTWaiter()

        self.receive(on: RunLoop.main).sink(receiveCompletion: { _ in }, receiveValue: {
            if takesNewest || !result {
                result = $0 == expectedValue
            }
            actualValues.append($0)
            actualFulfillmentCount += 1
            exp.fulfill()
        }).store(in: &cancellables)

        _ = waiter.wait(for: [exp], timeout: timeout)

        XCTAssertLessThanOrEqual(fulfillmentCount,
                                 actualFulfillmentCount,
                                 "\(file) - \(function):\(line): Expectation is short of fulfillment. '\(fulfillmentCount)' expected, but '\(actualFulfillmentCount)'.")

        XCTAssertTrue(result,
                      "\(file) - \(function):\(line): Expected output is '\(String(describing: expectedValue))', but actual stream is '\(String(describing: actualValues))'")

        return result && fulfillmentCount <= actualFulfillmentCount
    }
}

public extension Publisher where Failure: Equatable {
    @discardableResult
    func expectError(_ expectedError: Failure,
                     timeout: TimeInterval = 2.0,
                     file: StaticString = #file,
                     function: StaticString = #function,
                     line: UInt = #line) -> Bool {
        var actualError: Failure?
        var cancellables = Set<AnyCancellable>()
        let exp          = XCTestExpectation()

        let waiter = XCTWaiter()

        self.receive(on: RunLoop.main).sink(receiveCompletion: { completion in
            switch completion {
            case .failure(let receivedError):
                actualError = receivedError
            default: ()
            }

            exp.fulfill()
        }, receiveValue: { _ in }).store(in: &cancellables)

        _ = waiter.wait(for: [exp], timeout: timeout)

        XCTAssertEqual(expectedError, actualError,
                       "\(file) - \(function):\(line): Expected error is '\(String(describing: expectedError))', but actual error is '\(String(describing: actualError))'")

        return expectedError == actualError
    }
}

expectする値が取得できれば成功、できなければ失敗、そして結果のBool値を返すようになっています。
timeoutは値を取得する制限時間です。
expectのtakesNewestは、trueであればストリームの最新の値まで全て検査し、過去に該当する値があっても結果を上書きします。fulfillmentCountは何回値を取得するかを設定します。

テストで利用するには以下のように記述します。

class MyTests: XCTestCase {
    class P: Publisher {
        typealias Output = Int
        typealias Failure = Never
        
        func receive<S>(subscriber: S) where S : Subscriber, P.Failure == S.Failure, P.Output == S.Input {
            DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
                subscriber.receive(1)
                subscriber.receive(2)
                subscriber.receive(completion: .finished)
            }
            
            let subscription = Subscriptions.empty
            subscriber.receive(subscription: subscription)
        }
    }
    
    class Q: Publisher {
        typealias Output = Int
        typealias Failure = MyError
        
        func receive<S>(subscriber: S) where S : Subscriber, Q.Failure == S.Failure, Q.Output == S.Input {
            DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
                subscriber.receive(completion: .failure(.error1))
            }
            
            let subscription = Subscriptions.empty
            subscriber.receive(subscription: subscription)
        }
    }

    
    enum MyError: Error, Equatable {
        case error1
        case error2
    }


    func testExpect() {
        P().expect(1)
        P().buffer(size: 2, prefetch: .keepFull, whenFull: .dropOldest).collect().expect([1, 2])
        
        P().expect(1, takesNewest: true, fulfillmentCount: 2) // failed
        P().expect(3) // failed
    }
    
    func testExpectError() {
        Q().expectError(.error1)
    }
}

私のプロジェクトでは一応動作しているようです。
バグやより良い実装があれば教えてください。

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?