swiftunit-testingtimercombineexistential-type

How Do I Mock TimerPublisher in Swift? -- 'autoconnect' cannot be used on 'any ConnectablePublisher<Date, Never>'


I'm trying to test my client code which uses Apple's Timer.publish(every:on:in:).

I want to control time in my unit tests to avoid using wait(for:timeout:) and be able to test everything synchronously.

Checking out TimerPublisher in the docs reveals that it conforms to ConnectablePublisher, to abstract this away I made some boilerplate code for a Fake timer and factory:

import Foundation
import Combine

protocol TimerFactory {
    static func makeTimer() -> any ConnectablePublisher<Date, Never>
}

enum FakeTimerFactory: TimerFactory {
    static func makeTimer() -> any ConnectablePublisher<Date, Never> {
        return FakeTimer()
    }
}

struct FakeTimer: ConnectablePublisher {
    func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, Date == S.Input {
        
    }
    
    typealias Output = Date
    typealias Failure = Never
    
    func connect() -> Cancellable {
        return FakeCancellable()
    }
    
    struct FakeCancellable: Cancellable {
        func cancel() {
            
        }
    }
}

However, using the following sample code I'm unable to call the autoconnect() extension method on ConnectablePublisher:

FakeTimerFactory.makeTimer()
    .autoconnect()
    .sink { _ in
        print("Tick!")
    }

I get the following error:

Member 'autoconnect' cannot be used on value of type 'any ConnectablePublisher<Date, Never>'; consider using a generic constraint instead

For reference I want this code to be a drop-in replacement for Timer.publish(every:on:in:):

Timer.publish(every: 1.0, on: .main, in: .default)
    .autoconnect()
    .sink { _ in
        print("Tick!")
    }

I'll still need to figure out how to implement virtual time, but the current error seems to be an existential type problem.

Questions

  1. How come autoconnect() is unavailable?
  2. Is there a better way to test TimerPublisher?

Edits

The code should be compatible with a RealTimerFactory like so:

enum RealTimerFactory: TimerFactory {
    static func makeTimer() -> any ConnectablePublisher<Date, Never> {
        // these parameters are hard-coded for brevity
        // ideally we would pass this in as `makeTimer(every:on:in:)`
        return Timer.publish(every: 1.0, on: .main, in: .default)
    }
}

This way we can swap out out implementation between test and production code:

final class UsesTimers {
    var timerFactory: TimerFactory.Type
    
    init(timerFactory: TimerFactory.Type = RealTimerFactory.self) {
        self.timerFactory = timerFactory
    }
    
    func doSomethingWithTimers() {
        timerFactory.makeTimer()
            .autoconnect() // Error occurs here
            .sink { _ in
                print("Tick")
            }
    }
}

Solution

  • autoconnect is not available because its signature mentions Self:

    func autoconnect() -> Publishers.Autoconnect<Self>
    

    For an existential type, Self is unknown at compile time.


    You can easily get rid of the existential type by writing your own type eraser for connectable publishers, akin to AnyPublisher for publishers in general.

    struct AnyConnectablePublisher<Output, Failure: Error>: ConnectablePublisher {
        let upstream: any ConnectablePublisher<Output, Failure>
        
        func connect() -> any Cancellable {
            upstream.connect()
        }
        
        func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
            upstream.receive(subscriber: subscriber)
        }
        
        init(connectable upstream: any ConnectablePublisher<Output, Failure>) {
            self.upstream = upstream
        }
        
        init<Upstream>(publisher upstream: Upstream) where
            Self.Failure == Never,
            Upstream: Publisher,
            Upstream.Output == Self.Output,
            Upstream.Failure == Never
        {
            self.upstream = upstream.makeConnectable()
        }
        
    }
    

    For convenience, I have added an extra initialiser that wraps any general publisher, by using makeConnectable.

    Then the factory protocol's method can be declared to return AnyConnectablePublisher<Date, Never>. Here I have shown an example where the fake timer outputs the first 10 seconds of the year 1970.

    enum FakeTimerFactory: TimerFactory {
        static func makeTimer() -> AnyConnectablePublisher<Date, Never> {
            AnyConnectablePublisher(
                publisher: (0..<10).publisher
                    .flatMap(maxPublishers: .max(1)) { i in
                        Just(Date(timeIntervalSince1970: Double(i)))
                            .delay(for: 1, scheduler: RunLoop.main)
                    }
            )
        }
    }
    

    The real timer factory can just return

    AnyConnectablePublisher(connectable: Timer.publish(every: 1.0, on: .main, in: .default))
    

    In the context of testing, I would imagine you'd want to precisely control what gets published when. I think a more convenient approach would be to inject the AnyConnectablePublisher directly, instead of a factory. This way, you can inject a AnyConnectablePublisher that wraps a PassthroughSubject.

    let subject = PassthroughSubject<Date, Never>()
    let timer = AnyConnectablePublisher(publisher: subject)
    let thingToBeTested = ThingToBeTested(timer: timer)
    
    // now you can control the publishing by calling `subject.send(someDate)`
    

    Or you can make makeTimer an instance method, and inject an instance of the factory, instead of the metatype. Then the PassthroughSubject can live inside the factory instance.