Skip to content

Commit ce2beac

Browse files
Prachi Gauriarprachigauriar
authored andcommitted
Add Observable conformance to ThrowingStub
1 parent b1e7ff0 commit ce2beac

5 files changed

Lines changed: 132 additions & 25 deletions

File tree

.github/workflows/VerifyChanges.yaml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@ on:
66
push:
77
branches: ["main"]
88

9+
env:
10+
XCODE_VERSION: 26.0.1
11+
912
jobs:
1013
lint:
1114
name: Lint
1215
runs-on: macos-26
1316
steps:
1417
- name: Checkout
1518
uses: actions/checkout@v4
16-
- name: Select Xcode 26.0.0
17-
run: |
18-
sudo xcode-select -s /Applications/Xcode_26.0.0.app
19+
- name: Select Xcode ${{ env.XCODE_VERSION }}
20+
run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
1921
- name: Lint
2022
run: |
2123
Scripts/lint
@@ -29,7 +31,7 @@ jobs:
2931
matrix:
3032
include:
3133
# - platform: iOS
32-
# xcode_destination: "platform=iOS Simulator,name=iPhone 16 Pro"
34+
# xcode_destination: "platform=iOS Simulator,name=iPhone 17 Pro"
3335
- platform: macOS
3436
xcode_destination: "platform=macOS,arch=arm64"
3537
# - platform: tvOS
@@ -47,9 +49,8 @@ jobs:
4749
XCODE_TEST_PRODUCTS_PATH: .build/DevTesting.xctestproducts
4850

4951
steps:
50-
- name: Select Xcode 26.0.0
51-
run: |
52-
sudo xcode-select -s /Applications/Xcode_26.0.0.app
52+
- name: Select Xcode ${{ env.XCODE_VERSION }}
53+
run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
5354

5455
- name: Checkout
5556
uses: actions/checkout@v4

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# DevTesting Changelog
22

3+
## 1.4.0: October 12, 2025
4+
5+
`Stub` and `ThrowingStub` now conform to `Observable`. The only property that is tracked is
6+
``calls``. Changes to dependent properties like ``callArguments`` and ``callResults`` can also be
7+
tracked, but changes to ``resultQueue`` and ``defaultResult`` are not.
8+
9+
310
## 1.3.0: October 2, 2025
411

512
Adds functions for randomly generating dates within a specified range.

Scripts/test-all-platforms

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ print_error() {
2323

2424
# Platforms to test
2525
PLATFORMS=(
26-
"iOS Simulator,name=iPhone 16 Pro"
26+
"iOS Simulator,name=iPhone 17 Pro"
2727
"macOS"
2828
"tvOS Simulator,name=Apple TV 4K"
2929
"watchOS Simulator,name=Apple Watch Series 10"

Sources/DevTesting/Stubbing/Stub.swift

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,22 @@ import Foundation
99
import os
1010

1111
/// A stub for a function.
12+
///
13+
/// - Note: ``Stub`` and `ThrowingStub` are `Observable`, but the only property that is tracked is ``calls``. Changes to
14+
/// dependent properties like ``callArguments`` and ``callResults`` can also be tracked, but changes to
15+
/// ``resultQueue`` and ``defaultResult`` are not.
16+
@Observable
1217
public final class ThrowingStub<Arguments, ReturnType, ErrorType> where ErrorType: Error {
1318
/// A recorded call to the stub.
14-
public struct Call {
19+
///
20+
/// This type conforms to Sendable, but both properties are `nonisolated(unsafe)`. It is the consumer’s
21+
/// responsibility to ensure that the type is used in a safe way.
22+
public struct Call: Sendable {
1523
/// The call’s arguments.
16-
public let arguments: Arguments
24+
public nonisolated(unsafe) let arguments: Arguments
1725

1826
/// The result of the call.
19-
public let result: Result<ReturnType, ErrorType>
27+
public nonisolated(unsafe) let result: Result<ReturnType, ErrorType>
2028
}
2129

2230

@@ -88,13 +96,16 @@ public final class ThrowingStub<Arguments, ReturnType, ErrorType> where ErrorTyp
8896
///
8997
/// If you just need the call’s arguments, you can use ``callArguments`` instead.
9098
public var calls: [Call] {
99+
access(keyPath: \.calls)
91100
return mutableProperties.withLockUnchecked { $0.calls }
92101
}
93102

94103

95104
/// Clears the stub’s recorded calls.
96105
public func clearCalls() {
97-
mutableProperties.withLockUnchecked { $0.calls = [] }
106+
withMutation(keyPath: \.calls) {
107+
mutableProperties.withLockUnchecked { $0.calls = [] }
108+
}
98109
}
99110

100111

@@ -105,19 +116,21 @@ public final class ThrowingStub<Arguments, ReturnType, ErrorType> where ErrorTyp
105116
///
106117
/// - Parameter arguments: The arguments with which to call the stub.
107118
public func callAsFunction(_ arguments: Arguments) throws(ErrorType) -> ReturnType {
108-
let result = mutableProperties.withLockUnchecked { (properties) in
109-
let result =
110-
if properties.resultQueue.isEmpty {
111-
properties.defaultResult
112-
} else {
113-
properties.resultQueue.removeFirst()
114-
}
115-
116-
properties.calls.append(
117-
.init(arguments: arguments, result: result)
118-
)
119-
120-
return result
119+
let result = withMutation(keyPath: \.calls) {
120+
mutableProperties.withLockUnchecked { (properties) in
121+
let result =
122+
if properties.resultQueue.isEmpty {
123+
properties.defaultResult
124+
} else {
125+
properties.resultQueue.removeFirst()
126+
}
127+
128+
properties.calls.append(
129+
.init(arguments: arguments, result: result)
130+
)
131+
132+
return result
133+
}
121134
}
122135

123136
return try result.get()

Tests/DevTestingTests/Stubbing/StubTests.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,90 @@ struct StubTests {
195195
// queue just for posterity
196196
#expect(stub.returnValueQueue.isEmpty)
197197
}
198+
199+
200+
// MARK: - Observable
201+
202+
@Test
203+
func callAsFunctionTriggersObservation() async throws {
204+
// set up stub
205+
let stub = Stub<String, Int>(defaultReturnValue: 42)
206+
207+
// set up observations stream
208+
let observations = Observations { stub.calls }
209+
var iterator = observations.makeAsyncIterator()
210+
211+
// expect initial empty calls
212+
let initialCalls = await iterator.next()
213+
#expect(initialCalls?.isEmpty == true)
214+
215+
// exercise by calling stub
216+
_ = stub("first")
217+
218+
// expect calls updated with first call
219+
let callsAfterFirst = await iterator.next()
220+
#expect(callsAfterFirst?.count == 1)
221+
#expect(callsAfterFirst?.first?.arguments == "first")
222+
223+
// exercise by calling stub again
224+
_ = stub("second")
225+
226+
// expect calls updated with second call
227+
let callsAfterSecond = await iterator.next()
228+
#expect(callsAfterSecond?.count == 2)
229+
#expect(callsAfterSecond?.last?.arguments == "second")
230+
}
231+
232+
233+
@Test
234+
func clearCallsTriggersObservation() async throws {
235+
// set up stub with some calls
236+
let stub = Stub<String, Int>(defaultReturnValue: 42)
237+
_ = stub("first")
238+
_ = stub("second")
239+
240+
// set up observations stream
241+
let observations = Observations { stub.calls }
242+
var iterator = observations.makeAsyncIterator()
243+
244+
// expect initial calls
245+
let initialCalls = await iterator.next()
246+
#expect(initialCalls?.count == 2)
247+
248+
// exercise by clearing calls
249+
stub.clearCalls()
250+
251+
// expect calls cleared
252+
let clearedCalls = await iterator.next()
253+
#expect(clearedCalls?.isEmpty == true)
254+
}
255+
256+
257+
@Test
258+
mutating func observability() async {
259+
let arguments = [1, 2, 3, 4, 5]
260+
let stub = Stub<Int, Void>()
261+
262+
Task {
263+
for value in arguments {
264+
try? await Task.sleep(for: .milliseconds(100))
265+
stub(value)
266+
}
267+
}
268+
269+
let observationTask = Task {
270+
var i = 0
271+
for await argument in Observations({ stub.callArguments.last }).dropFirst() {
272+
#expect(argument == arguments[i])
273+
i += 1
274+
275+
if argument == arguments.count {
276+
break
277+
}
278+
}
279+
}
280+
281+
await observationTask.value
282+
#expect(stub.callArguments == arguments)
283+
}
198284
}

0 commit comments

Comments
 (0)