Skip to content

Commit bc01e1b

Browse files
committed
Add requirePassesEventually and requireAlwaysPasses
These two mirror their confirm counterparts, only throwing an error (instead of recording an issue) when they fail.
1 parent 2dce511 commit bc01e1b

File tree

2 files changed

+166
-9
lines changed

2 files changed

+166
-9
lines changed

Sources/Testing/Polling/Polling.swift

Lines changed: 156 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ internal let defaultPollingConfiguration = (
1414
pollingInterval: Duration.milliseconds(1)
1515
)
1616

17+
/// A type describing an error thrown when polling fails.
18+
@_spi(Experimental)
19+
public struct PollingFailedError: Error, Equatable {}
20+
1721
/// Confirm that some expression eventually returns true
1822
///
1923
/// - Parameters:
@@ -76,10 +80,73 @@ public func confirmPassesEventually(
7680
}
7781
}
7882

79-
/// A type describing an error thrown when polling fails to return a non-nil
80-
/// value
83+
/// Require that some expression eventually returns true
84+
///
85+
/// - Parameters:
86+
/// - comment: An optional comment to apply to any issues generated by this
87+
/// function.
88+
/// - maxPollingIterations: The maximum amount of times to attempt polling.
89+
/// If nil, this uses whatever value is specified under the last
90+
/// ``ConfirmPassesEventuallyConfigurationTrait`` added to the test or
91+
/// suite.
92+
/// If no ``ConfirmPassesEventuallyConfigurationTrait`` has been added, then
93+
/// polling will be attempted 1000 times before recording an issue.
94+
/// `maxPollingIterations` must be greater than 0.
95+
/// - pollingInterval: The minimum amount of time to wait between polling
96+
/// attempts.
97+
/// If nil, this uses whatever value is specified under the last
98+
/// ``ConfirmPassesEventuallyConfigurationTrait`` added to the test or suite.
99+
/// If no ``ConfirmPassesEventuallyConfigurationTrait`` has been added, then
100+
/// polling will wait at least 1 millisecond between polling attempts.
101+
/// `pollingInterval` must be greater than 0.
102+
/// - isolation: The actor to which `body` is isolated, if any.
103+
/// - sourceLocation: The source location to whych any recorded issues should
104+
/// be attributed.
105+
/// - body: The function to invoke.
106+
///
107+
/// - Throws: A `PollingFailedError` will be thrown if the expression never
108+
/// returns true.
109+
///
110+
/// Use polling confirmations to check that an event while a test is running in
111+
/// complex scenarios where other forms of confirmation are insufficient. For
112+
/// example, waiting on some state to change that cannot be easily confirmed
113+
/// through other forms of `confirmation`.
81114
@_spi(Experimental)
82-
public struct PollingFailedError: Error {}
115+
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
116+
public func requirePassesEventually(
117+
_ comment: Comment? = nil,
118+
maxPollingIterations: Int? = nil,
119+
pollingInterval: Duration? = nil,
120+
isolation: isolated (any Actor)? = #isolation,
121+
sourceLocation: SourceLocation = #_sourceLocation,
122+
_ body: @escaping () async throws -> Bool
123+
) async throws {
124+
let poller = Poller(
125+
pollingBehavior: .passesOnce,
126+
pollingIterations: getValueFromPollingTrait(
127+
providedValue: maxPollingIterations,
128+
default: defaultPollingConfiguration.maxPollingIterations,
129+
\ConfirmPassesEventuallyConfigurationTrait.maxPollingIterations
130+
),
131+
pollingInterval: getValueFromPollingTrait(
132+
providedValue: pollingInterval,
133+
default: defaultPollingConfiguration.pollingInterval,
134+
\ConfirmPassesEventuallyConfigurationTrait.pollingInterval
135+
),
136+
comment: comment,
137+
sourceLocation: sourceLocation
138+
)
139+
let passed = await poller.evaluate(raiseIssue: false, isolation: isolation) {
140+
do {
141+
return try await body()
142+
} catch {
143+
return false
144+
}
145+
}
146+
if !passed {
147+
throw PollingFailedError()
148+
}
149+
}
83150

84151
/// Confirm that some expression eventually returns a non-nil value
85152
///
@@ -108,7 +175,7 @@ public struct PollingFailedError: Error {}
108175
/// - Returns: The first non-nil value returned by `body`.
109176
///
110177
/// - Throws: A `PollingFailedError` will be thrown if `body` never returns a
111-
/// non-optional value
178+
/// non-optional value.
112179
///
113180
/// Use polling confirmations to check that an event while a test is running in
114181
/// complex scenarios where other forms of confirmation are insufficient. For
@@ -215,6 +282,72 @@ public func confirmAlwaysPasses(
215282
}
216283
}
217284

285+
/// Require that some expression always returns true
286+
///
287+
/// - Parameters:
288+
/// - comment: An optional comment to apply to any issues generated by this
289+
/// function.
290+
/// - maxPollingIterations: The maximum amount of times to attempt polling.
291+
/// If nil, this uses whatever value is specified under the last
292+
/// ``ConfirmAlwaysPassesConfigurationTrait`` added to the test or suite.
293+
/// If no ``ConfirmAlwaysPassesConfigurationTrait`` has been added, then
294+
/// polling will be attempted 1000 times before recording an issue.
295+
/// `maxPollingIterations` must be greater than 0.
296+
/// - pollingInterval: The minimum amount of time to wait between polling
297+
/// attempts.
298+
/// If nil, this uses whatever value is specified under the last
299+
/// ``ConfirmAlwaysPassesConfigurationTrait`` added to the test or suite.
300+
/// If no ``ConfirmAlwaysPassesConfigurationTrait`` has been added, then
301+
/// polling will wait at least 1 millisecond between polling attempts.
302+
/// `pollingInterval` must be greater than 0.
303+
/// - isolation: The actor to which `body` is isolated, if any.
304+
/// - sourceLocation: The source location to whych any recorded issues should
305+
/// be attributed.
306+
/// - body: The function to invoke.
307+
///
308+
/// - Throws: A `PollingFailedError` will be thrown if the expression ever
309+
/// returns false.
310+
///
311+
/// Use polling confirmations to check that an event while a test is running in
312+
/// complex scenarios where other forms of confirmation are insufficient. For
313+
/// example, confirming that some state does not change.
314+
@_spi(Experimental)
315+
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
316+
public func requireAlwaysPasses(
317+
_ comment: Comment? = nil,
318+
maxPollingIterations: Int? = nil,
319+
pollingInterval: Duration? = nil,
320+
isolation: isolated (any Actor)? = #isolation,
321+
sourceLocation: SourceLocation = #_sourceLocation,
322+
_ body: @escaping () async throws -> Bool
323+
) async throws {
324+
let poller = Poller(
325+
pollingBehavior: .passesAlways,
326+
pollingIterations: getValueFromPollingTrait(
327+
providedValue: maxPollingIterations,
328+
default: defaultPollingConfiguration.maxPollingIterations,
329+
\ConfirmAlwaysPassesConfigurationTrait.maxPollingIterations
330+
),
331+
pollingInterval: getValueFromPollingTrait(
332+
providedValue: pollingInterval,
333+
default: defaultPollingConfiguration.pollingInterval,
334+
\ConfirmAlwaysPassesConfigurationTrait.pollingInterval
335+
),
336+
comment: comment,
337+
sourceLocation: sourceLocation
338+
)
339+
let passed = await poller.evaluate(raiseIssue: false, isolation: isolation) {
340+
do {
341+
return try await body()
342+
} catch {
343+
return false
344+
}
345+
}
346+
if !passed {
347+
throw PollingFailedError()
348+
}
349+
}
350+
218351
/// A helper function to de-duplicate the logic of grabbing configuration from
219352
/// either the passed-in value (if given), the hardcoded default, and the
220353
/// appropriate configuration trait.
@@ -368,23 +501,38 @@ private struct Poller {
368501
/// Evaluate polling, and process the result, raising an issue if necessary.
369502
///
370503
/// - Parameters:
504+
/// - raiseIssue: Whether or not to raise an issue.
505+
/// This should only be false for `requirePassesEventually` or
506+
/// `requireAlwaysPasses`.
507+
/// - isolation: The isolation to use
371508
/// - body: The expression to poll
509+
///
510+
/// - Returns: Whether or not polling passed.
511+
///
372512
/// - Side effects: If polling fails (see `PollingBehavior`), then this will
373513
/// record an issue.
374-
func evaluate(
514+
@discardableResult func evaluate(
515+
raiseIssue: Bool = true,
375516
isolation: isolated (any Actor)?,
376517
_ body: @escaping () async -> Bool
377-
) async {
518+
) async -> Bool {
378519
precondition(pollingIterations > 0)
379520
precondition(pollingInterval > Duration.zero)
380521
let result = await poll(
381522
expression: body
382523
)
383-
result.issue(
524+
if let issue = result.issue(
384525
comment: comment,
385526
sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation),
386527
pollingBehavior: pollingBehavior
387-
)?.record()
528+
) {
529+
if raiseIssue {
530+
issue.record()
531+
}
532+
return false
533+
} else {
534+
return true
535+
}
388536
}
389537

390538
/// This function contains the logic for continuously polling an expression,

Tests/TestingTests/PollingTests.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,20 @@ struct PollingTests {
1616
struct PassesOnceBehavior {
1717
@Test("Simple passing expressions") func trivialHappyPath() async throws {
1818
await confirmPassesEventually { true }
19+
try await requirePassesEventually { true }
1920

2021
let value = try await confirmPassesEventually { 1 }
22+
2123
#expect(value == 1)
2224
}
2325

2426
@Test("Simple failing expressions") func trivialSadPath() async throws {
2527
let issues = await runTest {
2628
await confirmPassesEventually { false }
2729
_ = try await confirmPassesEventually { Optional<Int>.none }
30+
await #expect(throws: PollingFailedError()) {
31+
try await requirePassesEventually { false }
32+
}
2833
}
2934
#expect(issues.count == 3)
3035
}
@@ -122,13 +127,17 @@ struct PollingTests {
122127

123128
@Suite("confirmAlwaysPasses")
124129
struct PassesAlwaysBehavior {
125-
@Test("Simple passing expressions") func trivialHappyPath() async {
130+
@Test("Simple passing expressions") func trivialHappyPath() async throws {
126131
await confirmAlwaysPasses { true }
132+
try await requireAlwaysPasses { true }
127133
}
128134

129135
@Test("Simple failing expressions") func trivialSadPath() async {
130136
let issues = await runTest {
131137
await confirmAlwaysPasses { false }
138+
await #expect(throws: PollingFailedError()) {
139+
try await requireAlwaysPasses { false }
140+
}
132141
}
133142
#expect(issues.count == 1)
134143
}

0 commit comments

Comments
 (0)