diff --git a/Sources/Fakes/Pendable.swift b/Sources/Fakes/Pendable.swift deleted file mode 100644 index 8f8b09b..0000000 --- a/Sources/Fakes/Pendable.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation - -/// Default values for use with Pendable. -public final class PendableDefaults: @unchecked Sendable { - public static let shared = PendableDefaults() - private let lock = NSLock() - - public init() {} - - public static var delay: TimeInterval { - get { - PendableDefaults.shared.delay - } - set { - PendableDefaults.shared.delay = newValue - } - } - - private var _delay: TimeInterval = 1 - public var delay: TimeInterval { - get { - lock.lock() - defer { lock.unlock() } - return _delay - } - set { - lock.lock() - _delay = newValue - lock.unlock() - } - } -} - -/// Pendable represents the 2 states that an asynchronous call can be in -/// -/// - `pending`, the state while waiting for some asynchronous call to finish -/// - `finished`, the state once an asynchronous call has finished. -/// -/// Oftentimes, async calls also throw. For that, use the `ThrowingPendable` type. -/// `ThrowingPendable` is a typealias for when `Value` is -/// a `Result`. -/// -/// Pendable is a static value, there is no way to dynamically resolve a Pendable. -/// This is because to allow you to resolve the call whenever you want is -/// the equivalent of forcing Swift Concurrency to wait until some other function returns. -/// Which is possible, but incredibly tricky and very prone to deadlocks. -/// Using a Static value for Pendable enables us to essentially cheat that. -public enum Pendable { - /// an in-progress call state - /// - /// The associated value is a fallback value. - case pending(fallback: Value) - - /// a finished call state - case finished(Value) - - public func resolve( - delay: TimeInterval = PendableDefaults.delay - ) async -> Value { - switch self { - case .pending(let fallback): - _ = try? await Task.sleep( - nanoseconds: UInt64(1_000_000_000 * delay) - ) - return fallback - case .finished(let value): - return value - } - } - - public func resolve( - delay: TimeInterval = PendableDefaults.delay - ) async throws -> Success where Value == Result { - switch self { - case .pending(let fallback): - _ = try? await Task.sleep( - nanoseconds: UInt64(1_000_000_000 * delay) - ) - return try fallback.get() - case .finished(let value): - return try value.get() - } - } -} - -public typealias ThrowingPendable< - Success, - Failure: Error -> = Pendable> - -extension Pendable: Sendable where Value: Sendable {} diff --git a/Sources/Fakes/Pendable/Pendable.swift b/Sources/Fakes/Pendable/Pendable.swift new file mode 100644 index 0000000..8981119 --- /dev/null +++ b/Sources/Fakes/Pendable/Pendable.swift @@ -0,0 +1,178 @@ +import Foundation + +protocol ResolvableWithFallback { + func resolveWithFallback() +} + +/// Pendable is a safe way to represent the 2 states that an asynchronous call can be in +/// +/// - `pending`, the state while waiting for the call to finish. +/// - `finished`, the state once the call has finished. +/// +/// Pendable allows you to finish a pending call after it's been made. This makes Pendable behave very +/// similarly to something like Combine's `Future`. +/// +/// - Note: The reason you must provide a fallback value is to prevent deadlock when used in test. +/// Unlike something like Combine's `Future`, it is very often the case that you will write +/// tests which end while the call is in the pending state. If you do this too much, then your +/// entire test suite will deadlock, as Swift Concurrency works under the assumption that +/// blocked tasks of work will always eventually be unblocked. To help prevent this, pending calls +/// are always resolved with the fallback after a given delay. You can also manually force this +/// by calling the ``Pendable\resolveWithFallback()`` method. +public final class Pendable: @unchecked Sendable, ResolvableWithFallback { + private enum State: Sendable { + case pending + case finished(Value) + } + + private let lock = NSRecursiveLock() + private var state = State.pending + + private var inProgressCalls = [UnsafeContinuation]() + + private let fallbackValue: Value + + private var currentValue: Value { + switch state { + case .pending: + return fallbackValue + case .finished(let value): + return value + } + } + + deinit { + resolveWithFallback() + } + + /// Initializes a new `Pendable`, in a pending state, with the given fallback value. + public init(fallbackValue: Value) { + self.fallbackValue = fallbackValue + } + + /// Gets the value for the `Pendable`, possibly waiting until it's resolved. + /// + /// - parameter fallbackDelay: The amount of time (in seconds) to wait until the call returns + /// the fallback value. This is only used when the `Pendable` is in a pending state. + public func call(fallbackDelay: TimeInterval = PendableDefaults.delay) async -> Value { + return await withTaskGroup(of: Value.self) { taskGroup in + taskGroup.addTask { await self.handleCall() } + taskGroup.addTask { await self.resolveAfterDelay(fallbackDelay) } + + guard let value = await taskGroup.next() else { + fatalError("There were no tasks in the task group. This should not ever happen.") + } + taskGroup.cancelAll() + return value + + } + } + + /// Resolves the `Pendable` with the fallback value. + /// + /// - Note: This no-ops if the pendable is already in a resolved state. + /// - Note: This is called for when you re-stub a `Spy` in ``Spy/stub(_:)`` + public func resolveWithFallback() { + lock.lock() + defer { lock.unlock() } + + if case .pending = state { + resolve(with: fallbackValue) + } + } + + /// Resolves the `Pendable` with the given value. + /// + /// Even if the pendable is already resolves, this resets the resolved value to the given value. + public func resolve(with value: Value) { + lock.lock() + defer { lock.unlock() } + state = .finished(value) + inProgressCalls.forEach { + $0.resume(returning: value) + } + inProgressCalls = [] + + } + + /// Resolves any outstanding calls to the `Pendable` with the current value, + /// and resets it back into the pending state. + public func reset() { + lock.lock() + defer { lock.unlock() } + + inProgressCalls.forEach { + $0.resume(returning: currentValue) + } + inProgressCalls = [] + state = .pending + } + + // MARK: - Private + private func handleCall() async -> Value { + return await withUnsafeContinuation { continuation in + lock.lock() + defer { lock.unlock() } + switch state { + case .pending: + inProgressCalls.append(continuation) + case .finished(let value): + continuation.resume(returning: value) + } + } + } + + private func resolveAfterDelay(_ delay: TimeInterval) async -> Value { + do { + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } catch {} + resolveWithFallback() + return fallbackValue + } +} + +public typealias ThrowingDynamicPendable = Pendable> + +extension Pendable { + /// Gets or throws value for the `Pendable`, possibly waiting until it's resolved. + /// + /// - parameter resolveDelay: The amount of time (in seconds) to wait until the call returns + /// the fallback value. This is only used when the `Pendable` is in a pending state. + public func call( + resolveDelay: TimeInterval = PendableDefaults.delay + ) async throws -> Success where Value == Result { + try await call(fallbackDelay: resolveDelay).get() + } +} + +extension Pendable { + /// Creates a new finished `Pendable` pre-resolved with the given value. + public static func finished(_ value: Value) -> Pendable { + let pendable = Pendable(fallbackValue: value) + pendable.resolve(with: value) + return pendable + } + + /// Creates a new finished `Pendable` pre-resolved with Void. + public static func finished() -> Pendable where Value == Void { + return Pendable.finished(()) + } +} + +extension Pendable { + /// Creates a new pending `Pendable` with the given fallback value. + public static func pending(fallback: Value) -> Pendable { + return Pendable(fallbackValue: fallback) + } + + /// Creates a new pending `Pendable` with a fallback value of Void. + public static func pending() -> Pendable where Value == Void { + return Pendable(fallbackValue: ()) + } + + /// Creates a new pending `Pendable` with a fallback value of nil. + public static func pending() -> Pendable where Value == Optional { + // swiftlint:disable:previous syntactic_sugar + return Pendable(fallbackValue: nil) + } +} diff --git a/Sources/Fakes/Pendable/PendableDefaults.swift b/Sources/Fakes/Pendable/PendableDefaults.swift new file mode 100644 index 0000000..33ec0e9 --- /dev/null +++ b/Sources/Fakes/Pendable/PendableDefaults.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Default values for use with Pendable. +public final class PendableDefaults: @unchecked Sendable { + public static let shared = PendableDefaults() + private let lock = NSLock() + + public init() {} + + /// The amount of time to delay before resolving a pending Pendable with the fallback value. + /// By default this is 2 seconds. Conveniently, just long enough to be twice Nimble's default polling timeout. + /// In general, you should keep this set to some number greater than Nimble's default polling timeout, + /// in order to allow polling matchers to work correctly. + public static var delay: TimeInterval { + get { + PendableDefaults.shared.delay + } + set { + PendableDefaults.shared.delay = newValue + } + } + + private var _delay: TimeInterval = 2 + public var delay: TimeInterval { + get { + lock.lock() + defer { lock.unlock() } + return _delay + } + set { + lock.lock() + _delay = newValue + lock.unlock() + } + } +} diff --git a/Sources/Fakes/Spy+Nimble.swift b/Sources/Fakes/Spy/Spy+Nimble.swift similarity index 100% rename from Sources/Fakes/Spy+Nimble.swift rename to Sources/Fakes/Spy/Spy+Nimble.swift diff --git a/Sources/Fakes/Spy+StaticPendable.swift b/Sources/Fakes/Spy/Spy+Pendable.swift similarity index 63% rename from Sources/Fakes/Spy+StaticPendable.swift rename to Sources/Fakes/Spy/Spy+Pendable.swift index f7cca8a..337d61a 100644 --- a/Sources/Fakes/Spy+StaticPendable.swift +++ b/Sources/Fakes/Spy/Spy+Pendable.swift @@ -19,6 +19,13 @@ extension Spy { } } +extension Spy { + /// Resolve the pendable Spy's stub with Void + public func resolveStub() where Returning == Pendable { + self.resolveStub(with: ()) + } +} + extension Spy { /// Update the pendable Spy's stub to be in a pending state. public func stub(pendingFallback: Value) where Returning == Pendable { @@ -30,6 +37,12 @@ extension Spy { self.stub(.pending(fallback: ())) } + /// Update the pendable Spy's stub to be in a pending state. + public func stubPending() where Returning == Pendable> { + // swiftlint:disable:previous syntactic_sugar + self.stub(.pending(fallback: nil)) + } + /// Update the pendable Spy's stub to return the given value. /// /// - parameter finished: The value to return when `callAsFunction` is called. @@ -44,31 +57,25 @@ extension Spy { } extension Spy { - /// Records the arguments and handles the result according to ``Pendable/resolve(delay:)-hvhg``. + /// Records the arguments and handles the result according to ``Pendable/call(fallbackDelay:)``. /// /// - parameter arguments: The arguments to record. - /// - parameter pendingDelay: The amount of seconds to delay if the `Pendable` is .pending before - /// returning the `pendingFallback`. If the `Pendable` is .finished, then this value is ignored. - /// - /// Because of how ``Pendable`` currently works, you must provide a fallback option for when the Pendable is pending. - /// Alternatively, you can use the throwing version of `callAsFunction`, which will thorw an error instead of returning the fallback. + /// - parameter fallbackDelay: The amount of seconds to delay if the `Pendable` is pending before + /// returning its fallback value. If the `Pendable` is finished, then this value is ignored. public func callAsFunction( _ arguments: Arguments, - pendingDelay: TimeInterval = PendableDefaults.delay + fallbackDelay: TimeInterval = PendableDefaults.delay ) async -> Value where Returning == Pendable { - return await call(arguments).resolve(delay: pendingDelay) + return await call(arguments).call(fallbackDelay: fallbackDelay) } - /// Records that a call was made and handles the result according to ``Pendable/resolve(delay:)-hvhg``. - /// - /// - parameter pendingDelay: The amount of seconds to delay if the `Pendable` is .pending before - /// returning the `pendingFallback`. If the `Pendable` is .finished, then this value is ignored. + /// Records that a call was made and handles the result according to ``Pendable/call(fallbackDelay:)``. /// - /// Because of how ``Pendable`` currently works, you must provide a fallback option for when the Pendable is pending. - /// Alternatively, you can use the throwing version of `callAsFunction`, which will thorw an error instead of returning the fallback. + /// - parameter fallbackDelay: The amount of seconds to delay if the `Pendable` is pending before + /// returning its fallback value. If the `Pendable` is finished, then this value is ignored. public func callAsFunction( - pendingDelay: TimeInterval = PendableDefaults.delay + fallbackDelay: TimeInterval = PendableDefaults.delay ) async -> Value where Arguments == Void, Returning == Pendable { - return await call(()).resolve(delay: pendingDelay) + return await call(()).call(fallbackDelay: fallbackDelay) } } diff --git a/Sources/Fakes/Spy+Result.swift b/Sources/Fakes/Spy/Spy+Result.swift similarity index 100% rename from Sources/Fakes/Spy+Result.swift rename to Sources/Fakes/Spy/Spy+Result.swift diff --git a/Sources/Fakes/Spy+StaticThrowingPendable.swift b/Sources/Fakes/Spy/Spy+ThrowingPendable.swift similarity index 52% rename from Sources/Fakes/Spy+StaticThrowingPendable.swift rename to Sources/Fakes/Spy/Spy+ThrowingPendable.swift index 36bf885..f7c15b1 100644 --- a/Sources/Fakes/Spy+StaticThrowingPendable.swift +++ b/Sources/Fakes/Spy/Spy+ThrowingPendable.swift @@ -1,83 +1,95 @@ import Foundation -public typealias ThrowingPendableSpy = Spy> +public typealias ThrowingPendableSpy = Spy> extension Spy { /// Create a throwing pendable Spy that is pre-stubbed to return a pending that will block for a bit before returning success. - public convenience init(pendingSuccess: Success) where Returning == ThrowingPendable { + public convenience init(pendingSuccess: Success) where Returning == ThrowingDynamicPendable { self.init(.pending(fallback: .success(pendingSuccess))) } /// Create a throwing pendable Spy that is pre-stubbed to return a pending that will block for a bit before returning Void. - public convenience init() where Returning == ThrowingPendable<(), Failure> { + public convenience init() where Returning == ThrowingDynamicPendable<(), Failure> { self.init(.pending(fallback: .success(()))) } /// Create a throwing pendable Spy that is pre-stubbed to return a pending that will block for a bit before throwing an error. - public convenience init(pendingFailure: Failure) where Returning == ThrowingPendable { + public convenience init(pendingFailure: Failure) where Returning == ThrowingDynamicPendable { self.init(.pending(fallback: .failure(pendingFailure))) } /// Create a throwing pendable Spy that is pre-stubbed to return a finished & successful value. - public convenience init(success: Success) where Returning == ThrowingPendable { + public convenience init(success: Success) where Returning == ThrowingDynamicPendable { self.init(.finished(.success(success))) } /// Create a throwing pendable Spy that is pre-stubbed to throw the given error. - public convenience init(failure: Failure) where Returning == ThrowingPendable { + public convenience init(failure: Failure) where Returning == ThrowingDynamicPendable { self.init(.finished(.failure(failure))) } } +extension Spy { + /// Resolve the pendable Spy's stub with the success value. + public func resolveStub(success: Success) where Returning == ThrowingDynamicPendable { + self.resolveStub(with: .success(success)) + } + + /// Resolve the pendable spy's stub with the given error + public func resolveStub(failure: Failure) where Returning == ThrowingDynamicPendable { + self.resolveStub(with: .failure(failure)) + } +} + extension Spy { /// Update the pendable Spy's stub to be in a pending state. - public func stub(pendingSuccess: Success) where Returning == ThrowingPendable { + public func stub(pendingSuccess: Success) where Returning == ThrowingDynamicPendable { self.stub(.pending(fallback: .success(pendingSuccess))) } /// Update the pendable Spy's stub to be in a pending state. - public func stub(pendingFailure: Failure) where Returning == ThrowingPendable { + public func stub(pendingFailure: Failure) where Returning == ThrowingDynamicPendable { self.stub(.pending(fallback: .failure(pendingFailure))) } /// Update the throwing pendable Spy's stub to be successful, with the given value. /// /// - parameter success: The value to return when `callAsFunction` is called. - public func stub(success: Success) where Returning == ThrowingPendable { + public func stub(success: Success) where Returning == ThrowingDynamicPendable { self.stub(.finished(.success(success))) } /// Update the throwing pendable Spy's stub to throw the given error. /// /// - parameter failure: The error to throw when `callAsFunction` is called. - public func stub(failure: Failure) where Returning == ThrowingPendable { + public func stub(failure: Failure) where Returning == ThrowingDynamicPendable { self.stub(.finished(.failure(failure))) } } extension Spy { // Returning == ThrowingPendable - /// Records the arguments and handles the result according to ``Pendable/resolve(delay:)-1bb25``. + /// Records the arguments and handles the result according to ``Pendable/call(fallbackDelay:)``. /// This call then throws or returns the success, according to `Result.get`. /// /// - parameter arguments: The arguments to record. /// - parameter pendingDelay: The amount of seconds to delay if the `Pendable` is .pending before /// throwing a `PendableInProgressError`. If the `Pendable` is .finished, then this value is ignored. - public func callAsFunction( + public func callAsFunction( _ arguments: Arguments, - pendingDelay: TimeInterval = PendableDefaults.delay - ) async throws -> Success where Returning == ThrowingPendable { - return try await call(arguments).resolve(delay: pendingDelay) + fallbackDelay: TimeInterval = PendableDefaults.delay + ) async throws -> Success where Returning == ThrowingDynamicPendable { + return try await call(arguments).call(fallbackDelay: fallbackDelay).get() } - /// Records that a call was made and handles the result according to ``Pendable/resolve(delay:)-1bb25``. + /// Records that a call was made and handles the result according to ``Pendable/call(fallbackDelay:)``. /// This call then throws or returns the success, according to `Result.get`. /// /// - parameter pendingDelay: The amount of seconds to delay if the `Pendable` is .pending before /// throwing a `PendableInProgressError`. If the `Pendable` is .finished, then this value is ignored. - public func callAsFunction( - pendingDelay: TimeInterval = PendableDefaults.delay - ) async throws -> Success where Arguments == Void, Returning == ThrowingPendable { - return try await call(()).resolve(delay: pendingDelay) + public func callAsFunction( + fallbackDelay: TimeInterval = PendableDefaults.delay + ) async throws -> Success where Arguments == Void, Returning == ThrowingDynamicPendable { + return try await call(()).call(fallbackDelay: fallbackDelay).get() } } diff --git a/Sources/Fakes/Spy.swift b/Sources/Fakes/Spy/Spy.swift similarity index 80% rename from Sources/Fakes/Spy.swift rename to Sources/Fakes/Spy/Spy.swift index 5e715df..cde5c3b 100644 --- a/Sources/Fakes/Spy.swift +++ b/Sources/Fakes/Spy/Spy.swift @@ -27,6 +27,12 @@ public final class Spy { self.init(()) } + /// Create a Spy that returns nil + public convenience init() where Returning == Optional { + // swiftlint:disable:previous syntactic_sugar + self.init(nil) + } + /// Clear out existing call records. /// /// This removes all previously recorded calls from the spy. It does not otherwise @@ -43,6 +49,10 @@ public final class Spy { /// - parameter value: The value to return when `callAsFunction()` is called. public func stub(_ value: Returning) { lock.lock() + + if let resolvable = _stub as? ResolvableWithFallback { + resolvable.resolveWithFallback() + } _stub = value lock.unlock() } @@ -70,4 +80,12 @@ extension Spy { } } +extension Spy { + public func resolveStub(with value: Value) where Returning == Pendable { + lock.lock() + defer { lock.unlock() } + _stub.resolve(with: value) + } +} + extension Spy: @unchecked Sendable where Arguments: Sendable, Returning: Sendable {} diff --git a/Tests/FakesTests/PendableTests.swift b/Tests/FakesTests/PendableTests.swift new file mode 100644 index 0000000..3d02755 --- /dev/null +++ b/Tests/FakesTests/PendableTests.swift @@ -0,0 +1,52 @@ +import Fakes +import Nimble +import XCTest + +final class PendableTests: XCTestCase { + func testSingleCall() async throws { + let subject = Pendable.pending(fallback: 0) + + async let result = subject.call() + + try await Task.sleep(nanoseconds: UInt64(0.01 * 1_000_000_000)) + + subject.resolve(with: 2) + + let value = await result + expect(value).to(equal(2)) + } + + func testMultipleCalls() async throws { + let subject = Pendable.pending(fallback: 0) + + async let result = withTaskGroup(of: Int.self, returning: [Int].self) { taskGroup in + for _ in 0..<100 { + taskGroup.addTask { await subject.call() } + } + + var results = [Int]() + for await value in taskGroup { + results.append(value) + } + return results + } + + try await Task.sleep(nanoseconds: UInt64(0.1 * 1_000_000_000)) + + subject.resolve(with: 3) + + let value = await result + expect(value).to(equal(Array(repeating: 3, count: 100))) + } + + func testAutoresolve() async { + let subject = Pendable.pending(fallback: 3) + + await waitUntil(timeout: .milliseconds(500)) { done in + Task { + _ = await subject.call(fallbackDelay: 0.1) + done() + } + } + } +} diff --git a/Tests/FakesTests/SpyTests.swift b/Tests/FakesTests/SpyTests.swift index 703247f..096f04f 100644 --- a/Tests/FakesTests/SpyTests.swift +++ b/Tests/FakesTests/SpyTests.swift @@ -94,20 +94,20 @@ final class SpyTests: XCTestCase { let subject = PendableSpy(pendingFallback: 1) await expect { - await subject(pendingDelay: 0) + await subject(fallbackDelay: 0) }.toEventually(equal(1)) subject.stub(finished: 4) await expect { - await subject(pendingDelay: 0) + await subject(fallbackDelay: 0) }.toEventually(equal(4)) } func testPendableTakesNonVoidArguments() async throws { let subject = PendableSpy(finished: ()) - await subject(3, pendingDelay: 0) + await subject(3, fallbackDelay: 0) expect(subject.calls).to(equal([3])) } @@ -116,25 +116,25 @@ final class SpyTests: XCTestCase { let subject = ThrowingPendableSpy(pendingSuccess: 0) await expect { - try await subject(pendingDelay: 0) + try await subject(fallbackDelay: 0) }.toEventually(equal(0)) subject.stub(success: 5) await expect { - try await subject(pendingDelay: 0) + try await subject(fallbackDelay: 0) }.toEventually(equal(5)) subject.stub(failure: TestError.uhOh) await expect { - try await subject(pendingDelay: 0) + try await subject(fallbackDelay: 0) }.toEventually(throwError(TestError.uhOh)) } func testThrowingPendableTakesNonVoidArguments() async throws { let subject = ThrowingPendableSpy(success: ()) - try await subject(8, pendingDelay: 0) + try await subject(8, fallbackDelay: 0) expect(subject.calls).to(equal([8])) } @@ -148,6 +148,98 @@ final class SpyTests: XCTestCase { subject.clearCalls() expect(subject.calls).to(beEmpty()) } + + func testDynamicPendable() async { + let subject = Spy>() + + let managedTask = await ManagedTask.running { + await subject() + } + + await expect { await managedTask.isFinished }.toNever(beTrue()) + + subject.resolveStub(with: ()) + + await expect { await managedTask.isFinished }.toEventually(beTrue()) + } + + func testDynamicPendableDeinit() async { + let subject = Spy>() + + let managedTask = await ManagedTask.running { + await subject() + } + + await expect { await managedTask.hasStarted }.toEventually(beTrue()) + + subject.stub(Pendable.pending()) + subject.resolveStub(with: ()) + + await expect { await managedTask.isFinished }.toEventually(beTrue()) + } +} + +actor ManagedTask { + var hasStarted = false + var isFinished = false + + var task: Task! + + static func running(closure: @escaping @Sendable () async throws -> Success) async -> ManagedTask where Failure == Error { + let task = ManagedTask() + + await task.run(closure: closure) + + return task + } + + static func running(closure: @escaping @Sendable () async -> Success) async -> ManagedTask where Failure == Never { + let task = ManagedTask() + + await task.run(closure: closure) + + return task + } + + private init() {} + + private func run(closure: @escaping @Sendable () async throws -> Success) where Failure == Error { + task = Task { + self.recordStarted() + let result = try await closure() + self.recordFinished() + return result + } + } + + private func run(closure: @escaping @Sendable () async -> Success) where Failure == Never { + task = Task { + self.recordStarted() + let result = await closure() + self.recordFinished() + return result + } + } + + private func recordStarted() { + self.hasStarted = true + } + + private func recordFinished() { + self.isFinished = true + } + + var result: Result { + get async { + await task.result + } + } + + var value: Success { + get async throws { + try await task.value + } + } } enum TestError: Error {