Swift中Mock TimerPublisher遇autoconnect调用失败问题及测试咨询
我正在测试使用Apple的Timer.publish(every:on:in:)的客户端代码,希望在单元测试中控制时间,避免使用wait(for:timeout:)实现同步测试。查阅文档得知TimerPublisher符合ConnectablePublisher协议,因此编写了Fake定时器及工厂的样板代码:
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() { } } }
但调用autoconnect()时触发报错:
FakeTimerFactory.makeTimer() .autoconnect() .sink { _ in print("Tick!") }
报错信息:
Member 'autoconnect' cannot be used on value of type 'any ConnectablePublisher<Date, Never>'; consider using a generic constraint instead
我希望这段代码能直接替代Timer.publish(every:on:in:)的使用,同时兼容RealTimerFactory以便在测试和生产代码间切换:
enum RealTimerFactory: TimerFactory { static func makeTimer() -> any ConnectablePublisher<Date, Never> { return Timer.publish(every: 1.0, on: .main, in: .default) } } final class UsesTimers { var timerFactory: TimerFactory.Type init(timerFactory: TimerFactory.Type = RealTimerFactory.self) { self.timerFactory = timerFactory } func doSomethingWithTimers() { timerFactory.makeTimer() .autoconnect() // 错误发生在这里 .sink { _ in print("Tick") } } }
我的问题:
- 为何
autoconnect()无法使用? - 有没有更好的测试
TimerPublisher的方法?
问题1:为何autoconnect()无法使用?
autoconnect()是ConnectablePublisher协议的泛型扩展方法,而你返回的any ConnectablePublisher<Date, Never>是类型擦除后的存在类型(existential type)。Swift的泛型扩展无法直接作用于存在类型——编译器无法确定具体的实现类型,也就无法解析泛型约束。
解决方法
方案1:给工厂协议添加泛型约束
修改TimerFactory协议,让makeTimer()返回具体的关联类型,避免类型擦除:
import Foundation import Combine protocol TimerFactory { associatedtype TimerPublisher: ConnectablePublisher where TimerPublisher.Output == Date, TimerPublisher.Failure == Never static func makeTimer() -> TimerPublisher } enum FakeTimerFactory: TimerFactory { static func makeTimer() -> FakeTimer { return FakeTimer() } } enum RealTimerFactory: TimerFactory { static func makeTimer() -> Timer.TimerPublisher { return Timer.publish(every: 1.0, on: .main, in: .default) } }
同时调整UsesTimers为泛型类:
final class UsesTimers<T: TimerFactory> { var timerFactory: T.Type private var cancellables = Set<AnyCancellable>() init(timerFactory: T.Type = RealTimerFactory.self) { self.timerFactory = timerFactory } func doSomethingWithTimers() { timerFactory.makeTimer() .autoconnect() .sink { _ in print("Tick") } .store(in: &cancellables) } }
方案2:在协议中封装autoconnect逻辑
如果不想用泛型,直接在协议中提供带autoconnect的方法,避免外部处理存在类型:
protocol TimerFactory { static func makeAutoConnectingTimer() -> AnyPublisher<Date, Never> } enum FakeTimerFactory: TimerFactory { static func makeAutoConnectingTimer() -> AnyPublisher<Date, Never> { return FakeTimer().autoconnect().eraseToAnyPublisher() } } enum RealTimerFactory: TimerFactory { static func makeAutoConnectingTimer() -> AnyPublisher<Date, Never> { return Timer.publish(every: 1.0, on: .main, in: .default) .autoconnect() .eraseToAnyPublisher() } }
调整UsesTimers类:
final class UsesTimers { var timerFactory: TimerFactory.Type private var cancellables = Set<AnyCancellable>() init(timerFactory: TimerFactory.Type = RealTimerFactory.self) { self.timerFactory = timerFactory } func doSomethingWithTimers() { timerFactory.makeAutoConnectingTimer() .sink { _ in print("Tick") } .store(in: &cancellables) } }
问题2:有没有更好的测试TimerPublisher的方法?
除了自定义Fake定时器,还有几种更高效的方式:
1. 使用Combine自带的TestScheduler
Combine的TestScheduler(需导入XCTest或CombineTesting)可以完全控制时间流逝,无需自定义Fake实现:
import XCTest import Combine class TimerTests: XCTestCase { func testTimerTicks() { let scheduler = TestScheduler(initialClock: 0) var ticks = [Date]() let timerPublisher = Timer.publish(every: 1.0, on: .main, in: .default) .autoconnect() .subscribe(on: scheduler) let cancellable = timerPublisher.sink { date in ticks.append(date) } // 快进1秒 scheduler.advance(by: 1.0) XCTAssertEqual(ticks.count, 1) // 再快进2秒 scheduler.advance(by: 2.0) XCTAssertEqual(ticks.count, 3) cancellable.cancel() } }
这种方式无需修改生产代码,仅在测试时替换调度器即可。
2. 封装定时器协议(灵活手动触发)
封装TimerProtocol,测试时用Fake实现手动触发事件:
protocol TimerProtocol { var publisher: AnyPublisher<Date, Never> { get } func triggerManually(with date: Date) } // 生产环境实现 class RealTimer: TimerProtocol { var publisher: AnyPublisher<Date, Never> init(every: TimeInterval, on runLoop: RunLoop, in mode: RunLoop.Mode) { publisher = Timer.publish(every: every, on: runLoop, in: mode) .autoconnect() .eraseToAnyPublisher() } func triggerManually(with date: Date) {} } // 测试环境实现 class FakeTimer: TimerProtocol { private let subject = PassthroughSubject<Date, Never>() var publisher: AnyPublisher<Date, Never> { subject.eraseToAnyPublisher() } func triggerManually(with date: Date = Date()) { subject.send(date) } }
生产代码注入TimerProtocol实例,测试时用FakeTimer手动控制事件触发。
3. 使用第三方库
比如Point-Free出品的CombineSchedulers,提供了更友好的调度器抽象,简化测试/生产环境的切换,支持更多场景。
内容的提问来源于stack exchange,提问作者grego

