Skip to content

Commit 3c2c406

Browse files
[SWT-0004] Constrain the granularity of test time limit durations (#534)
API proposal for promoting TimeLimitTrait.Duration and associated declarations to API. View the full proposal [here](https://github.com/apple/swift-testing/blob/main/Documentation/Proposals/0004-constrain-the-granularity-of-test-time-limit-durations.md).
1 parent 255acf8 commit 3c2c406

File tree

2 files changed

+204
-2
lines changed

2 files changed

+204
-2
lines changed
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
# Constrain the granularity of test time limit durations
2+
3+
* Proposal:
4+
[SWT-0004](0004-constrain-the-granularity-of-test-time-limit-durations.md)
5+
* Authors: [Dennis Weissmann](https://github.com/dennisweissmann)
6+
* Status: **Accepted**
7+
* Implementation:
8+
[apple/swift-testing#534](https://github.com/apple/swift-testing/pull/534)
9+
* Review:
10+
([pitch](https://forums.swift.org/t/pitch-constrain-the-granularity-of-test-time-limit-durations/73146)),
11+
([acceptance](https://forums.swift.org/t/pitch-constrain-the-granularity-of-test-time-limit-durations/73146/3))
12+
13+
## Introduction
14+
15+
Sometimes tests might get into a state (either due the test code itself or due
16+
to the code they're testing) where they don't make forward progress and hang.
17+
Swift Testing provides a way to handle these issues using the TimeLimit trait:
18+
19+
```swift
20+
@Test(.timeLimit(.minutes(60))
21+
func testFunction() { ... }
22+
```
23+
24+
Currently there exist multiple overloads for the `.timeLimit` trait: one that
25+
takes a `Swift.Duration` which allows for arbitrary `Duration` values to be
26+
passed, and one that takes a `TimeLimitTrait.Duration` which constrains the
27+
minimum time limit as well as the increment to 1 minute.
28+
29+
## Motivation
30+
31+
Small time limit values in particular cause more harm than good due to tests
32+
running in environments with drastically differing performance characteristics.
33+
Particularly when running in CI systems or on virtualized hardware tests can
34+
run much slower than at desk.
35+
Swift Testing should help developers use a reasonable time limit value in its
36+
API without developers having to refer to the documentation.
37+
38+
It is crucial to emphasize that unit tests failing due to exceeding their
39+
timeout should be exceptionally rare. At the same time, a spurious unit test
40+
failure caused by a short timeout can be surprisingly costly, potentially
41+
leading to an entire CI pipeline being rerun. Determining an appropriate
42+
timeout for a specific test can be a challenging task.
43+
44+
Additionally, when the system intentionally runs multiple tests simultaneously
45+
to optimize resource utilization, the scheduler becomes the arbiter of test
46+
execution. Consequently, the test may take significantly longer than
47+
anticipated, potentially due to external factors beyond the control of the code
48+
under test.
49+
50+
A unit test should be capable of failing due to hanging, but it should not fail
51+
due to being slow, unless the developer has explicitly indicated that it
52+
should, effectively transforming it into a performance test.
53+
54+
The time limit feature is *not* intended to be used to apply small timeouts to
55+
tests to ensure test runtime doesn't regress by small amounts. This feature is
56+
intended to be used to guard against hangs and pathologically long running
57+
tests.
58+
59+
## Proposed Solution
60+
61+
We propose changing the `.timeLimit` API to accept values of a new `Duration`
62+
type defined in `TimeLimitTrait` which only allows for `.minute` values to be
63+
passed.
64+
This type already exists as SPI and this proposal is seeking to making it API.
65+
66+
## Detailed Design
67+
68+
The `TimeLimitTrait.Duration` struct only has one factory method:
69+
```swift
70+
public static func minutes(_ minutes: some BinaryInteger) -> Self
71+
```
72+
73+
That ensures 2 things:
74+
1. It's impossible to create short time limits (under a minute).
75+
2. It's impossible to create high-precision increments of time.
76+
77+
Both of these features are important to ensure the API is self documenting and
78+
conveying the intended purpose.
79+
80+
For parameterized tests these time limits apply to each individual test case.
81+
82+
The `TimeLimitTrait.Duration` struct is declared as follows:
83+
84+
```swift
85+
/// A type that defines a time limit to apply to a test.
86+
///
87+
/// To add this trait to a test, use one of the following functions:
88+
///
89+
/// - ``Trait/timeLimit(_:)``
90+
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
91+
public struct TimeLimitTrait: TestTrait, SuiteTrait {
92+
/// A type representing the duration of a time limit applied to a test.
93+
///
94+
/// This type is intended for use specifically for specifying test timeouts
95+
/// with ``TimeLimitTrait``. It is used instead of Swift's built-in `Duration`
96+
/// type because test timeouts do not support high-precision, arbitrarily
97+
/// short durations. The smallest allowed unit of time is minutes.
98+
public struct Duration: Sendable {
99+
100+
/// Construct a time limit duration given a number of minutes.
101+
///
102+
/// - Parameters:
103+
/// - minutes: The number of minutes the resulting duration should
104+
/// represent.
105+
///
106+
/// - Returns: A duration representing the specified number of minutes.
107+
public static func minutes(_ minutes: some BinaryInteger) -> Self
108+
}
109+
110+
/// The maximum amount of time a test may run for before timing out.
111+
public var timeLimit: Swift.Duration { get set }
112+
}
113+
```
114+
115+
The extension on `Trait` that allows for `.timeLimit(...)` to work is defined
116+
like this:
117+
118+
```swift
119+
/// Construct a time limit trait that causes a test to time out if it runs for
120+
/// too long.
121+
///
122+
/// - Parameters:
123+
/// - timeLimit: The maximum amount of time the test may run for.
124+
///
125+
/// - Returns: An instance of ``TimeLimitTrait``.
126+
///
127+
/// Test timeouts do not support high-precision, arbitrarily short durations
128+
/// due to variability in testing environments. The time limit must be at
129+
/// least one minute, and can only be expressed in increments of one minute.
130+
///
131+
/// When this trait is associated with a test, that test must complete within
132+
/// a time limit of, at most, `timeLimit`. If the test runs longer, an issue
133+
/// of kind ``Issue/Kind/timeLimitExceeded(timeLimitComponents:)`` is
134+
/// recorded. This timeout is treated as a test failure.
135+
///
136+
/// The time limit amount specified by `timeLimit` may be reduced if the
137+
/// testing library is configured to enforce a maximum per-test limit. When
138+
/// such a maximum is set, the effective time limit of the test this trait is
139+
/// applied to will be the lesser of `timeLimit` and that maximum. This is a
140+
/// policy which may be configured on a global basis by the tool responsible
141+
/// for launching the test process. Refer to that tool's documentation for
142+
/// more details.
143+
///
144+
/// If a test is parameterized, this time limit is applied to each of its
145+
/// test cases individually. If a test has more than one time limit associated
146+
/// with it, the shortest one is used. A test run may also be configured with
147+
/// a maximum time limit per test case.
148+
public static func timeLimit(_ timeLimit: Self.Duration) -> Self
149+
```
150+
151+
And finally, the call site of the API looks like this:
152+
153+
```swift
154+
@Test(.timeLimit(.minutes(60))
155+
func serve100CustomersInOneHour() async {
156+
for _ in 0 ..< 100 {
157+
let customer = await Customer.next()
158+
await customer.order()
159+
...
160+
}
161+
}
162+
```
163+
164+
The `TimeLimitTrait.Duration` struct has various `unavailable` overloads that
165+
are included for diagnostic purposes only. They are all documented and
166+
annotated like this:
167+
168+
```swift
169+
/// Construct a time limit duration given a number of <unit>.
170+
///
171+
/// This function is unavailable and is provided for diagnostic purposes only.
172+
@available(*, unavailable, message: "Time limit must be specified in minutes")
173+
```
174+
175+
## Source Compatibility
176+
177+
This impacts clients that have adopted the `.timeLimit` trait and use overloads
178+
of the trait that accept an arbitrary `Swift.Duration` except if they used the
179+
`minutes` overload.
180+
181+
## Integration with Supporting Tools
182+
183+
N/A
184+
185+
## Future Directions
186+
187+
We could allow more finegrained time limits in the future that scale with the
188+
performance of the test host device.
189+
Or take a more manual approach where we detect the type of environment
190+
(like CI vs local) and provide a way to use different timeouts depending on the
191+
environment.
192+
193+
## Alternatives Considered
194+
195+
We have considered using `Swift.Duration` as the currency type for this API but
196+
decided against it to avoid common pitfalls and misuses of this feature such as
197+
providing very small time limits that lead to flaky tests in different
198+
environments.
199+
200+
## Acknowledgments
201+
202+
The authors acknowledge valuable contributions and feedback from the Swift
203+
Testing community during the development of this proposal.

Sources/Testing/Traits/TimeLimitTrait.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ public struct TimeLimitTrait: TestTrait, SuiteTrait {
2121
/// with ``TimeLimitTrait``. It is used instead of Swift's built-in `Duration`
2222
/// type because test timeouts do not support high-precision, arbitrarily
2323
/// short durations. The smallest allowed unit of time is minutes.
24-
@_spi(Experimental)
2524
public struct Duration: Sendable {
2625
/// The underlying Swift `Duration` which this time limit duration
2726
/// represents.
@@ -84,6 +83,7 @@ extension Trait where Self == TimeLimitTrait {
8483
/// test cases individually. If a test has more than one time limit associated
8584
/// with it, the shortest one is used. A test run may also be configured with
8685
/// a maximum time limit per test case.
86+
@_spi(Experimental)
8787
public static func timeLimit(_ timeLimit: Duration) -> Self {
8888
return Self(timeLimit: timeLimit)
8989
}
@@ -117,7 +117,6 @@ extension Trait where Self == TimeLimitTrait {
117117
/// test cases individually. If a test has more than one time limit associated
118118
/// with it, the shortest one is used. A test run may also be configured with
119119
/// a maximum time limit per test case.
120-
@_spi(Experimental)
121120
public static func timeLimit(_ timeLimit: Self.Duration) -> Self {
122121
return Self(timeLimit: timeLimit.underlyingDuration)
123122
}

0 commit comments

Comments
 (0)