diff --git a/Sources/Testing/ExitTests/ExitTest.swift b/Sources/Testing/ExitTests/ExitTest.swift index 6ef1d8175..d0e98c65b 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -178,7 +178,9 @@ func callExitTest( // common issues, however they would constitute a failure of the test // infrastructure rather than the test itself and perhaps should not cause // the test to terminate early. - Issue.record(.errorCaught(error), comments: comments(), backtrace: .current(), sourceLocation: sourceLocation, configuration: configuration) + let issue = Issue(kind: .errorCaught(error), comments: comments(), sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation)) + issue.record(configuration: configuration) + return __checkValue( false, expression: expression, diff --git a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift index 3e433cb1a..60556a39c 100644 --- a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift +++ b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift @@ -114,7 +114,9 @@ public func __checkValue( // Ensure the backtrace is captured here so it has fewer extraneous frames // from the testing framework which aren't relevant to the user. let backtrace = Backtrace.current() - Issue.record(.expectationFailed(expectation), comments: comments(), backtrace: backtrace, sourceLocation: sourceLocation) + let issue = Issue(kind: .expectationFailed(expectation), comments: comments(), sourceContext: .init(backtrace: backtrace, sourceLocation: sourceLocation)) + issue.record() + return .failure(ExpectationFailedError(expectation: expectation)) } diff --git a/Sources/Testing/Issues/Confirmation.swift b/Sources/Testing/Issues/Confirmation.swift index 5848dfb50..82f140a54 100644 --- a/Sources/Testing/Issues/Confirmation.swift +++ b/Sources/Testing/Issues/Confirmation.swift @@ -173,12 +173,12 @@ public func confirmation( defer { let actualCount = confirmation.count.rawValue if !expectedCount.contains(actualCount) { - Issue.record( - expectedCount.issueKind(forActualCount: actualCount), + let issue = Issue( + kind: expectedCount.issueKind(forActualCount: actualCount), comments: Array(comment), - backtrace: .current(), - sourceLocation: sourceLocation + sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation) ) + issue.record() } } return try await body(confirmation) diff --git a/Sources/Testing/Issues/Issue+Recording.swift b/Sources/Testing/Issues/Issue+Recording.swift index 4578373a7..2a6facefb 100644 --- a/Sources/Testing/Issues/Issue+Recording.swift +++ b/Sources/Testing/Issues/Issue+Recording.swift @@ -17,43 +17,6 @@ extension Issue { @TaskLocal static var currentKnownIssueMatcher: KnownIssueMatcher? - /// Record a new issue with the specified properties. - /// - /// - Parameters: - /// - kind: The kind of issue. - /// - comments: An array of comments describing the issue. This array may be - /// empty. - /// - backtrace: The backtrace of the issue, if available. This value is - /// used to construct an instance of ``SourceContext``. - /// - sourceLocation: The source location of the issue. This value is used - /// to construct an instance of ``SourceContext``. - /// - configuration: The test configuration to use when recording the issue. - /// The default value is ``Configuration/current``. - /// - /// - Returns: The issue that was recorded. - @discardableResult - static func record(_ kind: Kind, comments: [Comment], backtrace: Backtrace?, sourceLocation: SourceLocation, configuration: Configuration? = nil) -> Self { - let sourceContext = SourceContext(backtrace: backtrace, sourceLocation: sourceLocation) - return record(kind, comments: comments, sourceContext: sourceContext, configuration: configuration) - } - - /// Record a new issue with the specified properties. - /// - /// - Parameters: - /// - kind: The kind of issue. - /// - comments: An array of comments describing the issue. This array may be - /// empty. - /// - sourceContext: The source context of the issue. - /// - configuration: The test configuration to use when recording the issue. - /// The default value is ``Configuration/current``. - /// - /// - Returns: The issue that was recorded. - @discardableResult - static func record(_ kind: Kind, comments: [Comment], sourceContext: SourceContext, configuration: Configuration? = nil) -> Self { - let issue = Issue(kind: kind, comments: comments, sourceContext: sourceContext) - return issue.record(configuration: configuration) - } - /// Record this issue by wrapping it in an ``Event`` and passing it to the /// current event handler. /// @@ -176,13 +139,8 @@ extension Issue { // condition evaluated to `false`. Those functions record their own issue, // so we don't need to record another one redundantly. } catch { - Issue.record( - .errorCaught(error), - comments: [], - backtrace: Backtrace(forFirstThrowOf: error), - sourceLocation: sourceLocation, - configuration: configuration - ) + let issue = Issue(for: error, sourceLocation: sourceLocation) + issue.record(configuration: configuration) return error } @@ -222,13 +180,8 @@ extension Issue { // condition evaluated to `false`. Those functions record their own issue, // so we don't need to record another one redundantly. } catch { - Issue.record( - .errorCaught(error), - comments: [], - backtrace: Backtrace(forFirstThrowOf: error), - sourceLocation: sourceLocation, - configuration: configuration - ) + let issue = Issue(for: error, sourceLocation: sourceLocation) + issue.record(configuration: configuration) return error } diff --git a/Sources/Testing/Issues/Issue.swift b/Sources/Testing/Issues/Issue.swift index 91602ef7c..bb3b6f2fe 100644 --- a/Sources/Testing/Issues/Issue.swift +++ b/Sources/Testing/Issues/Issue.swift @@ -108,19 +108,38 @@ public struct Issue: Sendable { /// - comments: An array of comments describing the issue. This array may be /// empty. /// - sourceContext: A ``SourceContext`` indicating where and how this issue - /// occurred. This defaults to a default source context returned by - /// calling ``SourceContext/init(backtrace:sourceLocation:)`` with zero - /// arguments. + /// occurred. init( kind: Kind, comments: [Comment], - sourceContext: SourceContext = .init() + sourceContext: SourceContext ) { self.kind = kind self.comments = comments self.sourceContext = sourceContext } + /// Initialize an issue instance representing a caught error. + /// + /// - Parameters: + /// - error: The error which was caught which this issue is describing. + /// - sourceLocation: The source location of the issue. This value is used + /// to construct an instance of ``SourceContext``. + /// + /// The ``sourceContext`` property will have a ``SourceContext/backtrace`` + /// property whose value is the backtrace for the first throw of `error`. + init( + for error: any Error, + sourceLocation: SourceLocation? = nil + ) { + let sourceContext = SourceContext(backtrace: Backtrace(forFirstThrowOf: error), sourceLocation: sourceLocation) + self.init( + kind: .errorCaught(error), + comments: [], + sourceContext: sourceContext + ) + } + /// The error which was associated with this issue, if any. /// /// The value of this property is non-`nil` when ``kind-swift.property`` is diff --git a/Sources/Testing/Issues/KnownIssue.swift b/Sources/Testing/Issues/KnownIssue.swift index 70c9c3875..4d7c16739 100644 --- a/Sources/Testing/Issues/KnownIssue.swift +++ b/Sources/Testing/Issues/KnownIssue.swift @@ -65,12 +65,12 @@ private func _matchError(_ error: any Error, using issueMatcher: KnownIssueMatch /// attributed. private func _handleMiscount(by matchCounter: Locked, comment: Comment?, sourceLocation: SourceLocation) { if matchCounter.rawValue == 0 { - Issue.record( - .knownIssueNotRecorded, + let issue = Issue( + kind: .knownIssueNotRecorded, comments: Array(comment), - backtrace: nil, - sourceLocation: sourceLocation + sourceContext: .init(backtrace: nil, sourceLocation: sourceLocation) ) + issue.record() } } diff --git a/Sources/Testing/Running/Runner.Plan.swift b/Sources/Testing/Running/Runner.Plan.swift index ff57b2d88..6d87443a3 100644 --- a/Sources/Testing/Running/Runner.Plan.swift +++ b/Sources/Testing/Running/Runner.Plan.swift @@ -41,7 +41,7 @@ extension Runner { /// /// - Parameters: /// - skipInfo: A ``SkipInfo`` representing the details of this skip. - indirect case skip(_ skipInfo: SkipInfo = .init()) + indirect case skip(_ skipInfo: SkipInfo) /// The test should record an issue due to a failure during /// planning. @@ -241,9 +241,7 @@ extension Runner.Plan { // If no trait specified that the test should be skipped, but one did // throw an error, then the action is to record an issue for that error. if case .run = action, let error = firstCaughtError { - let sourceContext = SourceContext(backtrace: Backtrace(forFirstThrowOf: error)) - let issue = Issue(kind: .errorCaught(error), comments: [], sourceContext: sourceContext) - action = .recordIssue(issue) + action = .recordIssue(Issue(for: error)) } // If the test is still planned to run (i.e. nothing thus far has caused @@ -257,15 +255,13 @@ extension Runner.Plan { do { try await test.evaluateTestCases() } catch { - let sourceContext = SourceContext(backtrace: Backtrace(forFirstThrowOf: error)) - let issue = Issue(kind: .errorCaught(error), comments: [], sourceContext: sourceContext) - action = .recordIssue(issue) + action = .recordIssue(Issue(for: error)) } } // If the test is parameterized but has no cases, mark it as skipped. if case .run = action, let testCases = test.testCases, testCases.first(where: { _ in true }) == nil { - action = .skip(SkipInfo(comment: "No test cases found.")) + action = .skip(SkipInfo(comment: "No test cases found.", sourceContext: .init(backtrace: nil, sourceLocation: test.sourceLocation))) } actionGraph.updateValue(action, at: keyPath) @@ -437,3 +433,12 @@ extension Runner.Plan.Action { } } #endif + +// MARK: - Deprecated + +extension Runner.Plan.Action { + @available(*, deprecated, message: "Use .skip(_:) and pass a SkipInfo explicitly.") + public static func skip() -> Self { + .skip(SkipInfo()) + } +} diff --git a/Sources/Testing/Running/Runner.swift b/Sources/Testing/Running/Runner.swift index 7642ad373..954485339 100644 --- a/Sources/Testing/Running/Runner.swift +++ b/Sources/Testing/Running/Runner.swift @@ -340,13 +340,12 @@ extension Runner { try await testCase.body() } } timeoutHandler: { timeLimit in - Issue.record( - .timeLimitExceeded(timeLimitComponents: timeLimit), + let issue = Issue( + kind: .timeLimitExceeded(timeLimitComponents: timeLimit), comments: [], - backtrace: .current(), - sourceLocation: sourceLocation, - configuration: configuration + sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation) ) + issue.record(configuration: configuration) } } } diff --git a/Sources/Testing/Running/SkipInfo.swift b/Sources/Testing/Running/SkipInfo.swift index 13bbd67dc..0c5a6923d 100644 --- a/Sources/Testing/Running/SkipInfo.swift +++ b/Sources/Testing/Running/SkipInfo.swift @@ -33,11 +33,9 @@ public struct SkipInfo: Sendable { /// - comment: A user-specified comment describing this skip, if any. /// Defaults to `nil`. /// - sourceContext: A source context indicating where this skip occurred. - /// Defaults to a source context returned by calling - /// ``SourceContext/init(backtrace:sourceLocation:)`` with zero arguments. public init( comment: Comment? = nil, - sourceContext: SourceContext = .init() + sourceContext: SourceContext ) { self.comment = comment self.sourceContext = sourceContext @@ -55,3 +53,12 @@ extension SkipInfo: Equatable, Hashable {} // MARK: - Codable extension SkipInfo: Codable {} + +// MARK: - Deprecated + +extension SkipInfo { + @available(*, deprecated, message: "Use init(comment:sourceContext:) and pass an explicit SourceContext.") + public init(comment: Comment? = nil) { + self.init(comment: comment, sourceContext: .init(backtrace: .current(), sourceLocation: nil)) + } +} diff --git a/Sources/Testing/SourceAttribution/SourceContext.swift b/Sources/Testing/SourceAttribution/SourceContext.swift index dd56dc14c..8e6ff6b96 100644 --- a/Sources/Testing/SourceAttribution/SourceContext.swift +++ b/Sources/Testing/SourceAttribution/SourceContext.swift @@ -26,11 +26,10 @@ public struct SourceContext: Sendable { /// source location. /// /// - Parameters: - /// - backtrace: The backtrace associated with the new instance. Defaults to - /// the current backtrace (obtained via - /// ``Backtrace/current(maximumAddressCount:)``). - /// - sourceLocation: The source location associated with the new instance. - public init(backtrace: Backtrace? = .current(), sourceLocation: SourceLocation? = nil) { + /// - backtrace: The backtrace associated with the new instance. + /// - sourceLocation: The source location associated with the new instance, + /// if available. + public init(backtrace: Backtrace?, sourceLocation: SourceLocation?) { self.backtrace = backtrace self.sourceLocation = sourceLocation } @@ -41,3 +40,17 @@ extension SourceContext: Equatable, Hashable {} // MARK: - Codable extension SourceContext: Codable {} + +// MARK: - Deprecated + +extension SourceContext { + @available(*, deprecated, message: "Use init(backtrace:sourceLocation:) and pass both arguments explicitly instead.") + public init(backtrace: Backtrace?) { + self.init(backtrace: backtrace, sourceLocation: nil) + } + + @available(*, deprecated, message: "Use init(backtrace:sourceLocation:) and pass both arguments explicitly instead.") + public init(sourceLocation: SourceLocation? = nil) { + self.init(backtrace: nil, sourceLocation: sourceLocation) + } +} diff --git a/Sources/Testing/Test+Macro.swift b/Sources/Testing/Test+Macro.swift index 7fc2cf0eb..50afa755d 100644 --- a/Sources/Testing/Test+Macro.swift +++ b/Sources/Testing/Test+Macro.swift @@ -602,11 +602,11 @@ public func __invokeXCTestCaseMethod( guard let xcTestCaseClass, isClass(xcTestCaseSubclass, subclassOf: xcTestCaseClass) else { return false } - Issue.record( - .apiMisused, + let issue = Issue( + kind: .apiMisused, comments: ["The @Test attribute cannot be applied to methods on a subclass of XCTestCase."], - backtrace: nil, - sourceLocation: sourceLocation + sourceContext: .init(backtrace: nil, sourceLocation: sourceLocation) ) + issue.record() return true } diff --git a/Sources/Testing/Traits/ConditionTrait.swift b/Sources/Testing/Traits/ConditionTrait.swift index 55d2b0b60..09c8909dc 100644 --- a/Sources/Testing/Traits/ConditionTrait.swift +++ b/Sources/Testing/Traits/ConditionTrait.swift @@ -92,7 +92,12 @@ public struct ConditionTrait: TestTrait, SuiteTrait { } if !result { - let sourceContext = SourceContext(sourceLocation: sourceLocation) + // We don't need to consider including a backtrace here because it will + // primarily contain frames in the testing library, not user code. If an + // error was thrown by a condition evaluated above, the caller _should_ + // attempt to get the backtrace of the caught error when creating an issue + // for it, however. + let sourceContext = SourceContext(backtrace: nil, sourceLocation: sourceLocation) throw SkipInfo(comment: commentOverride ?? comments.first, sourceContext: sourceContext) } } diff --git a/Tests/TestingTests/EventRecorderTests.swift b/Tests/TestingTests/EventRecorderTests.swift index a6e284a84..525e9d252 100644 --- a/Tests/TestingTests/EventRecorderTests.swift +++ b/Tests/TestingTests/EventRecorderTests.swift @@ -358,7 +358,7 @@ struct EventRecorderTests { @Test("HumanReadableOutputRecorder counts issues without associated tests") func humanReadableRecorderCountsIssuesWithoutTests() { - let issue = Issue(kind: .unconditional, comments: [], sourceContext: .init()) + let issue = Issue(kind: .unconditional) let event = Event(.issueRecorded(issue), testID: nil, testCaseID: nil) let context = Event.Context(test: nil, testCase: nil, configuration: nil) @@ -373,7 +373,7 @@ struct EventRecorderTests { @Test("JUnitXMLRecorder counts issues without associated tests") func junitRecorderCountsIssuesWithoutTests() async throws { - let issue = Issue(kind: .unconditional, comments: [], sourceContext: .init()) + let issue = Issue(kind: .unconditional) let event = Event(.issueRecorded(issue), testID: nil, testCaseID: nil) let context = Event.Context(test: nil, testCase: nil, configuration: nil) diff --git a/Tests/TestingTests/IssueTests.swift b/Tests/TestingTests/IssueTests.swift index e31041464..9f28641e2 100644 --- a/Tests/TestingTests/IssueTests.swift +++ b/Tests/TestingTests/IssueTests.swift @@ -1038,7 +1038,7 @@ final class IssueTests: XCTestCase { func testSetSourceLocationProperty() async throws { let sourceLocation = SourceLocation(fileID: "A/B", filePath: "", line: 12345, column: 1) - var issue = Issue(kind: .unconditional, comments: [], sourceContext: .init(sourceLocation: sourceLocation)) + var issue = Issue(kind: .unconditional, sourceContext: .init(sourceLocation: sourceLocation)) var issueSourceLocation = try XCTUnwrap(issue.sourceLocation) XCTAssertEqual(issueSourceLocation.line, 12345) @@ -1480,7 +1480,7 @@ struct IssueCodingTests { } @Test func errorSnapshot() throws { - let issue = Issue(kind: .errorCaught(NSError(domain: "Domain", code: 13)), comments: []) + let issue = Issue(kind: .errorCaught(NSError(domain: "Domain", code: 13))) let underlyingError = try #require(issue.error) let issueSnapshot = Issue.Snapshot(snapshotting: issue) @@ -1501,11 +1501,7 @@ struct IssueCodingTests { sourceLocation: sourceLocation ) - let issue = Issue( - kind: .apiMisused, - comments: [], - sourceContext: sourceContext - ) + let issue = Issue(kind: .apiMisused, sourceContext: sourceContext) let issueSnapshot = Issue.Snapshot(snapshotting: issue) #expect(issueSnapshot.sourceContext == sourceContext) @@ -1525,11 +1521,7 @@ struct IssueCodingTests { sourceLocation: initialSourceLocation ) - let issue = Issue( - kind: .apiMisused, - comments: [], - sourceContext: sourceContext - ) + let issue = Issue(kind: .apiMisused, sourceContext: sourceContext) let updatedSourceLocation = SourceLocation( fileID: "fileID2", diff --git a/Tests/TestingTests/RunnerTests.swift b/Tests/TestingTests/RunnerTests.swift index 7aacc2f61..bbc88949c 100644 --- a/Tests/TestingTests/RunnerTests.swift +++ b/Tests/TestingTests/RunnerTests.swift @@ -405,8 +405,9 @@ final class RunnerTests: XCTestCase { testFunction(named: "succeedsAsync()", in: SendableTests.self), testFunction(named: "succeeds()", in: SendableTests.NestedSendableTests.self), ].map { try XCTUnwrap($0) } + let skipInfo = SkipInfo(sourceContext: .init(backtrace: nil)) let steps: [Runner.Plan.Step] = tests - .map { .init(test: $0, action: .skip()) } + .map { .init(test: $0, action: .skip(skipInfo)) } let plan = Runner.Plan(steps: steps) let testStarted = expectation(description: "Test was skipped") diff --git a/Tests/TestingTests/SkipInfoTests.swift b/Tests/TestingTests/SkipInfoTests.swift index 2bfa481b6..b89b899b6 100644 --- a/Tests/TestingTests/SkipInfoTests.swift +++ b/Tests/TestingTests/SkipInfoTests.swift @@ -13,7 +13,7 @@ @Suite("SkipInfo Tests") struct SkipInfoTests { @Test("comment property") func comment() { - var skipInfo = SkipInfo(comment: "abc123") + var skipInfo = SkipInfo(comment: "abc123", sourceContext: .init()) #expect(skipInfo.comment == "abc123") skipInfo.comment = .__line("// Foo") #expect(skipInfo.comment == .__line("// Foo")) diff --git a/Tests/TestingTests/TestSupport/TestingAdditions.swift b/Tests/TestingTests/TestSupport/TestingAdditions.swift index 05dbb6302..0f0d4641a 100644 --- a/Tests/TestingTests/TestSupport/TestingAdditions.swift +++ b/Tests/TestingTests/TestSupport/TestingAdditions.swift @@ -366,3 +366,23 @@ extension Trait where Self == TimeLimitTrait { return Self(timeLimit: timeLimit) } } + +extension Issue { + init(kind: Kind, sourceContext: SourceContext = .init()) { + self.init(kind: kind, comments: [], sourceContext: sourceContext) + } +} + +extension SourceContext { + init() { + self.init(sourceLocation: nil) + } + + init(backtrace: Backtrace?) { + self.init(backtrace: backtrace, sourceLocation: nil) + } + + init(sourceLocation: SourceLocation?) { + self.init(backtrace: .current(), sourceLocation: sourceLocation) + } +}