diff --git a/.github/workflows/VerifyChanges.yaml b/.github/workflows/VerifyChanges.yaml index 6dd9b10..ddc9d96 100644 --- a/.github/workflows/VerifyChanges.yaml +++ b/.github/workflows/VerifyChanges.yaml @@ -6,6 +6,9 @@ on: push: branches: ["main"] +env: + XCODE_VERSION: 26.0.1 + jobs: lint: name: Lint @@ -13,9 +16,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Select Xcode 26.0.0 - run: | - sudo xcode-select -s /Applications/Xcode_26.0.0.app + - name: Select Xcode ${{ env.XCODE_VERSION }} + run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app - name: Lint run: | Scripts/lint @@ -29,7 +31,7 @@ jobs: matrix: include: # - platform: iOS -# xcode_destination: "platform=iOS Simulator,name=iPhone 16 Pro" +# xcode_destination: "platform=iOS Simulator,name=iPhone 17 Pro" - platform: macOS xcode_destination: "platform=macOS,arch=arm64" # - platform: tvOS @@ -47,9 +49,8 @@ jobs: XCODE_TEST_PRODUCTS_PATH: .build/DevTesting.xctestproducts steps: - - name: Select Xcode 26.0.0 - run: | - sudo xcode-select -s /Applications/Xcode_26.0.0.app + - name: Select Xcode ${{ env.XCODE_VERSION }} + run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app - name: Checkout uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index ad93bd3..bef5b54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # DevTesting Changelog +## 1.4.0: October 12, 2025 + +`Stub` and `ThrowingStub` now conform to `Observable`. The only property that is tracked is +``calls``. Changes to dependent properties like ``callArguments`` and ``callResults`` can also be +tracked, but changes to ``resultQueue`` and ``defaultResult`` are not. + + ## 1.3.0: October 2, 2025 Adds functions for randomly generating dates within a specified range. diff --git a/Scripts/test-all-platforms b/Scripts/test-all-platforms index 2cf4155..f756cc8 100755 --- a/Scripts/test-all-platforms +++ b/Scripts/test-all-platforms @@ -23,7 +23,7 @@ print_error() { # Platforms to test PLATFORMS=( - "iOS Simulator,name=iPhone 16 Pro" + "iOS Simulator,name=iPhone 17 Pro" "macOS" "tvOS Simulator,name=Apple TV 4K" "watchOS Simulator,name=Apple Watch Series 10" diff --git a/Sources/DevTesting/Stubbing/Stub.swift b/Sources/DevTesting/Stubbing/Stub.swift index d5a2cf9..21740f5 100644 --- a/Sources/DevTesting/Stubbing/Stub.swift +++ b/Sources/DevTesting/Stubbing/Stub.swift @@ -9,14 +9,22 @@ import Foundation import os /// A stub for a function. +/// +/// - Note: ``Stub`` and `ThrowingStub` are `Observable`, but the only property that is tracked is ``calls``. Changes to +/// dependent properties like ``callArguments`` and ``callResults`` can also be tracked, but changes to +/// ``resultQueue`` and ``defaultResult`` are not. +@Observable public final class ThrowingStub where ErrorType: Error { /// A recorded call to the stub. - public struct Call { + /// + /// This type conforms to Sendable, but both properties are `nonisolated(unsafe)`. It is the consumer’s + /// responsibility to ensure that the type is used in a safe way. + public struct Call: Sendable { /// The call’s arguments. - public let arguments: Arguments + public nonisolated(unsafe) let arguments: Arguments /// The result of the call. - public let result: Result + public nonisolated(unsafe) let result: Result } @@ -88,13 +96,16 @@ public final class ThrowingStub where ErrorTyp /// /// If you just need the call’s arguments, you can use ``callArguments`` instead. public var calls: [Call] { + access(keyPath: \.calls) return mutableProperties.withLockUnchecked { $0.calls } } /// Clears the stub’s recorded calls. public func clearCalls() { - mutableProperties.withLockUnchecked { $0.calls = [] } + withMutation(keyPath: \.calls) { + mutableProperties.withLockUnchecked { $0.calls = [] } + } } @@ -105,19 +116,21 @@ public final class ThrowingStub where ErrorTyp /// /// - Parameter arguments: The arguments with which to call the stub. public func callAsFunction(_ arguments: Arguments) throws(ErrorType) -> ReturnType { - let result = mutableProperties.withLockUnchecked { (properties) in - let result = - if properties.resultQueue.isEmpty { - properties.defaultResult - } else { - properties.resultQueue.removeFirst() - } - - properties.calls.append( - .init(arguments: arguments, result: result) - ) - - return result + let result = withMutation(keyPath: \.calls) { + mutableProperties.withLockUnchecked { (properties) in + let result = + if properties.resultQueue.isEmpty { + properties.defaultResult + } else { + properties.resultQueue.removeFirst() + } + + properties.calls.append( + .init(arguments: arguments, result: result) + ) + + return result + } } return try result.get() diff --git a/Tests/DevTestingTests/Stubbing/StubTests.swift b/Tests/DevTestingTests/Stubbing/StubTests.swift index c0e6921..72fe9f9 100644 --- a/Tests/DevTestingTests/Stubbing/StubTests.swift +++ b/Tests/DevTestingTests/Stubbing/StubTests.swift @@ -195,4 +195,90 @@ struct StubTests { // queue just for posterity #expect(stub.returnValueQueue.isEmpty) } + + + // MARK: - Observable + + @Test + func callAsFunctionTriggersObservation() async throws { + // set up stub + let stub = Stub(defaultReturnValue: 42) + + // set up observations stream + let observations = Observations { stub.calls } + var iterator = observations.makeAsyncIterator() + + // expect initial empty calls + let initialCalls = await iterator.next() + #expect(initialCalls?.isEmpty == true) + + // exercise by calling stub + _ = stub("first") + + // expect calls updated with first call + let callsAfterFirst = await iterator.next() + #expect(callsAfterFirst?.count == 1) + #expect(callsAfterFirst?.first?.arguments == "first") + + // exercise by calling stub again + _ = stub("second") + + // expect calls updated with second call + let callsAfterSecond = await iterator.next() + #expect(callsAfterSecond?.count == 2) + #expect(callsAfterSecond?.last?.arguments == "second") + } + + + @Test + func clearCallsTriggersObservation() async throws { + // set up stub with some calls + let stub = Stub(defaultReturnValue: 42) + _ = stub("first") + _ = stub("second") + + // set up observations stream + let observations = Observations { stub.calls } + var iterator = observations.makeAsyncIterator() + + // expect initial calls + let initialCalls = await iterator.next() + #expect(initialCalls?.count == 2) + + // exercise by clearing calls + stub.clearCalls() + + // expect calls cleared + let clearedCalls = await iterator.next() + #expect(clearedCalls?.isEmpty == true) + } + + + @Test + mutating func observability() async { + let arguments = [1, 2, 3, 4, 5] + let stub = Stub() + + Task { + for value in arguments { + try? await Task.sleep(for: .milliseconds(100)) + stub(value) + } + } + + let observationTask = Task { + var i = 0 + for await argument in Observations({ stub.callArguments.last }).dropFirst() { + #expect(argument == arguments[i]) + i += 1 + + if argument == arguments.count { + break + } + } + } + + await observationTask.value + #expect(stub.callArguments == arguments) + } }