Skip to content

Commit f926e06

Browse files
committed
Sink iteration down to a per-test-case iteration
1 parent b986343 commit f926e06

2 files changed

Lines changed: 122 additions & 68 deletions

File tree

Sources/Testing/Running/Runner.swift

Lines changed: 76 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -402,35 +402,87 @@ extension Runner {
402402
/// This function sets ``Test/Case/current``, then invokes the test case's
403403
/// body closure.
404404
private static func _runTestCase(_ testCase: Test.Case, within step: Plan.Step, context: _Context) async {
405-
let configuration = _configuration
405+
await _applyRepetitionPolicy(test: step.test, testCase: testCase) {
406+
let configuration = _configuration
406407

407-
Event.post(.testCaseStarted, for: (step.test, testCase), configuration: configuration)
408-
defer {
409-
Event.post(.testCaseEnded, for: (step.test, testCase), configuration: configuration)
410-
}
408+
Event.post(.testCaseStarted, for: (step.test, testCase), configuration: configuration)
409+
defer {
410+
Event.post(.testCaseEnded, for: (step.test, testCase), configuration: configuration)
411+
}
411412

412-
await Test.Case.withCurrent(testCase) {
413-
let sourceLocation = step.test.sourceLocation
414-
await Issue.withErrorRecording(at: sourceLocation, configuration: configuration) {
415-
// Exit early if the task has already been cancelled.
416-
try Task.checkCancellation()
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()
417418

418-
try await withTimeLimit(for: step.test, configuration: configuration) {
419-
try await _applyScopingTraits(for: step.test, testCase: testCase) {
420-
try await testCase.body()
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)
421430
}
422-
} timeoutHandler: { timeLimit in
423-
let issue = Issue(
424-
kind: .timeLimitExceeded(timeLimitComponents: timeLimit),
425-
comments: [],
426-
sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation)
427-
)
428-
issue.record(configuration: configuration)
429431
}
430432
}
431433
}
432434
}
433435

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: () async -> Void
449+
) async {
450+
var config = _configuration
451+
452+
for i in 0..<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
458+
}
459+
}
460+
eventHandler(event, context)
461+
}
462+
463+
await Configuration.withCurrent(config) {
464+
Event.post(.iterationStarted(i), for: (test, testCase))
465+
defer {
466+
Event.post(.iterationEnded(i), for: (test, testCase))
467+
}
468+
await body()
469+
}
470+
471+
// Determine if the test plan should iterate again.
472+
let shouldContinue = switch config.repetitionPolicy.continuationCondition {
473+
case nil:
474+
true
475+
case .untilIssueRecorded:
476+
!issueRecorded.rawValue
477+
case .whileIssueRecorded:
478+
issueRecorded.rawValue
479+
}
480+
guard shouldContinue else {
481+
break
482+
}
483+
}
484+
}
485+
434486
/// Run the tests in this runner's plan.
435487
public func run() async {
436488
await Self._run(self)
@@ -450,17 +502,6 @@ extension Runner {
450502
runner.configureAttachmentHandling()
451503
#endif
452504

453-
// Track whether or not any issues were recorded across the entire run.
454-
let issueRecorded = Mutex(false)
455-
runner.configuration.eventHandler = { [eventHandler = runner.configuration.eventHandler] event, context in
456-
if case let .issueRecorded(issue) = event.kind, !issue.isKnown {
457-
issueRecorded.withLock { issueRecorded in
458-
issueRecorded = true
459-
}
460-
}
461-
eventHandler(event, context)
462-
}
463-
464505
// Context to pass into the test run. We intentionally don't pass the Runner
465506
// itself (implicitly as `self` nor as an argument) because we don't want to
466507
// accidentally depend on e.g. the `configuration` property rather than the
@@ -490,43 +531,11 @@ extension Runner {
490531
Event.post(.runEnded, for: (nil, nil), configuration: runner.configuration)
491532
}
492533

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

Tests/TestingTests/PlanIterationTests.swift

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ 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 {
2121
await confirmation("N iterations started") { started in
@@ -118,6 +118,51 @@ struct PlanIterationTests {
118118
}
119119
}
120120

121+
@Test
122+
func iterationOnlyRepeatsFailingTest() async {
123+
let iterationIndexForFailingTest = Mutex(0)
124+
let iterationIndexForSucceedingTest = Mutex(0)
125+
126+
let iterationCount = 10
127+
let iterationWithoutIssue = 5
128+
129+
var configuration = Configuration()
130+
configuration.eventHandler = { event, context in
131+
guard let test = context.test else { return }
132+
133+
if case let .iterationStarted(index) = event.kind {
134+
if test.name.contains("Failing") {
135+
iterationIndexForFailingTest.withLock { iterationIndex in
136+
iterationIndex = index
137+
}
138+
}
139+
if test.name.contains("Succeeding") {
140+
iterationIndexForSucceedingTest.withLock { iterationIndex in
141+
iterationIndex = index
142+
}
143+
}
144+
}
145+
}
146+
configuration.repetitionPolicy = .repeating(.whileIssueRecorded, maximumIterationCount: iterationCount)
147+
148+
let runner = await Runner(testing: [
149+
Test(name: "Failing") {
150+
if iterationIndexForFailingTest.rawValue < iterationWithoutIssue {
151+
#expect(Bool(false))
152+
}
153+
},
154+
Test(name: "Succeeding") {
155+
#expect(Bool(true))
156+
},
157+
158+
], configuration: configuration)
159+
160+
await runner.run()
161+
162+
#expect(iterationIndexForFailingTest.rawValue == iterationWithoutIssue)
163+
#expect(iterationIndexForSucceedingTest.rawValue == 0)
164+
}
165+
121166
#if !SWT_NO_EXIT_TESTS
122167
@Test("Iteration count must be positive")
123168
func positiveIterationCount() async {

0 commit comments

Comments
 (0)