Skip to content

Commit e1d0868

Browse files
committed
Polling confirmations: expand the motivation section, adding a section on when polling should not be used.
1 parent 3765b11 commit e1d0868

File tree

1 file changed

+104
-18
lines changed

1 file changed

+104
-18
lines changed

proposals/testing/NNNN-polling-confirmations.md

Lines changed: 104 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,66 @@ APIs or awaiting on an `async` callable in order to block test execution until
2020
a callback is called, or an async callable returns. However, this requires the
2121
code being tested to support callbacks or return a status as an async callable.
2222

23-
This proposal adds another avenue for waiting for code to update to a specified
24-
value, by proactively polling the test closure until it passes or a timeout is
25-
reached.
26-
27-
More concretely, we can imagine a type that updates its status over an
28-
indefinite timeframe:
23+
Consider the following class, `Aquarium`, modeling raising dolphins:
2924

3025
```swift
31-
actor Aquarium {
32-
var dolphins: [Dolphin]
33-
34-
func raiseDolphins() async {
35-
// over a very long timeframe
36-
dolphins.append(Dolphin())
26+
@MainActor
27+
final class Aquarium {
28+
private(set) var isRaising = false
29+
var hasFunding = true
30+
31+
func raiseDolphins() {
32+
Task {
33+
if hasFunding {
34+
isRaising = true
35+
36+
// Long running work that I'm not qualified to describe.
37+
// ...
38+
39+
isRaising = false
40+
}
41+
}
3742
}
3843
}
3944
```
4045

46+
As is, it is extremely difficult to check that `isRaising` is correctly set to
47+
true once `raiseDolphins` is called. The system offers test authors no
48+
control for when the created task runs, leaving test authors add arbitrary sleep
49+
calls. Like this example:
50+
51+
```swift
52+
@Test func `raiseDolphins if hasFunding sets isRaising to true`() async throws {
53+
let subject = Aquarium()
54+
subject.hasFunding = true
55+
56+
subject.raiseDolphins()
57+
58+
try await Task.sleep(for: .seconds(1))
59+
60+
#expect(subject.isRaising == true)
61+
}
62+
```
63+
64+
This requires test authors to have to figure out how long to wait so that
65+
`isRaising` will reliably be set to true, while not waiting too long, such that
66+
the test suite is not unnecessarily delayed or task itself finishes.
67+
68+
As another example, imagine a test author wants to verify that no dolphins are
69+
raised when there isn't any funding. There isn't and can't be a mechanism for
70+
verifying that `isRaising` is never set to `true`, but if we constrain the
71+
problem to within a given timeframe, then we can have a reasonable assumption
72+
that `isRaising` remains set to false. Again, without some other mechanism to
73+
notify the test when to check `isRaising`, test authors are left to add
74+
arbitrary sleep calls, when having the ability to fail fast would save a not
75+
insignificant amount of time in the event that `isRaising` is mistakenly set to
76+
true.
77+
78+
This proposal introduces polling to help test authors address these cases. In
79+
this and other similar cases, polling makes these tests practical or even
80+
possible, as well as speeding up the execution of individual tests as well as
81+
the entire test suite.
82+
4183
## Proposed solution
4284

4385
This proposal introduces new members of the `confirmation` family of functions:
@@ -63,15 +105,26 @@ When `PollingStopCondition.stopsPassing` is specified, reaching the duration
63105
stop point will mark the confirmation as passing.
64106

65107
Tests will now be able to poll code updating in the background using either of
66-
the stop conditions:
108+
the stop conditions. For the example of `Aquarium.raiseDolphins`, valid tests
109+
might look like:
67110

68111
```swift
69-
let subject = Aquarium()
70-
Task {
71-
await subject.raiseDolphins()
112+
@Test func `raiseDolphins if hasFunding sets isRaising to true`() async throws {
113+
let subject = Aquarium()
114+
subject.hasFunding = true
115+
116+
subject.raiseDolphins()
117+
118+
try await confirmation(until: .firstPass) { subject.isRaising == true }
72119
}
73-
await confirmation(until: .firstPass) {
74-
subject.dolphins.count == 1
120+
121+
@Test func `raiseDolphins if no funding keeps isRaising false`() async throws {
122+
let subject = Aquarium()
123+
subject.hasFunding = false
124+
125+
subject.raiseDolphins()
126+
127+
try await confirmation(until: .stopsPassing) { subject.isRaising == false }
75128
}
76129
```
77130

@@ -368,6 +421,39 @@ Polling will be stopped when either:
368421
- the closure returns a value that satisfies the stopping condition, or
369422
- the closure throws an error.
370423

424+
### When Polling should not be used
425+
426+
Polling is not a silver bullet, and should not be abused. In many cases, the
427+
problems that polling solves can be solved through other, better means. Such as
428+
the observability system, using Async sequences, callbacks, or delegates. When
429+
possible, implementation code which requires polling to be tested should be
430+
refactored to support other means. Polling exists for the case where such
431+
refactors are either not possible or require a large amount of overhead.
432+
433+
Polling introduces a small amount of instability to the tests - in the example
434+
of waiting for `Aquarium.isRaising` to be set to true, it is entirely possible
435+
that, unless the code covered by
436+
`// Long running work that I'm not qualified to describe` has a test-controlled
437+
means to block further execution, the created `Task` could finish between
438+
polling attempts - resulting `Aquarium.isRaising` to always be read as false,
439+
and failing the test despite the code having done the right thing.
440+
441+
Polling also only offers a snapshot in time of the state. When
442+
`PollingStopCondition.firstPass` is used, polling will stop and return a pass
443+
after the first time the `body` returns true, even if any subsequent calls
444+
would've returned false.
445+
446+
Furthermore, polling introduces delays to the running code. This isn't that
447+
much of a concern for `PollingStopCondition.firstPass`, where the passing
448+
case minimizes test execution time. However, the
449+
passing case when using `PollingStopCondition.stopsPassing` utilizes the full
450+
duration specified. If the test author specifies the polling duration to be
451+
10 minutes, then the test will poll for approximately that long, so long as the
452+
polling body keeps returning true.
453+
454+
Despite all this, we think that polling is an extremely valuable tool, and is
455+
worth adding to the Testing library.
456+
371457
## Source compatibility
372458

373459
This is a new interface that is unlikely to collide with any existing

0 commit comments

Comments
 (0)