|
| 1 | +# Constrain the granularity of test time limit durations |
| 2 | + |
| 3 | +* Proposal: |
| 4 | +[SWT-NNNN](NNNN-constrain-the-granularity-of-test-time-limit-durations.md) |
| 5 | +* Authors: [Dennis Weissmann](https://github.com/dennisweissmann) |
| 6 | +* Status: **Awaiting review** |
| 7 | +* Implementation: |
| 8 | +[apple/swift-testing#NNNNN](https://github.com/apple/swift-testing/pull/NNNNN) |
| 9 | +* Review: |
| 10 | +([pitch](https://forums.swift.org/t/pitch-constrain-the-granularity-of-test-time |
| 11 | +-limit-durations/73146)) |
| 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 | +``` |
| 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 | +These types already exist as SPI and this proposal is seeking to making these |
| 65 | +API. |
| 66 | + |
| 67 | +## Detailed Design |
| 68 | + |
| 69 | +The `TimeLimitTrait.Duration` struct only has one factory method: `public |
| 70 | +static func minutes(_ minutes: some BinaryInteger) -> Self`. |
| 71 | + |
| 72 | +That ensures 2 things: |
| 73 | +1. It's impossible to create short time limits (under a minute). |
| 74 | +2. It's impossible to create high-precision increments of time. |
| 75 | + |
| 76 | +Both of these features are important to ensure the API is self documenting and |
| 77 | +conveying the intended purpose. |
| 78 | + |
| 79 | +For parameterized tests these time limits apply to each individual test case. |
| 80 | + |
| 81 | +The `TimeLimitTrait.Duration` struct is declared as follows: |
| 82 | + |
| 83 | +```swift |
| 84 | +/// A type that defines a time limit to apply to a test. |
| 85 | +/// |
| 86 | +/// To add this trait to a test, use one of the following functions: |
| 87 | +/// |
| 88 | +/// - ``Trait/timeLimit(_:)`` |
| 89 | +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) |
| 90 | +public struct TimeLimitTrait: TestTrait, SuiteTrait { |
| 91 | + /// A type representing the duration of a time limit applied to a test. |
| 92 | + /// |
| 93 | + /// This type is intended for use specifically for specifying test timeouts |
| 94 | + /// with ``TimeLimitTrait``. It is used instead of Swift's built-in `Duration` |
| 95 | + /// type because test timeouts do not support high-precision, arbitrarily |
| 96 | + /// short durations. The smallest allowed unit of time is minutes. |
| 97 | + public struct Duration: Sendable { |
| 98 | + |
| 99 | + /// Construct a time limit duration given a number of minutes. |
| 100 | + /// |
| 101 | + /// - Parameters: |
| 102 | + /// - minutes: The number of minutes the resulting duration should |
| 103 | + /// represent. |
| 104 | + /// |
| 105 | + /// - Returns: A duration representing the specified number of minutes. |
| 106 | + public static func minutes(_ minutes: some BinaryInteger) -> Self |
| 107 | + } |
| 108 | + |
| 109 | + /// The maximum amount of time a test may run for before timing out. |
| 110 | + public var timeLimit: Swift.Duration |
| 111 | +} |
| 112 | +``` |
| 113 | + |
| 114 | +The extension on `Trait` that allows for `.timeLimit(...)` to work is defined |
| 115 | +like this: |
| 116 | + |
| 117 | +``` |
| 118 | +/// Construct a time limit trait that causes a test to time out if it runs for |
| 119 | +/// too long. |
| 120 | +/// |
| 121 | +/// - Parameters: |
| 122 | +/// - timeLimit: The maximum amount of time the test may run for. |
| 123 | +/// |
| 124 | +/// - Returns: An instance of ``TimeLimitTrait``. |
| 125 | +/// |
| 126 | +/// Test timeouts do not support high-precision, arbitrarily short durations |
| 127 | +/// due to variability in testing environments. The time limit must be at |
| 128 | +/// least one minute, and can only be expressed in increments of one minute. |
| 129 | +/// |
| 130 | +/// When this trait is associated with a test, that test must complete within |
| 131 | +/// a time limit of, at most, `timeLimit`. If the test runs longer, an issue |
| 132 | +/// of kind ``Issue/Kind/timeLimitExceeded(timeLimitComponents:)`` is |
| 133 | +/// recorded. This timeout is treated as a test failure. |
| 134 | +/// |
| 135 | +/// The time limit amount specified by `timeLimit` may be reduced if the |
| 136 | +/// testing library is configured to enforce a maximum per-test limit. When |
| 137 | +/// such a maximum is set, the effective time limit of the test this trait is |
| 138 | +/// applied to will be the lesser of `timeLimit` and that maximum. This is a |
| 139 | +/// policy which may be configured on a global basis by the tool responsible |
| 140 | +/// for launching the test process. Refer to that tool's documentation for |
| 141 | +/// more details. |
| 142 | +/// |
| 143 | +/// If a test is parameterized, this time limit is applied to each of its |
| 144 | +/// test cases individually. If a test has more than one time limit associated |
| 145 | +/// with it, the shortest one is used. A test run may also be configured with |
| 146 | +/// a maximum time limit per test case. |
| 147 | +public static func timeLimit(_ timeLimit: Self.Duration) -> Self |
| 148 | +``` |
| 149 | + |
| 150 | +And finally, the call site of the API looks like this: |
| 151 | + |
| 152 | +```swift |
| 153 | +@Test(.timeLimit(.minutes(60)) |
| 154 | +func serve100CustomersInOneHour() async { |
| 155 | + for _ in 0 ..< 100 { |
| 156 | + let customer = await Customer.next() |
| 157 | + await customer.order() |
| 158 | + ... |
| 159 | + } |
| 160 | +} |
| 161 | +``` |
| 162 | + |
| 163 | +The `TimeLimitTrait.Duration` struct has various `unavailable` overloads that |
| 164 | +are included for diagnostic purposes only. They are all documented and |
| 165 | +annotated like this: |
| 166 | + |
| 167 | +``` |
| 168 | +/// Construct a time limit duration given a number of <unit>. |
| 169 | +/// |
| 170 | +/// This function is unavailable and is provided for diagnostic purposes only. |
| 171 | +@available(*, unavailable, message: "Time limit must be specified in minutes") |
| 172 | +``` |
| 173 | + |
| 174 | +## Source Compatibility |
| 175 | + |
| 176 | +This is purely additional API and does not impact existing code. |
| 177 | + |
| 178 | +## Integration with Supporting Tools |
| 179 | + |
| 180 | +N/A |
| 181 | + |
| 182 | +## Future Directions |
| 183 | + |
| 184 | +N/A. |
| 185 | + |
| 186 | +## Alternatives Considered |
| 187 | + |
| 188 | +We have considered using `Swift.Duration` as the currency type for this API but |
| 189 | +decided against it to avoid common pitfals and misuses of this feature such as |
| 190 | +providing very small time limits that lead to flaky tests in different |
| 191 | +environments. |
| 192 | + |
| 193 | +## Acknowledgments |
| 194 | + |
| 195 | +The authors acknowledge valuable contributions and feedback from the Swift |
| 196 | +Testing community during the development of this proposal. |
0 commit comments