Skip to content

Commit 1ccbe4c

Browse files
committed
[SWT-NNNN] Range-based confirmations
Swift Testing includes [an interface](https://swiftpackageindex.com/swiftlang/swift-testing/main/documentation/testing/confirmation(_:expectedcount:isolation:sourcelocation:_:)) for checking that some asynchronous event occurs a given number of times (typically exactly once or never at all.) This proposal enhances that interface to allow arbitrary ranges of event counts so that a test can be written against code that may not always fire said event the exact same number of times. Read the full proposal [here]().
1 parent 404f121 commit 1ccbe4c

File tree

13 files changed

+358
-79
lines changed

13 files changed

+358
-79
lines changed
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# Range-based confirmations
2+
3+
* Proposal: [SWT-NNNN](NNNN-ranged-confirmations.md)
4+
* Authors: [Jonathan Grynspan](https://github.com/grynspan)
5+
* Status: **Awaiting review**
6+
* Implementation: [swiftlang/swift-testing#598](https://github.com/swiftlang/swift-testing/pull/598), [swiftlang/swift-testing#689](https://github.com/swiftlang/swift-testing/pull689)
7+
* Review: TBD
8+
9+
## Introduction
10+
11+
Swift Testing includes [an interface](https://swiftpackageindex.com/swiftlang/swift-testing/main/documentation/testing/confirmation(_:expectedcount:isolation:sourcelocation:_:))
12+
for checking that some asynchronous event occurs a given number of times
13+
(typically exactly once or never at all.) This proposal enhances that interface
14+
to allow arbitrary ranges of event counts so that a test can be written against
15+
code that may not always fire said event the exact same number of times.
16+
17+
## Motivation
18+
19+
Some tests rely on fixtures or external state that is not perfectly
20+
deterministic. For example, consider a test that checks that clicking the mouse
21+
button will generate a `.mouseClicked` event. Such a test might use the
22+
`confirmation()` interface:
23+
24+
```swift
25+
await confirmation(expectedCount: 1) { mouseClicked in
26+
var eventLoop = EventLoop()
27+
eventLoop.eventHandler = { event in
28+
if event == .mouseClicked {
29+
mouseClicked()
30+
}
31+
}
32+
await eventLoop.simulate(.mouseClicked)
33+
}
34+
```
35+
36+
But what happens if the user _actually_ clicks a mouse button while this test is
37+
running? That might trigger a _second_ `.mouseClicked` event, and then the test
38+
will fail spuriously.
39+
40+
## Proposed solution
41+
42+
If the test author could instead indicate to Swift Testing that their test will
43+
generate _one or more_ events, they could avoid spurious failures:
44+
45+
```swift
46+
await confirmation(expectedCount: 1...) { mouseClicked in
47+
...
48+
}
49+
```
50+
51+
With this proposal, we add an overload of `confirmation()` that takes any range
52+
expression instead of a single integer value (which is still accepted via the
53+
existing overload.)
54+
55+
## Detailed design
56+
57+
A new overload of `confirmation()` is added:
58+
59+
```swift
60+
/// Confirm that some event occurs during the invocation of a function.
61+
///
62+
/// - Parameters:
63+
/// - comment: An optional comment to apply to any issues generated by this
64+
/// function.
65+
/// - expectedCount: A range of integers indicating the number of times the
66+
/// expected event should occur when `body` is invoked.
67+
/// - isolation: The actor to which `body` is isolated, if any.
68+
/// - sourceLocation: The source location to which any recorded issues should
69+
/// be attributed.
70+
/// - body: The function to invoke.
71+
///
72+
/// - Returns: Whatever is returned by `body`.
73+
///
74+
/// - Throws: Whatever is thrown by `body`.
75+
///
76+
/// Use confirmations to check that an event occurs while a test is running in
77+
/// complex scenarios where `#expect()` and `#require()` are insufficient. For
78+
/// example, a confirmation may be useful when an expected event occurs:
79+
///
80+
/// - In a context that cannot be awaited by the calling function such as an
81+
/// event handler or delegate callback;
82+
/// - More than once, or never; or
83+
/// - As a callback that is invoked as part of a larger operation.
84+
///
85+
/// To use a confirmation, pass a closure containing the work to be performed.
86+
/// The testing library will then pass an instance of ``Confirmation`` to the
87+
/// closure. Every time the event in question occurs, the closure should call
88+
/// the confirmation:
89+
///
90+
/// ```swift
91+
/// let minBuns = 5
92+
/// let maxBuns = 10
93+
/// await confirmation(
94+
/// "Baked between \(minBuns) and \(maxBuns) buns",
95+
/// expectedCount: minBuns ... maxBuns
96+
/// ) { bunBaked in
97+
/// foodTruck.eventHandler = { event in
98+
/// if event == .baked(.cinnamonBun) {
99+
/// bunBaked()
100+
/// }
101+
/// }
102+
/// await foodTruck.bakeTray(of: .cinnamonBun)
103+
/// }
104+
/// ```
105+
///
106+
/// When the closure returns, the testing library checks if the confirmation's
107+
/// preconditions have been met, and records an issue if they have not.
108+
///
109+
/// If an exact count is expected, use
110+
/// ``confirmation(_:expectedCount:isolation:sourceLocation:_:)`` instead.
111+
public func confirmation<R>(
112+
_ comment: Comment? = nil,
113+
expectedCount: some RangeExpression<Int> & Sendable,
114+
isolation: isolated (any Actor)? = #isolation,
115+
sourceLocation: SourceLocation = #_sourceLocation,
116+
_ body: (Confirmation) async throws -> sending R
117+
) async rethrows -> R
118+
```
119+
120+
### Ranges without lower bounds
121+
122+
Certain types of range, specifically [`PartialRangeUpTo`](https://developer.apple.com/documentation/swift/partialrangeupto)
123+
and [`PartialRangeThrough`](https://developer.apple.com/documentation/swift/partialrangethrough),
124+
are valid when used with this new interface, but may have surprising behavior
125+
because they implicitly include `0`. If a test author writes `...10`, do they
126+
mean "zero to ten" or "one to ten"? The programmatic meaning is the former, but
127+
some test authors might mean the latter. If an event does not occur, a test
128+
using `confirmation()` and this `expectedCount` value would pass when the test
129+
author meant for it to fail.
130+
131+
Swift Testing will attempt to detect these ambiguous uses of `...n` and `..<n`
132+
expressions and diagnose them, recommending that test authors explicitly write
133+
`0` or `1` as a lower bound.
134+
135+
### Unbounded ranges
136+
137+
Finally, an overload is added that takes an "unbounded range" (which is not
138+
technically a range at all, but… a closure?) This overload is marked unavailable
139+
because an unbounded range is effectively useless for testing:
140+
141+
```swift
142+
@available(*, unavailable, message: "Unbounded range '...' has no effect when used with a confirmation.")
143+
public func confirmation<R>(
144+
_ comment: Comment? = nil,
145+
expectedCount: UnboundedRange,
146+
isolation: isolated (any Actor)? = #isolation,
147+
sourceLocation: SourceLocation = #_sourceLocation,
148+
_ body: (Confirmation) async throws -> R
149+
) async rethrows -> R {
150+
fatalError("Unsupported")
151+
}
152+
```
153+
154+
## Source compatibility
155+
156+
This change is additive. Existing tests are unaffected.
157+
158+
Code that refers to `confirmation(_:expectedCount:isolation:sourceLocation:_:)`
159+
by symbol name may need to add a contextual type to disambiguate the two
160+
overloads at compile time.
161+
162+
## Integration with supporting tools
163+
164+
The type of the associated value `expected` for the `Issue.Kind` case
165+
`confirmationMiscounted(actual:expected:)` will change from `Int` to
166+
`any RangeExpression & Sendable`[^1]. Tools that implement event handlers and
167+
distinguish between `Issue.Kind` cases are advised not to assume the type of
168+
this value is `Int`.
169+
170+
## Alternatives considered
171+
172+
- Doing nothing. We have identified real-world use cases for this interface
173+
including in Swift Testing’s own test target.
174+
- Allowing the use of any value as the `expectedCount` argument so long as it
175+
conforms to a protocol `ExpectedCount` (we'd have range types and `Int`
176+
conform by default.) It was unclear what this sort of flexibility would let
177+
us do, and posed challenges for encoding and decoding events and issues when
178+
using the JSON event stream interface.
179+
180+
## Acknowledgments
181+
182+
If significant changes or improvements suggested by members of the community
183+
were incorporated into the proposal as it developed, take a moment here to thank
184+
them for their contributions. This is a collaborative process, and everyone's
185+
input should receive recognition!
186+
187+
Generally, you should not acknowledge anyone who is listed as a co-author.
188+
189+
[^1]: In the future, this type will change to
190+
`any RangeExpression<Int> & Sendable`. Compiler support is required
191+
([96960993](rdar://96960993)).

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ extension Array where Element == PackageDescription.SwiftSetting {
150150
.enableExperimentalFeature("AvailabilityMacro=_clockAPI:macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0"),
151151
.enableExperimentalFeature("AvailabilityMacro=_regexAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"),
152152
.enableExperimentalFeature("AvailabilityMacro=_swiftVersionAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"),
153+
.enableExperimentalFeature("AvailabilityMacro=_parameterizedProtocolsAPI:macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0"),
153154
.enableExperimentalFeature("AvailabilityMacro=_typedThrowsAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"),
154155

155156
.enableExperimentalFeature("AvailabilityMacro=_distantFuture:macOS 99.0, iOS 99.0, watchOS 99.0, tvOS 99.0, visionOS 99.0"),

Sources/Testing/Issues/Confirmation.swift

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,6 @@ public func confirmation<R>(
161161
///
162162
/// If an exact count is expected, use
163163
/// ``confirmation(_:expectedCount:isolation:sourceLocation:_:)`` instead.
164-
@_spi(Experimental)
165164
public func confirmation<R>(
166165
_ comment: Comment? = nil,
167166
expectedCount: some RangeExpression<Int> & Sendable,
@@ -174,7 +173,7 @@ public func confirmation<R>(
174173
let actualCount = confirmation.count.rawValue
175174
if !expectedCount.contains(actualCount) {
176175
let issue = Issue(
177-
kind: expectedCount.issueKind(forActualCount: actualCount),
176+
kind: .confirmationMiscounted(actual: actualCount, expected: expectedCount),
178177
comments: Array(comment),
179178
sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation)
180179
)
@@ -184,7 +183,7 @@ public func confirmation<R>(
184183
return try await body(confirmation)
185184
}
186185

187-
/// An overload of ``confirmation(_:expectedCount:sourceLocation:_:)-9bfdc``
186+
/// An overload of ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-6bkl6``
188187
/// that handles the unbounded range operator (`...`).
189188
///
190189
/// This overload is necessary because `UnboundedRange` does not conform to
@@ -194,27 +193,9 @@ public func confirmation<R>(
194193
public func confirmation<R>(
195194
_ comment: Comment? = nil,
196195
expectedCount: UnboundedRange,
196+
isolation: isolated (any Actor)? = #isolation,
197197
sourceLocation: SourceLocation = #_sourceLocation,
198198
_ body: (Confirmation) async throws -> R
199199
) async rethrows -> R {
200200
fatalError("Unsupported")
201201
}
202-
203-
extension RangeExpression where Bound == Int, Self: Sendable {
204-
/// Get an instance of ``Issue/Kind-swift.enum`` corresponding to this value.
205-
///
206-
/// - Parameters:
207-
/// - actualCount: The actual count for the failed confirmation.
208-
///
209-
/// - Returns: An instance of ``Issue/Kind-swift.enum`` that describes `self`.
210-
fileprivate func issueKind(forActualCount actualCount: Int) -> Issue.Kind {
211-
switch self {
212-
case let expectedCount as ClosedRange<Int> where expectedCount.lowerBound == expectedCount.upperBound:
213-
return .confirmationMiscounted(actual: actualCount, expected: expectedCount.lowerBound)
214-
case let expectedCount as Range<Int> where expectedCount.lowerBound == expectedCount.upperBound - 1:
215-
return .confirmationMiscounted(actual: actualCount, expected: expectedCount.lowerBound)
216-
default:
217-
return .confirmationOutOfRange(actual: actualCount, expected: self)
218-
}
219-
}
220-
}

Sources/Testing/Issues/Issue.swift

Lines changed: 23 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -32,27 +32,11 @@ public struct Issue: Sendable {
3232
/// - expected: The expected number of times
3333
/// ``Confirmation/confirm(count:)`` should have been called.
3434
///
35-
/// This issue can occur when calling
36-
/// ``confirmation(_:expectedCount:isolation:sourceLocation:_:)`` when the
37-
/// confirmation passed to these functions' `body` closures is confirmed too
38-
/// few or too many times.
39-
indirect case confirmationMiscounted(actual: Int, expected: Int)
40-
41-
/// An issue due to a confirmation being confirmed the wrong number of
42-
/// times.
43-
///
44-
/// - Parameters:
45-
/// - actual: The number of times ``Confirmation/confirm(count:)`` was
46-
/// actually called.
47-
/// - expected: The expected number of times
48-
/// ``Confirmation/confirm(count:)`` should have been called.
49-
///
50-
/// This issue can occur when calling
51-
/// ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-9rt6m`` when
52-
/// the confirmation passed to these functions' `body` closures is confirmed
53-
/// too few or too many times.
54-
@_spi(Experimental)
55-
indirect case confirmationOutOfRange(actual: Int, expected: any RangeExpression & Sendable)
35+
/// This issue can occur when calling ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-5mqz2``
36+
/// or ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-6bkl6``
37+
/// when the confirmation passed to these functions' `body` closures is
38+
/// confirmed too few or too many times.
39+
indirect case confirmationMiscounted(actual: Int, expected: any RangeExpression & Sendable)
5640

5741
/// An issue due to an `Error` being thrown by a test function and caught by
5842
/// the testing library.
@@ -193,29 +177,33 @@ extension Issue.Kind: CustomStringConvertible {
193177
// Although the failure is unconditional at the point it is recorded, the
194178
// code that recorded the issue may not be unconditionally executing, so
195179
// we shouldn't describe it as unconditional (we just don't know!)
196-
"Issue recorded"
180+
return "Issue recorded"
197181
case let .expectationFailed(expectation):
198-
if let mismatchedErrorDescription = expectation.mismatchedErrorDescription {
182+
return if let mismatchedErrorDescription = expectation.mismatchedErrorDescription {
199183
"Expectation failed: \(mismatchedErrorDescription)"
200184
} else if let mismatchedExitConditionDescription = expectation.mismatchedExitConditionDescription {
201185
"Expectation failed: \(mismatchedExitConditionDescription)"
202186
} else {
203187
"Expectation failed: \(expectation.evaluatedExpression.expandedDescription())"
204188
}
205189
case let .confirmationMiscounted(actual: actual, expected: expected):
206-
"Confirmation was confirmed \(actual.counting("time")), but expected to be confirmed \(expected.counting("time"))"
207-
case let .confirmationOutOfRange(actual: actual, expected: expected):
208-
"Confirmation was confirmed \(actual.counting("time")), but expected to be confirmed \(String(describingForTest: expected)) time(s)"
190+
if #available(_parameterizedProtocolsAPI, *), let expected = expected as? any RangeExpression<Int> {
191+
let expected = expected.relative(to: [])
192+
if expected.upperBound > expected.lowerBound && expected.lowerBound == expected.upperBound - 1 {
193+
return "Confirmation was confirmed \(actual.counting("time")), but expected to be confirmed \(expected.lowerBound.counting("time"))"
194+
}
195+
}
196+
return "Confirmation was confirmed \(actual.counting("time")), but expected to be confirmed \(String(describingForTest: expected)) time(s)"
209197
case let .errorCaught(error):
210-
"Caught error: \(error)"
198+
return "Caught error: \(error)"
211199
case let .timeLimitExceeded(timeLimitComponents: timeLimitComponents):
212-
"Time limit was exceeded: \(TimeValue(timeLimitComponents))"
200+
return "Time limit was exceeded: \(TimeValue(timeLimitComponents))"
213201
case .knownIssueNotRecorded:
214-
"Known issue was not recorded"
202+
return "Known issue was not recorded"
215203
case .apiMisused:
216-
"An API was misused"
204+
return "An API was misused"
217205
case .system:
218-
"A system failure occurred"
206+
return "A system failure occurred"
219207
}
220208
}
221209
}
@@ -246,7 +234,7 @@ extension Issue {
246234
///
247235
/// - Parameter issue: The original issue that gets snapshotted.
248236
public init(snapshotting issue: borrowing Issue) {
249-
if case .confirmationOutOfRange = issue.kind {
237+
if case .confirmationMiscounted = issue.kind {
250238
// Work around poor stringification of this issue kind in Xcode 16.
251239
self.kind = .unconditional
252240
self.comments = CollectionOfOne("\(issue.kind)") + issue.comments
@@ -306,7 +294,7 @@ extension Issue.Kind {
306294
/// ``Confirmation/confirm(count:)`` should have been called.
307295
///
308296
/// This issue can occur when calling
309-
/// ``confirmation(_:expectedCount:sourceLocation:_:)`` when the
297+
/// ``confirmation(_:expectedCount:isolation:sourceLocation:_:)`` when the
310298
/// confirmation passed to these functions' `body` closures is confirmed too
311299
/// few or too many times.
312300
indirect case confirmationMiscounted(actual: Int, expected: Int)
@@ -349,10 +337,8 @@ extension Issue.Kind {
349337
.unconditional
350338
case let .expectationFailed(expectation):
351339
.expectationFailed(Expectation.Snapshot(snapshotting: expectation))
352-
case let .confirmationMiscounted(actual: actual, expected: expected):
353-
.confirmationMiscounted(actual: actual, expected: expected)
354-
case let .confirmationOutOfRange(actual: actual, expected: _):
355-
.confirmationMiscounted(actual: actual, expected: 0)
340+
case .confirmationMiscounted:
341+
.unconditional
356342
case let .errorCaught(error):
357343
.errorCaught(ErrorSnapshot(snapshotting: error))
358344
case let .timeLimitExceeded(timeLimitComponents: timeLimitComponents):

Sources/Testing/Testing.docc/Expectations.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ the test when the code doesn't satisfy a requirement, use
7575
### Confirming that asynchronous events occur
7676

7777
- <doc:testing-asynchronous-code>
78-
- ``confirmation(_:expectedCount:isolation:sourceLocation:_:)``
78+
- ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-5mqz2``
79+
- ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-6bkl6``
7980
- ``Confirmation``
8081

8182
### Retrieving information about checked expectations

Sources/Testing/Testing.docc/MigratingFromXCTest.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,8 @@ Some tests, especially those that test asynchronously-delivered events, cannot
428428
be readily converted to use Swift concurrency. The testing library offers
429429
functionality called _confirmations_ which can be used to implement these tests.
430430
Instances of ``Confirmation`` are created and used within the scope of the
431-
function ``confirmation(_:expectedCount:isolation:sourceLocation:_:)``.
431+
functions ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-5mqz2``
432+
and ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-6bkl6``.
432433

433434
Confirmations function similarly to the expectations API of XCTest, however, they don't
434435
block or suspend the caller while waiting for a condition to be fulfilled.

0 commit comments

Comments
 (0)