Skip to content

Commit c5b1b79

Browse files
authored
Add experimental overload of confirmation() that takes a range. (#512)
Wouldn't it be useful to be able to specify a range of expected counts for a confirmation rather than a specific number? For instance, if you expect an event to occur exactly zero or one times: ```swift await confirmation(expectedCount: 0 ... 1) { thingHappened in // ... } ``` Or maybe you expect that an event will occur _at least_ 10 times, but maybe many more? ```swift await confirmation(expectedCount: 10...) { thingHappened in // ... } ``` Well... good news, everyone! Here's an experimental overload of `confirmation()` that allows you to do exactly that! ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated.
1 parent 3c93f6f commit c5b1b79

File tree

4 files changed

+203
-11
lines changed

4 files changed

+203
-11
lines changed

Sources/Testing/Issues/Confirmation.swift

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,80 @@ public func confirmation<R>(
9696
expectedCount: Int = 1,
9797
sourceLocation: SourceLocation = #_sourceLocation,
9898
_ body: (Confirmation) async throws -> R
99+
) async rethrows -> R {
100+
try await confirmation(
101+
comment,
102+
expectedCount: expectedCount ... expectedCount,
103+
sourceLocation: sourceLocation,
104+
body
105+
)
106+
}
107+
108+
// MARK: - Ranges as expected counts
109+
110+
/// Confirm that some event occurs during the invocation of a function.
111+
///
112+
/// - Parameters:
113+
/// - comment: An optional comment to apply to any issues generated by this
114+
/// function.
115+
/// - expectedCount: A range of integers indicating the number of times the
116+
/// expected event should occur when `body` is invoked.
117+
/// - sourceLocation: The source location to which any recorded issues should
118+
/// be attributed.
119+
/// - body: The function to invoke.
120+
///
121+
/// - Returns: Whatever is returned by `body`.
122+
///
123+
/// - Throws: Whatever is thrown by `body`.
124+
///
125+
/// Use confirmations to check that an event occurs while a test is running in
126+
/// complex scenarios where `#expect()` and `#require()` are insufficient. For
127+
/// example, a confirmation may be useful when an expected event occurs:
128+
///
129+
/// - In a context that cannot be awaited by the calling function such as an
130+
/// event handler or delegate callback;
131+
/// - More than once, or never; or
132+
/// - As a callback that is invoked as part of a larger operation.
133+
///
134+
/// To use a confirmation, pass a closure containing the work to be performed.
135+
/// The testing library will then pass an instance of ``Confirmation`` to the
136+
/// closure. Every time the event in question occurs, the closure should call
137+
/// the confirmation:
138+
///
139+
/// ```swift
140+
/// let minBuns = 5
141+
/// let maxBuns = 10
142+
/// await confirmation(
143+
/// "Baked between \(minBuns) and \(maxBuns) buns",
144+
/// expectedCount: minBuns ... maxBuns
145+
/// ) { bunBaked in
146+
/// foodTruck.eventHandler = { event in
147+
/// if event == .baked(.cinnamonBun) {
148+
/// bunBaked()
149+
/// }
150+
/// }
151+
/// await foodTruck.bakeTray(of: .cinnamonBun)
152+
/// }
153+
/// ```
154+
///
155+
/// When the closure returns, the testing library checks if the confirmation's
156+
/// preconditions have been met, and records an issue if they have not.
157+
///
158+
/// If an exact count is expected, use
159+
/// ``confirmation(_:expectedCount:sourceLocation:_:)-7kfko`` instead.
160+
@_spi(Experimental)
161+
public func confirmation<R>(
162+
_ comment: Comment? = nil,
163+
expectedCount: some Confirmation.ExpectedCount,
164+
sourceLocation: SourceLocation = #_sourceLocation,
165+
_ body: (Confirmation) async throws -> R
99166
) async rethrows -> R {
100167
let confirmation = Confirmation()
101168
defer {
102169
let actualCount = confirmation.count.rawValue
103-
if actualCount != expectedCount {
170+
if !expectedCount.contains(actualCount) {
104171
Issue.record(
105-
.confirmationMiscounted(actual: actualCount, expected: expectedCount),
172+
expectedCount.issueKind(forActualCount: actualCount),
106173
comments: Array(comment),
107174
backtrace: .current(),
108175
sourceLocation: sourceLocation
@@ -111,3 +178,52 @@ public func confirmation<R>(
111178
}
112179
return try await body(confirmation)
113180
}
181+
182+
@_spi(Experimental)
183+
extension Confirmation {
184+
/// A protocol that describes a range expression that can be used with
185+
/// ``confirmation(_:expectedCount:sourceLocation:_:)-41gmd``.
186+
///
187+
/// This protocol represents any expression that describes a range of
188+
/// confirmation counts. For example, the expression `1 ..< 10` automatically
189+
/// conforms to it.
190+
///
191+
/// You do not generally need to add conformances to this type yourself. It is
192+
/// used by the testing library to abstract away the different range types
193+
/// provided by the Swift standard library.
194+
public protocol ExpectedCount: Sendable, RangeExpression<Int> {}
195+
}
196+
197+
extension Confirmation.ExpectedCount {
198+
/// Get an instance of ``Issue/Kind-swift.enum`` corresponding to this value.
199+
///
200+
/// - Parameters:
201+
/// - actualCount: The actual count for the failed confirmation.
202+
///
203+
/// - Returns: An instance of ``Issue/Kind-swift.enum`` that describes `self`.
204+
fileprivate func issueKind(forActualCount actualCount: Int) -> Issue.Kind {
205+
switch self {
206+
case let expectedCount as ClosedRange<Int> where expectedCount.lowerBound == expectedCount.upperBound:
207+
return .confirmationMiscounted(actual: actualCount, expected: expectedCount.lowerBound)
208+
case let expectedCount as Range<Int> where expectedCount.lowerBound == expectedCount.upperBound - 1:
209+
return .confirmationMiscounted(actual: actualCount, expected: expectedCount.lowerBound)
210+
default:
211+
return .confirmationOutOfRange(actual: actualCount, expected: self)
212+
}
213+
}
214+
}
215+
216+
@_spi(Experimental)
217+
extension ClosedRange<Int>: Confirmation.ExpectedCount {}
218+
219+
@_spi(Experimental)
220+
extension PartialRangeFrom<Int>: Confirmation.ExpectedCount {}
221+
222+
@_spi(Experimental)
223+
extension PartialRangeThrough<Int>: Confirmation.ExpectedCount {}
224+
225+
@_spi(Experimental)
226+
extension PartialRangeUpTo<Int>: Confirmation.ExpectedCount {}
227+
228+
@_spi(Experimental)
229+
extension Range<Int>: Confirmation.ExpectedCount {}

Sources/Testing/Issues/Issue.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ public struct Issue: Sendable {
3838
/// few or too many times.
3939
indirect case confirmationMiscounted(actual: Int, expected: Int)
4040

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:sourceLocation:_:)-41gmd`` when the
52+
/// confirmation passed to these functions' `body` closures is confirmed too
53+
/// few or too many times.
54+
@_spi(Experimental)
55+
indirect case confirmationOutOfRange(actual: Int, expected: any Confirmation.ExpectedCount)
56+
4157
/// An issue due to an `Error` being thrown by a test function and caught by
4258
/// the testing library.
4359
///
@@ -162,6 +178,8 @@ extension Issue.Kind: CustomStringConvertible {
162178
}
163179
case let .confirmationMiscounted(actual: actual, expected: expected):
164180
"Confirmation was confirmed \(actual.counting("time")), but expected to be confirmed \(expected.counting("time"))"
181+
case let .confirmationOutOfRange(actual: actual, expected: expected):
182+
"Confirmation was confirmed \(actual.counting("time")), but expected to be confirmed \(String(describingForTest: expected)) time(s)"
165183
case let .errorCaught(error):
166184
"Caught error: \(error)"
167185
case let .timeLimitExceeded(timeLimitComponents: timeLimitComponents):
@@ -300,6 +318,8 @@ extension Issue.Kind {
300318
.expectationFailed(Expectation.Snapshot(snapshotting: expectation))
301319
case let .confirmationMiscounted(actual: actual, expected: expected):
302320
.confirmationMiscounted(actual: actual, expected: expected)
321+
case let .confirmationOutOfRange(actual: actual, expected: _):
322+
.confirmationMiscounted(actual: actual, expected: 0)
303323
case let .errorCaught(error):
304324
.errorCaught(ErrorSnapshot(snapshotting: error))
305325
case let .timeLimitExceeded(timeLimitComponents: timeLimitComponents):

Sources/Testing/SourceAttribution/CustomTestStringConvertible.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ extension _OptionalNilComparisonType: CustomTestStringConvertible {
158158
}
159159
}
160160

161+
// MARK: - Strings
162+
161163
extension CustomTestStringConvertible where Self: StringProtocol {
162164
public var testDescription: String {
163165
"\"\(self)\""
@@ -166,3 +168,35 @@ extension CustomTestStringConvertible where Self: StringProtocol {
166168

167169
extension String: CustomTestStringConvertible {}
168170
extension Substring: CustomTestStringConvertible {}
171+
172+
// MARK: - Ranges
173+
174+
extension ClosedRange: CustomTestStringConvertible {
175+
public var testDescription: String {
176+
"\(String(describingForTest: lowerBound)) ... \(String(describingForTest: upperBound))"
177+
}
178+
}
179+
180+
extension PartialRangeFrom: CustomTestStringConvertible {
181+
public var testDescription: String {
182+
"\(String(describingForTest: lowerBound))..."
183+
}
184+
}
185+
186+
extension PartialRangeThrough: CustomTestStringConvertible {
187+
public var testDescription: String {
188+
"...\(String(describingForTest: upperBound))"
189+
}
190+
}
191+
192+
extension PartialRangeUpTo: CustomTestStringConvertible {
193+
public var testDescription: String {
194+
"..<\(String(describingForTest: upperBound))"
195+
}
196+
}
197+
198+
extension Range: CustomTestStringConvertible {
199+
public var testDescription: String {
200+
"\(String(describingForTest: lowerBound)) ..< \(String(describingForTest: upperBound))"
201+
}
202+
}

Tests/TestingTests/ConfirmationTests.swift

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,25 @@ struct ConfirmationTests {
2929

3030
@Test("Unsuccessful confirmations")
3131
func unsuccessfulConfirmations() async {
32-
await confirmation("Issue recorded", expectedCount: 3) { issueRecorded in
33-
var configuration = Configuration()
34-
configuration.eventHandler = { event, _ in
35-
if case let .issueRecorded(issue) = event.kind,
36-
case .confirmationMiscounted = issue.kind {
37-
issueRecorded()
32+
await confirmation("Miscount recorded", expectedCount: 4) { miscountRecorded in
33+
await confirmation("Out of range recorded", expectedCount: 5) { outOfRangeRecorded in
34+
var configuration = Configuration()
35+
configuration.eventHandler = { event, _ in
36+
if case let .issueRecorded(issue) = event.kind {
37+
switch issue.kind {
38+
case .confirmationMiscounted:
39+
miscountRecorded()
40+
case .confirmationOutOfRange:
41+
outOfRangeRecorded()
42+
default:
43+
break
44+
}
45+
}
3846
}
47+
let testPlan = await Runner.Plan(selecting: UnsuccessfulConfirmationTests.self)
48+
let runner = Runner(plan: testPlan, configuration: configuration)
49+
await runner.run()
3950
}
40-
let testPlan = await Runner.Plan(selecting: UnsuccessfulConfirmationTests.self)
41-
let runner = Runner(plan: testPlan, configuration: configuration)
42-
await runner.run()
4351
}
4452
}
4553

@@ -100,4 +108,18 @@ struct UnsuccessfulConfirmationTests {
100108
thingHappened(count: 10)
101109
}
102110
}
111+
112+
@Test(.hidden, arguments: [
113+
1 ... 2 as any Confirmation.ExpectedCount,
114+
1 ..< 2,
115+
1 ..< 3,
116+
..<2,
117+
...2,
118+
999...,
119+
])
120+
func confirmedOutOfRange(_ range: any Confirmation.ExpectedCount) async {
121+
await confirmation(expectedCount: range) { (thingHappened) async in
122+
thingHappened(count: 3)
123+
}
124+
}
103125
}

0 commit comments

Comments
 (0)