Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions .github/workflows/VerifyChanges.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@ on:
push:
branches: ["main"]

env:
XCODE_VERSION: 26.0.1

jobs:
lint:
name: Lint
runs-on: macos-26
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
Expand All @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Scripts/test-all-platforms
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
47 changes: 30 additions & 17 deletions Sources/DevTesting/Stubbing/Stub.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arguments, ReturnType, ErrorType> 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<ReturnType, ErrorType>
public nonisolated(unsafe) let result: Result<ReturnType, ErrorType>
}


Expand Down Expand Up @@ -88,13 +96,16 @@ public final class ThrowingStub<Arguments, ReturnType, ErrorType> 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 = [] }
}
}


Expand All @@ -105,19 +116,21 @@ public final class ThrowingStub<Arguments, ReturnType, ErrorType> 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()
Expand Down
86 changes: 86 additions & 0 deletions Tests/DevTestingTests/Stubbing/StubTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Int>(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<String, Int>(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<Int, Void>()

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)
}
}