Skip to content

Commit 28f016c

Browse files
committed
Sink iteration down to a per-test-case iteration
1 parent 06a94d4 commit 28f016c

3 files changed

Lines changed: 134 additions & 107 deletions

File tree

Sources/Testing/Running/Runner.swift

Lines changed: 73 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,6 @@ extension Runner {
8282
private struct _Context: Sendable {
8383
/// A serializer used to reduce parallelism among test cases.
8484
var testCaseSerializer: Serializer?
85-
86-
/// Which iteration of the test plan is being executed.
87-
var iteration: Int
8885
}
8986

9087
/// Apply the custom scope for any test scope providers of the traits
@@ -405,31 +402,79 @@ extension Runner {
405402
/// This function sets ``Test/Case/current``, then invokes the test case's
406403
/// body closure.
407404
private static func _runTestCase(_ testCase: Test.Case, within step: Plan.Step, context: _Context) async {
408-
let configuration = _configuration
405+
await _applyRepetitionPolicy(test: step.test, testCase: testCase) { iteration in
406+
let configuration = _configuration
409407

410-
Event.post(.testCaseStarted, for: (step.test, testCase), iteration: context.iteration, configuration: configuration)
411-
defer {
412-
Event.post(.testCaseEnded, for: (step.test, testCase), iteration: context.iteration, configuration: configuration)
413-
}
408+
Event.post(.testCaseStarted, for: (step.test, testCase), iteration: iteration, configuration: configuration)
409+
defer {
410+
Event.post(.testCaseEnded, for: (step.test, testCase), iteration: iteration, configuration: configuration)
411+
}
412+
413+
await Test.Case.withCurrent(testCase) {
414+
let sourceLocation = step.test.sourceLocation
415+
await Issue.withErrorRecording(at: sourceLocation, configuration: configuration) {
416+
// Exit early if the task has already been cancelled.
417+
try Task.checkCancellation()
414418

415-
await Test.Case.withCurrent(testCase) {
416-
let sourceLocation = step.test.sourceLocation
417-
await Issue.withErrorRecording(at: sourceLocation, configuration: configuration) {
418-
// Exit early if the task has already been cancelled.
419-
try Task.checkCancellation()
419+
try await withTimeLimit(for: step.test, configuration: configuration) {
420+
try await _applyScopingTraits(for: step.test, testCase: testCase) {
421+
try await testCase.body()
422+
}
423+
} timeoutHandler: { timeLimit in
424+
let issue = Issue(
425+
kind: .timeLimitExceeded(timeLimitComponents: timeLimit),
426+
comments: [],
427+
sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation)
428+
)
429+
issue.record(configuration: configuration)
430+
}
431+
}
432+
}
433+
}
434+
}
420435

421-
try await withTimeLimit(for: step.test, configuration: configuration) {
422-
try await _applyScopingTraits(for: step.test, testCase: testCase) {
423-
try await testCase.body()
436+
/// Applies the repetition policy specified in the current configuration by running the provided test case
437+
/// repeatedly until the continuation condition is satisfied.
438+
///
439+
/// - Parameters:
440+
/// - test: The test being executed.
441+
/// - testCase: The test case being iterated.
442+
/// - body: The actual body of the function which must ultimately call into the test function.
443+
///
444+
/// - Note: This function updates ``Configuration/current`` before invoking the test body.
445+
private static func _applyRepetitionPolicy(
446+
test: Test,
447+
testCase: Test.Case,
448+
perform body: (Int) async -> Void
449+
) async {
450+
var config = _configuration
451+
452+
for iteration in 1...config.repetitionPolicy.maximumIterationCount {
453+
let issueRecorded = Mutex(false)
454+
config.eventHandler = { [eventHandler = config.eventHandler] event, context in
455+
if case let .issueRecorded(issue) = event.kind, !issue.isKnown {
456+
issueRecorded.withLock { issueRecorded in
457+
issueRecorded = true
424458
}
425-
} timeoutHandler: { timeLimit in
426-
let issue = Issue(
427-
kind: .timeLimitExceeded(timeLimitComponents: timeLimit),
428-
comments: [],
429-
sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation)
430-
)
431-
issue.record(configuration: configuration)
432459
}
460+
eventHandler(event, context)
461+
}
462+
463+
await Configuration.withCurrent(config) {
464+
await body(iteration)
465+
}
466+
467+
// Determine if the test plan should iterate again.
468+
let shouldContinue = switch config.repetitionPolicy.continuationCondition {
469+
case nil:
470+
true
471+
case .untilIssueRecorded:
472+
!issueRecorded.rawValue
473+
case .whileIssueRecorded:
474+
issueRecorded.rawValue
475+
}
476+
guard shouldContinue else {
477+
break
433478
}
434479
}
435480
}
@@ -453,23 +498,12 @@ extension Runner {
453498
runner.configureAttachmentHandling()
454499
#endif
455500

456-
// Track whether or not any issues were recorded across the entire run.
457-
let issueRecorded = Mutex(false)
458-
runner.configuration.eventHandler = { [eventHandler = runner.configuration.eventHandler] event, context in
459-
if case let .issueRecorded(issue) = event.kind, !issue.isKnown {
460-
issueRecorded.withLock { issueRecorded in
461-
issueRecorded = true
462-
}
463-
}
464-
eventHandler(event, context)
465-
}
466-
467501
// Context to pass into the test run. We intentionally don't pass the Runner
468502
// itself (implicitly as `self` nor as an argument) because we don't want to
469503
// accidentally depend on e.g. the `configuration` property rather than the
470504
// current configuration.
471505
let context: _Context = {
472-
var context = _Context(iteration: 0)
506+
var context = _Context()
473507

474508
let maximumParallelizationWidth = runner.configuration.maximumParallelizationWidth
475509
if maximumParallelizationWidth > 1 && maximumParallelizationWidth < .max {
@@ -493,46 +527,11 @@ extension Runner {
493527
Event.post(.runEnded, for: (nil, nil), configuration: runner.configuration)
494528
}
495529

496-
let repetitionPolicy = runner.configuration.repetitionPolicy
497-
let iterationCount = repetitionPolicy.maximumIterationCount
498-
for iterationIndex in 0 ..< iterationCount {
499-
Event.post(.iterationStarted(iterationIndex), for: (nil, nil), configuration: runner.configuration)
500-
defer {
501-
Event.post(.iterationEnded(iterationIndex), for: (nil, nil), configuration: runner.configuration)
502-
}
503-
504-
await withTaskGroup { [runner] taskGroup in
505-
var taskAction: String?
506-
if iterationCount > 1 {
507-
taskAction = "running iteration #\(iterationIndex + 1)"
508-
}
509-
_ = taskGroup.addTaskUnlessCancelled(name: decorateTaskName("test run", withAction: taskAction)) {
510-
var iterationContext = context
511-
// `iteration` is one-indexed, so offset that here.
512-
iterationContext.iteration = iterationIndex + 1
513-
try? await _runStep(atRootOf: runner.plan.stepGraph, context: iterationContext)
514-
}
515-
await taskGroup.waitForAll()
516-
}
517-
518-
// Determine if the test plan should iterate again. (The iteration count
519-
// is handled by the outer for-loop.)
520-
let shouldContinue = switch repetitionPolicy.continuationCondition {
521-
case nil:
522-
true
523-
case .untilIssueRecorded:
524-
!issueRecorded.rawValue
525-
case .whileIssueRecorded:
526-
issueRecorded.rawValue
527-
}
528-
guard shouldContinue else {
529-
break
530-
}
531-
532-
// Reset the run-wide "issue was recorded" flag for this iteration.
533-
issueRecorded.withLock { issueRecorded in
534-
issueRecorded = false
530+
await withTaskGroup { [runner] taskGroup in
531+
_ = taskGroup.addTaskUnlessCancelled(name: decorateTaskName("test run", withAction: nil)) {
532+
try? await _runStep(atRootOf: runner.plan.stepGraph, context: context)
535533
}
534+
await taskGroup.waitForAll()
536535
}
537536
}
538537
}

Tests/TestingTests/EventIterationTests.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ struct EventIterationTests {
4444

4545
// Verify all expected iterations were recorded
4646
let iteration = recordedIteration.rawValue
47-
#expect(iteration == expectedIterations, "Final observed iteration did not match expected number of iterations", sourceLocation: location)
47+
#expect(iteration == expectedIterations, sourceLocation: location)
4848
}
4949
}
5050

@@ -96,9 +96,7 @@ struct EventIterationTests {
9696
repetitionPolicy: policy,
9797
expectedIterations: expectedIterations
9898
) { iteration in
99-
if iteration < 3 {
100-
Issue.record("Failure")
101-
}
99+
#expect(iteration >= 3)
102100
}
103101
}
104102
}

Tests/TestingTests/PlanIterationTests.swift

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@ import Synchronization
1515
#endif
1616

1717
@Suite("Configuration.RepetitionPolicy Tests")
18-
struct PlanIterationTests {
18+
struct TestCaseIterationTests {
1919
@Test("One iteration (default behavior)")
2020
func oneIteration() async {
21-
await confirmation("N iterations started") { started in
22-
await confirmation("N iterations ended") { ended in
21+
await confirmation("1 iteration started") { started in
22+
await confirmation("1 iteration ended") { ended in
2323
var configuration = Configuration()
2424
configuration.eventHandler = { event, _ in
25-
if case .iterationStarted = event.kind {
25+
if case .testStarted = event.kind {
2626
started()
27-
} else if case .iterationEnded = event.kind {
27+
} else if case .testEnded = event.kind {
2828
ended()
2929
}
3030
}
@@ -43,9 +43,9 @@ struct PlanIterationTests {
4343
await confirmation("N iterations ended", expectedCount: iterationCount) { ended in
4444
var configuration = Configuration()
4545
configuration.eventHandler = { event, _ in
46-
if case .iterationStarted = event.kind {
46+
if case .testStarted = event.kind {
4747
started()
48-
} else if case .iterationEnded = event.kind {
48+
} else if case .testEnded = event.kind {
4949
ended()
5050
}
5151
}
@@ -62,62 +62,92 @@ struct PlanIterationTests {
6262

6363
@Test("Iteration until issue recorded")
6464
func iterationUntilIssueRecorded() async {
65-
let iterationIndex = Mutex(0)
65+
let iterations = Mutex(0)
6666
let iterationCount = 10
6767
let iterationWithIssue = 5
6868
await confirmation("N iterations started", expectedCount: iterationWithIssue + 1) { started in
6969
await confirmation("N iterations ended", expectedCount: iterationWithIssue + 1) { ended in
7070
var configuration = Configuration()
71-
configuration.eventHandler = { event, _ in
72-
if case let .iterationStarted(index) = event.kind {
73-
iterationIndex.withLock { iterationIndex in
74-
iterationIndex = index
75-
}
71+
configuration.eventHandler = { event, context in
72+
guard let iteration = context.iteration else { return }
73+
if case .testStarted = event.kind {
74+
iterations.withLock { $0 = iteration }
7675
started()
77-
} else if case .iterationEnded = event.kind {
76+
} else if case .testEnded = event.kind {
7877
ended()
7978
}
8079
}
8180
configuration.repetitionPolicy = .repeating(.untilIssueRecorded, maximumIterationCount: iterationCount)
8281

8382
await Test {
84-
if iterationIndex.rawValue == iterationWithIssue {
85-
#expect(Bool(false))
86-
}
83+
#expect(iterations.rawValue < iterationWithIssue)
8784
}.run(configuration: configuration)
8885
}
8986
}
9087
}
9188

9289
@Test("Iteration while issue recorded")
9390
func iterationWhileIssueRecorded() async {
94-
let iterationIndex = Mutex(0)
91+
let iterations = Mutex(0)
9592
let iterationCount = 10
9693
let iterationWithoutIssue = 5
97-
await confirmation("N iterations started", expectedCount: iterationWithoutIssue + 1) { started in
98-
await confirmation("N iterations ended", expectedCount: iterationWithoutIssue + 1) { ended in
94+
await confirmation("N iterations started", expectedCount: iterationWithoutIssue) { started in
95+
await confirmation("N iterations ended", expectedCount: iterationWithoutIssue) { ended in
9996
var configuration = Configuration()
100-
configuration.eventHandler = { event, _ in
101-
if case let .iterationStarted(index) = event.kind {
102-
iterationIndex.withLock { iterationIndex in
103-
iterationIndex = index
104-
}
97+
configuration.eventHandler = { event, context in
98+
guard let iteration = context.iteration else { return }
99+
if case .testStarted = event.kind {
100+
iterations.withLock { $0 = iteration }
105101
started()
106-
} else if case .iterationEnded = event.kind {
102+
} else if case .testEnded = event.kind {
107103
ended()
108104
}
109105
}
110106
configuration.repetitionPolicy = .repeating(.whileIssueRecorded, maximumIterationCount: iterationCount)
111107

112108
await Test {
113-
if iterationIndex.rawValue < iterationWithoutIssue {
114-
#expect(Bool(false))
115-
}
109+
#expect(iterations.rawValue >= iterationWithoutIssue)
116110
}.run(configuration: configuration)
117111
}
118112
}
119113
}
120114

115+
@Test
116+
func iterationOnlyRepeatsFailingTest() async {
117+
let iterationForFailingTest = Mutex(0)
118+
let iterationForSucceedingTest = Mutex(0)
119+
120+
let iterationCount = 10
121+
let iterationWithoutIssue = 5
122+
123+
var configuration = Configuration()
124+
configuration.eventHandler = { event, context in
125+
guard let test = context.test, let iteration = context.iteration else { return }
126+
if test.name.contains("Failing") {
127+
iterationForFailingTest.withLock { $0 = iteration }
128+
}
129+
if test.name.contains("Succeeding") {
130+
iterationForSucceedingTest.withLock { $0 = iteration }
131+
}
132+
}
133+
configuration.repetitionPolicy = .repeating(.whileIssueRecorded, maximumIterationCount: iterationCount)
134+
135+
let runner = await Runner(testing: [
136+
Test(name: "Failing") {
137+
#expect(iterationForFailingTest.rawValue >= iterationWithoutIssue)
138+
},
139+
Test(name: "Succeeding") {
140+
#expect(Bool(true))
141+
},
142+
143+
], configuration: configuration)
144+
145+
await runner.run()
146+
147+
#expect(iterationForFailingTest.rawValue == iterationWithoutIssue)
148+
#expect(iterationForSucceedingTest.rawValue == 1)
149+
}
150+
121151
#if !SWT_NO_EXIT_TESTS
122152
@Test("Iteration count must be positive")
123153
func positiveIterationCount() async {

0 commit comments

Comments
 (0)