Skip to content

Commit 0a1ba7b

Browse files
authored
Only accept minute-granularity test time limit durations (#391)
This introduces a distinct type constraining the acceptable granularity of `.timeLimit()` to minutes, and also improves API documentation around test timeouts. ### Motivation: The testing library is not capable of properly handling arbitrarily short test time limit durations, such as `.seconds(1)` or `.microseconds(20)`, due to variability in execution environments. Note that the analogous XCTest API, [`executionTimeAllowance`](https://developer.apple.com/documentation/xctest/xctestcase/3526064-executiontimeallowance?changes=lates_1), behaves similarly. ### Modifications: - Introduce a new `TimeLimitTrait.Duration` type which may only be expressed in minutes, and change `.timeLimit(_:)` to accept this type instead of `Swift.Duration`. - Update existing documentation and add some new discussion about limitations. - Update tests. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated. Resolves rdar://122189336 Resolves rdar://122194368
1 parent d50745c commit 0a1ba7b

File tree

4 files changed

+172
-27
lines changed

4 files changed

+172
-27
lines changed

Sources/Testing/Traits/TimeLimitTrait.swift

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,32 @@
1515
/// - ``Trait/timeLimit(_:)``
1616
@available(_clockAPI, *)
1717
public struct TimeLimitTrait: TestTrait, SuiteTrait {
18+
/// A type representing the duration of a time limit applied to a test.
19+
///
20+
/// This type is intended for use specifically for specifying test timeouts
21+
/// with ``TimeLimitTrait``. It is used instead of Swift's built-in `Duration`
22+
/// type because test timeouts do not support high-precision, arbitrarily
23+
/// short durations. The smallest allowed unit of time is minutes.
24+
@_spi(Experimental)
25+
public struct Duration: Sendable {
26+
/// The underlying Swift `Duration` which this time limit duration
27+
/// represents.
28+
var underlyingDuration: Swift.Duration
29+
30+
/// Construct a time limit duration given a number of minutes.
31+
///
32+
/// - Parameters:
33+
/// - minutes: The number of minutes the resulting duration should
34+
/// represent.
35+
///
36+
/// - Returns: A duration representing the specified number of minutes.
37+
public static func minutes(_ minutes: some BinaryInteger) -> Self {
38+
Self(underlyingDuration: .seconds(60) * minutes)
39+
}
40+
}
41+
1842
/// The maximum amount of time a test may run for before timing out.
19-
public var timeLimit: Duration
43+
public var timeLimit: Swift.Duration
2044

2145
public var isRecursive: Bool {
2246
// Since test functions cannot be nested inside other test functions,
@@ -39,18 +63,123 @@ extension Trait where Self == TimeLimitTrait {
3963
///
4064
/// - Returns: An instance of ``TimeLimitTrait``.
4165
///
66+
/// Test timeouts do not support high-precision, arbitrarily short durations
67+
/// due to variability in testing environments. The time limit must be at
68+
/// least one minute, and can only be expressed in increments of one minute.
69+
///
4270
/// When this trait is associated with a test, that test must complete within
4371
/// a time limit of, at most, `timeLimit`. If the test runs longer, an issue
4472
/// of kind ``Issue/Kind/timeLimitExceeded(timeLimitComponents:)`` is
4573
/// recorded. This timeout is treated as a test failure.
4674
///
75+
/// The time limit amount specified by `timeLimit` may be reduced if the
76+
/// testing library is configured to enforce a maximum per-test limit. When
77+
/// such a maximum is set, the effective time limit of the test this trait is
78+
/// applied to will be the lesser of `timeLimit` and that maximum. This is a
79+
/// policy which may be configured on a global basis by the tool responsible
80+
/// for launching the test process. Refer to that tool's documentation for
81+
/// more details.
82+
///
4783
/// If a test is parameterized, this time limit is applied to each of its
4884
/// test cases individually. If a test has more than one time limit associated
4985
/// with it, the shortest one is used. A test run may also be configured with
5086
/// a maximum time limit per test case.
5187
public static func timeLimit(_ timeLimit: Duration) -> Self {
5288
return Self(timeLimit: timeLimit)
5389
}
90+
91+
/// Construct a time limit trait that causes a test to time out if it runs for
92+
/// too long.
93+
///
94+
/// - Parameters:
95+
/// - timeLimit: The maximum amount of time the test may run for.
96+
///
97+
/// - Returns: An instance of ``TimeLimitTrait``.
98+
///
99+
/// Test timeouts do not support high-precision, arbitrarily short durations
100+
/// due to variability in testing environments. The time limit must be at
101+
/// least one minute, and can only be expressed in increments of one minute.
102+
///
103+
/// When this trait is associated with a test, that test must complete within
104+
/// a time limit of, at most, `timeLimit`. If the test runs longer, an issue
105+
/// of kind ``Issue/Kind/timeLimitExceeded(timeLimitComponents:)`` is
106+
/// recorded. This timeout is treated as a test failure.
107+
///
108+
/// The time limit amount specified by `timeLimit` may be reduced if the
109+
/// testing library is configured to enforce a maximum per-test limit. When
110+
/// such a maximum is set, the effective time limit of the test this trait is
111+
/// applied to will be the lesser of `timeLimit` and that maximum. This is a
112+
/// policy which may be configured on a global basis by the tool responsible
113+
/// for launching the test process. Refer to that tool's documentation for
114+
/// more details.
115+
///
116+
/// If a test is parameterized, this time limit is applied to each of its
117+
/// test cases individually. If a test has more than one time limit associated
118+
/// with it, the shortest one is used. A test run may also be configured with
119+
/// a maximum time limit per test case.
120+
@_spi(Experimental)
121+
public static func timeLimit(_ timeLimit: Self.Duration) -> Self {
122+
return Self(timeLimit: timeLimit.underlyingDuration)
123+
}
124+
}
125+
126+
@available(_clockAPI, *)
127+
extension TimeLimitTrait.Duration {
128+
/// Construct a time limit duration given a number of seconds.
129+
///
130+
/// This function is unavailable and is provided for diagnostic purposes only.
131+
@available(*, unavailable, message: "Time limit must be specified in minutes")
132+
public static func seconds(_ seconds: some BinaryInteger) -> Self {
133+
fatalError("Unsupported")
134+
}
135+
136+
/// Construct a time limit duration given a number of seconds.
137+
///
138+
/// This function is unavailable and is provided for diagnostic purposes only.
139+
@available(*, unavailable, message: "Time limit must be specified in minutes")
140+
public static func seconds(_ seconds: Double) -> Self {
141+
fatalError("Unsupported")
142+
}
143+
144+
/// Construct a time limit duration given a number of milliseconds.
145+
///
146+
/// This function is unavailable and is provided for diagnostic purposes only.
147+
@available(*, unavailable, message: "Time limit must be specified in minutes")
148+
public static func milliseconds(_ milliseconds: some BinaryInteger) -> Self {
149+
fatalError("Unsupported")
150+
}
151+
152+
/// Construct a time limit duration given a number of milliseconds.
153+
///
154+
/// This function is unavailable and is provided for diagnostic purposes only.
155+
@available(*, unavailable, message: "Time limit must be specified in minutes")
156+
public static func milliseconds(_ milliseconds: Double) -> Self {
157+
fatalError("Unsupported")
158+
}
159+
160+
/// Construct a time limit duration given a number of microseconds.
161+
///
162+
/// This function is unavailable and is provided for diagnostic purposes only.
163+
@available(*, unavailable, message: "Time limit must be specified in minutes")
164+
public static func microseconds(_ microseconds: some BinaryInteger) -> Self {
165+
fatalError("Unsupported")
166+
}
167+
168+
/// Construct a time limit duration given a number of microseconds.
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+
public static func microseconds(_ microseconds: Double) -> Self {
173+
fatalError("Unsupported")
174+
}
175+
176+
/// Construct a time limit duration given a number of nanoseconds.
177+
///
178+
/// This function is unavailable and is provided for diagnostic purposes only.
179+
@available(*, unavailable, message: "Time limit must be specified in minutes")
180+
public static func nanoseconds(_ nanoseconds: some BinaryInteger) -> Self {
181+
fatalError("Unsupported")
182+
}
54183
}
55184

56185
// MARK: -

Tests/TestingTests/Test.SnapshotTests.swift

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
//
1010

11-
@_spi(ForToolsIntegrationOnly) @testable import Testing
11+
@_spi(Experimental) @_spi(ForToolsIntegrationOnly) @testable import Testing
1212

1313
@Suite("Test.Snapshot tests")
1414
struct Test_SnapshotTests {
@@ -97,17 +97,12 @@ struct Test_SnapshotTests {
9797
private static let bug: Bug = Bug.bug(12345, "Lorem ipsum")
9898

9999
@available(_clockAPI, *)
100-
@Test("timeLimit property", .timeLimit(duration))
100+
@Test("timeLimit property", .timeLimit(.minutes(999_999_999)))
101101
func timeLimit() async throws {
102102
let test = try #require(Test.current)
103103
let snapshot = Test.Snapshot(snapshotting: test)
104104

105-
#expect(snapshot.timeLimit == Self.duration)
106-
}
107-
108-
@available(_clockAPI, *)
109-
private static var duration: Duration {
110-
.seconds(999_999_999)
105+
#expect(snapshot.timeLimit == .seconds(60) * 999_999_999)
111106
}
112107
}
113108

Tests/TestingTests/TestSupport/TestingAdditions.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,20 @@ extension JSON {
364364
}
365365
}
366366
}
367+
368+
@available(_clockAPI, *)
369+
extension Trait where Self == TimeLimitTrait {
370+
/// Construct a time limit trait that causes a test to time out if it runs for
371+
/// too long.
372+
///
373+
/// - Parameters:
374+
/// - timeLimit: The maximum amount of time the test may run for.
375+
///
376+
/// - Returns: An instance of ``TimeLimitTrait``.
377+
///
378+
/// This function is meant for use only in testing ``TimeLimitTrait`` itself,
379+
/// and accepts any arbitrary Swift `Duration` value.
380+
static func timeLimit(_ timeLimit: Swift.Duration) -> Self {
381+
return Self(timeLimit: timeLimit)
382+
}
383+
}

Tests/TestingTests/Traits/TimeLimitTraitTests.swift

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,36 +15,40 @@ struct TimeLimitTraitTests {
1515
@available(_clockAPI, *)
1616
@Test(".timeLimit() factory method")
1717
func timeLimitTrait() throws {
18-
let test = Test(.timeLimit(.seconds(20))) {}
19-
#expect(test.timeLimit == .seconds(20))
18+
let test = Test(.timeLimit(.minutes(2))) {}
19+
#expect(test.timeLimit == .seconds(60) * 2)
2020
}
2121

2222
@available(_clockAPI, *)
2323
@Test("adjustedTimeLimit(configuration:) function")
2424
func adjustedTimeLimitMethod() throws {
25-
for seconds in 1 ... 59 {
26-
for milliseconds in 0 ... 100 {
27-
let test = Test(.timeLimit(.seconds(seconds) + .milliseconds(milliseconds * 10))) {}
28-
let adjustedTimeLimit = test.adjustedTimeLimit(configuration: .init())
29-
#expect(adjustedTimeLimit == .seconds(60))
30-
}
25+
let oneHour = Duration.seconds(60 * 60)
26+
27+
var configuration = Configuration()
28+
configuration.testTimeLimitGranularity = oneHour
29+
30+
for minutes in 1 ... 60 {
31+
let test = Test(.timeLimit(.minutes(minutes))) {}
32+
let adjustedTimeLimit = test.adjustedTimeLimit(configuration: configuration)
33+
#expect(adjustedTimeLimit == oneHour)
3134
}
3235

33-
for seconds in 60 ... 119 {
34-
let test = Test(.timeLimit(.seconds(seconds) + .milliseconds(1))) {}
35-
let adjustedTimeLimit = test.adjustedTimeLimit(configuration: .init())
36-
#expect(adjustedTimeLimit == .seconds(120))
36+
for minutes in 61 ... 120 {
37+
let test = Test(.timeLimit(.minutes(minutes))) {}
38+
let adjustedTimeLimit = test.adjustedTimeLimit(configuration: configuration)
39+
#expect(adjustedTimeLimit == oneHour * 2)
3740
}
3841
}
3942

4043
@available(_clockAPI, *)
4144
@Test("Configuration.maximumTestTimeLimit property")
4245
func maximumTimeLimit() throws {
46+
let tenMinutes = Duration.seconds(60 * 10)
4347
var configuration = Configuration()
44-
configuration.maximumTestTimeLimit = .seconds(99)
45-
let test = Test(.timeLimit(.seconds(100) + .milliseconds(100))) {}
48+
configuration.maximumTestTimeLimit = tenMinutes
49+
let test = Test(.timeLimit(.minutes(20))) {}
4650
let adjustedTimeLimit = test.adjustedTimeLimit(configuration: configuration)
47-
#expect(adjustedTimeLimit == .seconds(99))
51+
#expect(adjustedTimeLimit == tenMinutes)
4852
}
4953

5054
@available(_clockAPI, *)
@@ -239,17 +243,17 @@ struct TimeLimitTraitTests {
239243

240244
// MARK: - Fixtures
241245

242-
func timeLimitIfAvailable(milliseconds: UInt64) -> any SuiteTrait {
246+
func timeLimitIfAvailable(minutes: UInt64) -> any SuiteTrait {
243247
// @available can't be applied to a suite type, so we can't mark the suite as
244248
// available only on newer OSes.
245249
if #available(_clockAPI, *) {
246-
.timeLimit(.milliseconds(milliseconds))
250+
.timeLimit(.minutes(minutes))
247251
} else {
248252
.disabled(".timeLimit() not available")
249253
}
250254
}
251255

252-
@Suite(.hidden, timeLimitIfAvailable(milliseconds: 10))
256+
@Suite(.hidden, timeLimitIfAvailable(minutes: 10))
253257
struct TestTypeThatTimesOut {
254258
@available(_clockAPI, *)
255259
@Test(.hidden, arguments: 0 ..< 10)

0 commit comments

Comments
 (0)