Skip to content

Commit 55d0023

Browse files
Add a (internal-only) CustomIssueRepresentable protocol. (#945)
This PR adds a (for now internal-only) protocol that lets us transform arbitrary errors to arbitrary issues (rather than always emitting them as `.errorCaught`). This protocol replaces the special-casing we were doing for `SystemError` and `APIMisuseError`. This change also causes us to emit an API misuse issue if a developer passes an error of type `ExpectationFailedError` to `Issue.record()` as we can reasonably assume that such an error was already recorded correctly as `.expectationFailed`. ### 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. --------- Co-authored-by: Stuart Montgomery <[email protected]>
1 parent 5a95228 commit 55d0023

File tree

5 files changed

+169
-49
lines changed

5 files changed

+169
-49
lines changed

Sources/Testing/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,14 @@ add_library(Testing
7373
Support/Additions/WinSDKAdditions.swift
7474
Support/CartesianProduct.swift
7575
Support/CError.swift
76+
Support/CustomIssueRepresentable.swift
7677
Support/Environment.swift
7778
Support/FileHandle.swift
7879
Support/GetSymbol.swift
7980
Support/Graph.swift
8081
Support/JSON.swift
8182
Support/Locked.swift
8283
Support/Locked+Platform.swift
83-
Support/SystemError.swift
8484
Support/Versions.swift
8585
Discovery.swift
8686
Discovery+Platform.swift

Sources/Testing/Issues/Issue+Recording.swift

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,11 @@ extension Issue {
2727
/// - Returns: The issue that was recorded (`self` or a modified copy of it.)
2828
@discardableResult
2929
func record(configuration: Configuration? = nil) -> Self {
30-
// If this issue is a caught error of kind SystemError, reinterpret it as a
31-
// testing system issue instead (per the documentation for SystemError.)
30+
// If this issue is a caught error that has a custom issue representation,
31+
// perform that customization now.
3232
if case let .errorCaught(error) = kind {
33-
// TODO: consider factoring this logic out into a protocol
34-
if let error = error as? SystemError {
35-
var selfCopy = self
36-
selfCopy.kind = .system
37-
selfCopy.comments.append(Comment(rawValue: String(describingForTest: error)))
38-
return selfCopy.record(configuration: configuration)
39-
} else if let error = error as? APIMisuseError {
40-
var selfCopy = self
41-
selfCopy.kind = .apiMisused
42-
selfCopy.comments.append(Comment(rawValue: String(describingForTest: error)))
33+
if let error = error as? any CustomIssueRepresentable {
34+
let selfCopy = error.customize(self)
4335
return selfCopy.record(configuration: configuration)
4436
}
4537
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024–2025 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
/// A protocol that provides instances of conforming types with the ability to
12+
/// record themselves as test issues.
13+
///
14+
/// When a type conforms to this protocol, values of that type can be passed to
15+
/// ``Issue/record(_:_:)``. The testing library then calls the
16+
/// ``customize(_:)`` function and passes it an instance of ``Issue`` that will
17+
/// be used to represent the value. The function can then reconfigure or replace
18+
/// the issue as needed.
19+
///
20+
/// This protocol may become part of the testing library's public interface in
21+
/// the future. There's not really anything _requiring_ it to conform to `Error`
22+
/// but all our current use cases are error types. If we want to allow other
23+
/// types to be represented as issues, we will need to add an overload of
24+
/// `Issue.record()` that takes `some CustomIssueRepresentable`.
25+
protocol CustomIssueRepresentable: Error {
26+
/// Customize the issue that will represent this value.
27+
///
28+
/// - Parameters:
29+
/// - issue: The issue to customize. The function consumes this value.
30+
///
31+
/// - Returns: A customized copy of `issue`.
32+
func customize(_ issue: consuming Issue) -> Issue
33+
}
34+
35+
// MARK: - Internal error types
36+
37+
/// A type representing an error in the testing library or its underlying
38+
/// infrastructure.
39+
///
40+
/// When an error of this type is thrown and caught by the testing library, it
41+
/// is recorded as an issue of kind ``Issue/Kind/system`` rather than
42+
/// ``Issue/Kind/errorCaught(_:)``.
43+
///
44+
/// This type is not part of the public interface of the testing library.
45+
/// External callers should generally record issues by throwing their own errors
46+
/// or by calling ``Issue/record(_:sourceLocation:)``.
47+
struct SystemError: Error, CustomStringConvertible, CustomIssueRepresentable {
48+
var description: String
49+
50+
func customize(_ issue: consuming Issue) -> Issue {
51+
issue.kind = .system
52+
issue.comments.append("\(self)")
53+
return issue
54+
}
55+
}
56+
57+
/// A type representing misuse of testing library API.
58+
///
59+
/// When an error of this type is thrown and caught by the testing library, it
60+
/// is recorded as an issue of kind ``Issue/Kind/apiMisused`` rather than
61+
/// ``Issue/Kind/errorCaught(_:)``.
62+
///
63+
/// This type is not part of the public interface of the testing library.
64+
/// External callers should generally record issues by throwing their own errors
65+
/// or by calling ``Issue/record(_:sourceLocation:)``.
66+
struct APIMisuseError: Error, CustomStringConvertible, CustomIssueRepresentable {
67+
var description: String
68+
69+
func customize(_ issue: consuming Issue) -> Issue {
70+
issue.kind = .apiMisused
71+
issue.comments.append("\(self)")
72+
return issue
73+
}
74+
}
75+
76+
extension ExpectationFailedError: CustomIssueRepresentable {
77+
func customize(_ issue: consuming Issue) -> Issue {
78+
// Instances of this error type are thrown by `#require()` after an issue of
79+
// kind `.expectationFailed` has already been recorded. Code that rethrows
80+
// this error does not generate a new issue, but code that passes this error
81+
// to Issue.record() is misbehaving.
82+
issue.kind = .apiMisused
83+
issue.comments.append("Recorded an error of type \(Self.self) representing an expectation that failed and was already recorded: \(expectation)")
84+
return issue
85+
}
86+
}

Sources/Testing/Support/SystemError.swift

Lines changed: 0 additions & 36 deletions
This file was deleted.

Tests/TestingTests/IssueTests.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,6 +1515,84 @@ final class IssueTests: XCTestCase {
15151515

15161516
await fulfillment(of: [expectationFailed], timeout: 0.0)
15171517
}
1518+
1519+
func testThrowing(_ error: some Error, producesIssueMatching issueMatcher: @escaping @Sendable (Issue) -> Bool) async {
1520+
let issueRecorded = expectation(description: "Issue recorded")
1521+
1522+
var configuration = Configuration()
1523+
configuration.eventHandler = { event, _ in
1524+
guard case let .issueRecorded(issue) = event.kind else {
1525+
return
1526+
}
1527+
if issueMatcher(issue) {
1528+
issueRecorded.fulfill()
1529+
let description = String(describing: error)
1530+
#expect(issue.comments.map(String.init(describing:)).contains(description))
1531+
} else {
1532+
Issue.record("Unexpected issue \(issue)")
1533+
}
1534+
}
1535+
1536+
await Test {
1537+
throw error
1538+
}.run(configuration: configuration)
1539+
1540+
await fulfillment(of: [issueRecorded], timeout: 0.0)
1541+
}
1542+
1543+
func testThrowingSystemErrorProducesSystemIssue() async {
1544+
await testThrowing(
1545+
SystemError(description: "flinging excuses"),
1546+
producesIssueMatching: { issue in
1547+
if case .system = issue.kind {
1548+
return true
1549+
}
1550+
return false
1551+
}
1552+
)
1553+
}
1554+
1555+
func testThrowingAPIMisuseErrorProducesAPIMisuseIssue() async {
1556+
await testThrowing(
1557+
APIMisuseError(description: "you did it wrong"),
1558+
producesIssueMatching: { issue in
1559+
if case .apiMisused = issue.kind {
1560+
return true
1561+
}
1562+
return false
1563+
}
1564+
)
1565+
}
1566+
1567+
func testRethrowingExpectationFailedErrorCausesAPIMisuseError() async {
1568+
let expectationFailed = expectation(description: "Expectation failed (issue recorded)")
1569+
let apiMisused = expectation(description: "API misused (issue recorded)")
1570+
1571+
var configuration = Configuration()
1572+
configuration.eventHandler = { event, _ in
1573+
guard case let .issueRecorded(issue) = event.kind else {
1574+
return
1575+
}
1576+
switch issue.kind {
1577+
case .expectationFailed:
1578+
expectationFailed.fulfill()
1579+
case .apiMisused:
1580+
apiMisused.fulfill()
1581+
default:
1582+
Issue.record("Unexpected issue \(issue)")
1583+
}
1584+
}
1585+
1586+
await Test {
1587+
do {
1588+
try #require(Bool(false))
1589+
} catch {
1590+
Issue.record(error)
1591+
}
1592+
}.run(configuration: configuration)
1593+
1594+
await fulfillment(of: [expectationFailed, apiMisused], timeout: 0.0)
1595+
}
15181596
}
15191597
#endif
15201598

0 commit comments

Comments
 (0)