|
| 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. |
0 commit comments