File: 0004-constrain-the-granularity-of-test-time-limit-durations.md

package info (click to toggle)
swiftlang 6.0.3-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 2,519,992 kB
  • sloc: cpp: 9,107,863; ansic: 2,040,022; asm: 1,135,751; python: 296,500; objc: 82,456; f90: 60,502; lisp: 34,951; pascal: 19,946; sh: 18,133; perl: 7,482; ml: 4,937; javascript: 4,117; makefile: 3,840; awk: 3,535; xml: 914; fortran: 619; cs: 573; ruby: 573
file content (203 lines) | stat: -rw-r--r-- 8,095 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# Constrain the granularity of test time limit durations

* Proposal: 
[SWT-0004](0004-constrain-the-granularity-of-test-time-limit-durations.md)
* Authors: [Dennis Weissmann](https://github.com/dennisweissmann)
* Status: **Accepted**
* Implementation: 
[swiftlang/swift-testing#534](https://github.com/swiftlang/swift-testing/pull/534)
* Review: 
([pitch](https://forums.swift.org/t/pitch-constrain-the-granularity-of-test-time-limit-durations/73146)),
([acceptance](https://forums.swift.org/t/pitch-constrain-the-granularity-of-test-time-limit-durations/73146/3))

## Introduction

Sometimes tests might get into a state (either due the test code itself or due 
to the code they're testing) where they don't make forward progress and hang.
Swift Testing provides a way to handle these issues using the TimeLimit trait:

```swift
@Test(.timeLimit(.minutes(60))
func testFunction() { ... }
```

Currently there exist multiple overloads for the `.timeLimit` trait: one that 
takes a `Swift.Duration` which allows for arbitrary `Duration` values to be 
passed, and one that takes a `TimeLimitTrait.Duration` which constrains the 
minimum time limit as well as the increment to 1 minute.

## Motivation

Small time limit values in particular cause more harm than good due to tests 
running in environments with drastically differing performance characteristics.
Particularly when running in CI systems or on virtualized hardware tests can 
run much slower than at desk.
Swift Testing should help developers use a reasonable time limit value in its 
API without developers having to refer to the documentation.

It is crucial to emphasize that unit tests failing due to exceeding their 
timeout should be exceptionally rare. At the same time, a spurious unit test 
failure caused by a short timeout can be surprisingly costly, potentially 
leading to an entire CI pipeline being rerun. Determining an appropriate 
timeout for a specific test can be a challenging task.

Additionally, when the system intentionally runs multiple tests simultaneously 
to optimize resource utilization, the scheduler becomes the arbiter of test 
execution. Consequently, the test may take significantly longer than 
anticipated, potentially due to external factors beyond the control of the code 
under test.

A unit test should be capable of failing due to hanging, but it should not fail 
due to being slow, unless the developer has explicitly indicated that it 
should, effectively transforming it into a performance test.

The time limit feature is *not* intended to be used to apply small timeouts to 
tests to ensure test runtime doesn't regress by small amounts. This feature is 
intended to be used to guard against hangs and pathologically long running 
tests.

## Proposed Solution

We propose changing the `.timeLimit` API to accept values of a new `Duration` 
type defined in `TimeLimitTrait` which only allows for `.minute` values to be 
passed.
This type already exists as SPI and this proposal is seeking to making it API.

## Detailed Design

The `TimeLimitTrait.Duration` struct only has one factory method:
```swift
public static func minutes(_ minutes: some BinaryInteger) -> Self
```

That ensures 2 things:
1. It's impossible to create short time limits (under a minute).
2. It's impossible to create high-precision increments of time.

Both of these features are important to ensure the API is self documenting and 
conveying the intended purpose.

For parameterized tests these time limits apply to each individual test case.

The `TimeLimitTrait.Duration` struct is declared as follows:

```swift
/// A type that defines a time limit to apply to a test.
///
/// To add this trait to a test, use one of the following functions:
///
/// - ``Trait/timeLimit(_:)``
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
public struct TimeLimitTrait: TestTrait, SuiteTrait {
  /// A type representing the duration of a time limit applied to a test.
  ///
  /// This type is intended for use specifically for specifying test timeouts
  /// with ``TimeLimitTrait``. It is used instead of Swift's built-in `Duration`
  /// type because test timeouts do not support high-precision, arbitrarily
  /// short durations. The smallest allowed unit of time is minutes.
  public struct Duration: Sendable {

    /// Construct a time limit duration given a number of minutes.
    ///
    /// - Parameters:
    ///   - minutes: The number of minutes the resulting duration should
    ///     represent.
    ///
    /// - Returns: A duration representing the specified number of minutes.
    public static func minutes(_ minutes: some BinaryInteger) -> Self
  }

  /// The maximum amount of time a test may run for before timing out.
  public var timeLimit: Swift.Duration { get set }
}
```

The extension on `Trait` that allows for `.timeLimit(...)` to work is defined 
like this:

```swift
/// Construct a time limit trait that causes a test to time out if it runs for
/// too long.
///
/// - Parameters:
///   - timeLimit: The maximum amount of time the test may run for.
///
/// - Returns: An instance of ``TimeLimitTrait``.
///
/// Test timeouts do not support high-precision, arbitrarily short durations
/// due to variability in testing environments. The time limit must be at
/// least one minute, and can only be expressed in increments of one minute.
///
/// When this trait is associated with a test, that test must complete within
/// a time limit of, at most, `timeLimit`. If the test runs longer, an issue
/// of kind ``Issue/Kind/timeLimitExceeded(timeLimitComponents:)`` is
/// recorded. This timeout is treated as a test failure.
///
/// The time limit amount specified by `timeLimit` may be reduced if the
/// testing library is configured to enforce a maximum per-test limit. When
/// such a maximum is set, the effective time limit of the test this trait is
/// applied to will be the lesser of `timeLimit` and that maximum. This is a
/// policy which may be configured on a global basis by the tool responsible
/// for launching the test process. Refer to that tool's documentation for
/// more details.
///
/// If a test is parameterized, this time limit is applied to each of its
/// test cases individually. If a test has more than one time limit associated
/// with it, the shortest one is used. A test run may also be configured with
/// a maximum time limit per test case.
public static func timeLimit(_ timeLimit: Self.Duration) -> Self
```

And finally, the call site of the API looks like this:

```swift
@Test(.timeLimit(.minutes(60))
func serve100CustomersInOneHour() async {
  for _ in 0 ..< 100 {
    let customer = await Customer.next()
    await customer.order()
    ...
  }
}
```

The `TimeLimitTrait.Duration` struct has various `unavailable` overloads that
are included for diagnostic purposes only. They are all documented and
annotated like this:

```swift
/// Construct a time limit duration given a number of <unit>.
///
/// This function is unavailable and is provided for diagnostic purposes only.
@available(*, unavailable, message: "Time limit must be specified in minutes")
```

## Source Compatibility

This impacts clients that have adopted the `.timeLimit` trait and use overloads
of the trait that accept an arbitrary `Swift.Duration` except if they used the
`minutes` overload.

## Integration with Supporting Tools

N/A

## Future Directions

We could allow more finegrained time limits in the future that scale with the
performance of the test host device.
Or take a more manual approach where we detect the type of environment
(like CI vs local) and provide a way to use different timeouts depending on the
environment.

## Alternatives Considered

We have considered using `Swift.Duration` as the currency type for this API but 
decided against it to avoid common pitfalls and misuses of this feature such as
providing very small time limits that lead to flaky tests in different 
environments.

## Acknowledgments

The authors acknowledge valuable contributions and feedback from the Swift 
Testing community during the development of this proposal.