Skip to content

Commit 06a94d4

Browse files
authored
Enhance closure-based expectation failures. (#1582)
This PR is a follow-up of #1572 and #1581. It cleans up the output produced by the `#expect(throws:)` and `#expect(processExitsWith:)` macros to more closely match what we now output for boolean and optional expectations. For instance, the following test: ```swift @test func foo() { #expect(throws: MyError()) { throw MyParameterizedError(index: 0) } } ``` Previously output: ``` ◇ Test foo() started. ✘ Test foo() recorded an issue at [...]: Expectation failed: expected error "MyError()", but "MyParameterizedError(index: 0)" was thrown instead ↳ throw MyParameterizedError(index: 0) → MyParameterizedError(index: 0) ✘ Test foo() failed after 0.001 seconds with 1 issue. ``` But will now output: ``` ◇ Test foo() started. ✘ Test foo() recorded an issue at [...]: Expectation failed: expected error "MyError()", but "MyParameterizedError(index: 0)" was thrown instead ↳ MyError() → MyParameterizedError(index: 0) ↳ index → 0 ✘ Test foo() failed after 0.001 seconds with 1 issue. ``` And the following exit test: ```swift @test func bar() async { await #expect(processExitsWith: .exitCode(EX_USAGE)) { exit(EX_IOERR) } } ``` Previously output: ``` ◇ Test bar() started. ✘ Test bar() recorded an issue at [...]: Expectation failed: .exitCode(EX_USAGE → 64) ↳ .exitCode(EX_USAGE → 64) → .exitCode(EX_IOERR → 74) ✘ Test bar() failed after 0.040 seconds with 1 issue. ``` But will now output: ``` ◇ Test bar() started. ✘ Test bar() recorded an issue at [...]: Expectation failed: expected exit status ".exitCode(EX_USAGE)", but ".exitCode(EX_IOERR)" was reported instead ↳ .exitCode(EX_USAGE) → .exitCode(EX_IOERR) ↳ EX_USAGE → 64 ↳ EX_IOERR → 74 ✘ Test bar() failed after 0.038 seconds with 1 issue. ``` We already have code in Swift Testing that uses reflection to extract information about fields of captured values, and Xcode uses this code, but it's effectively dead when using `swift test`. With this change in place, we'll output those subexpressions recursively. For example, the following test: ```swift struct S: Equatable, CustomStringConvertible { var x: Int var description: String { "DESC" } } @test func quux() { let s1 = S(x: 1) let s2 = S(x: 2) #expect(s1 == s2) } ``` Would previously output: ``` ◇ Test quux() started. ✘ Test quux() recorded an issue at [...]: Expectation failed: s1 == s2 ↳ s1 == s2 → false ↳ s1 → DESC ↳ s2 → DESC ✘ Test quux() failed after 0.001 seconds with 1 issue. ``` And will now output: ``` ◇ Test quux() started. ✘ Test quux() recorded an issue at [...]: Expectation failed: s1 == s2 ↳ s1 == s2 → false ↳ s1 → DESC ↳ x → 1 ↳ s2 → DESC ↳ x → 2 ✘ Test quux() failed after 0.001 seconds with 1 issue. ``` ### 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.
1 parent bb8abff commit 06a94d4

10 files changed

Lines changed: 173 additions & 50 deletions

File tree

Sources/Testing/ABI/Encoded/ABI.EncodedExpression.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ extension ABI {
3838
sourceCode = expression.sourceCode
3939
runtimeValue = expression.runtimeValue.map(String.init(describingForTest:))
4040
runtimeTypeName = expression.runtimeValue.map(\.typeInfo.fullyQualifiedName)
41-
if !expression.subexpressions.isEmpty {
42-
children = expression.subexpressions.map { [eventContext = copy eventContext] subexpression in
41+
let subexpressions = expression.subexpressions
42+
if !subexpressions.isEmpty {
43+
children = subexpressions.map { [eventContext = copy eventContext] subexpression in
4344
Self(encoding: subexpression, in: eventContext)
4445
}
4546
}

Sources/Testing/ExitTests/ExitStatus.swift

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -113,41 +113,65 @@ private let _sigabbrev_np = symbol(named: "sigabbrev_np").map {
113113
@available(*, unavailable, message: "Exit tests are not available on this platform.")
114114
#endif
115115
extension ExitStatus: CustomStringConvertible {
116-
public var description: String {
116+
/// The name of the exit code or signal, if known.
117+
var name: String? {
118+
var result: String?
119+
117120
switch self {
118121
case let .exitCode(exitCode):
119-
if let name = swt_getExitCodeName(exitCode).flatMap(String.init(validatingCString:)) {
120-
return ".exitCode(\(name)\(exitCode))"
121-
}
122-
return ".exitCode(\(exitCode))"
122+
result = swt_getExitCodeName(exitCode).flatMap(String.init(validatingCString:))
123123
case let .signal(signal):
124-
var signalName: String?
125-
126124
#if SWT_TARGET_OS_APPLE || os(FreeBSD) || os(OpenBSD) || os(Android)
127125
#if !SWT_NO_SYS_SIGNAME
128126
// These platforms define sys_signame with a size, which is imported
129127
// into Swift as a tuple.
130-
withUnsafeBytes(of: sys_signame) { sys_signame in
128+
result = withUnsafeBytes(of: sys_signame) { sys_signame in
131129
sys_signame.withMemoryRebound(to: UnsafePointer<CChar>.self) { sys_signame in
132130
if signal > 0 && signal < sys_signame.count {
133-
signalName = String(validatingCString: sys_signame[Int(signal)])?.uppercased()
131+
return String(validatingCString: sys_signame[Int(signal)])
132+
.map { "SIG\($0.uppercased())" }
134133
}
134+
return nil
135135
}
136136
}
137137
#endif
138138
#elseif os(Linux)
139139
#if !SWT_NO_DYNAMIC_LINKING
140-
signalName = _sigabbrev_np?(signal).flatMap(String.init(validatingCString:))
140+
result = _sigabbrev_np?(signal)
141+
.flatMap(String.init(validatingCString:))
142+
.map { "SIG\($0)" }
141143
#endif
142144
#elseif os(Windows) || os(WASI)
143145
// These platforms do not have API to get the programmatic name of a
144146
// signal constant.
145147
#else
146148
#warning("Platform-specific implementation missing: signal names unavailable")
147149
#endif
150+
}
151+
152+
return result
153+
}
154+
155+
/// The represented exit code or signal.
156+
var code: CInt {
157+
switch self {
158+
case let .exitCode(exitCode):
159+
exitCode
160+
case let .signal(signal):
161+
signal
162+
}
163+
}
148164

149-
if let signalName {
150-
return ".signal(SIG\(signalName)\(signal))"
165+
public var description: String {
166+
switch self {
167+
case let .exitCode(exitCode):
168+
if let name {
169+
return ".exitCode(\(name))"
170+
}
171+
return ".exitCode(\(exitCode))"
172+
case let .signal(signal):
173+
if let name {
174+
return ".signal(\(name))"
151175
}
152176
return ".signal(\(signal))"
153177
}

Sources/Testing/ExitTests/ExitTest.Condition.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ extension ExitTest {
5353

5454
/// The kind of condition.
5555
private var _kind: _Kind
56+
57+
/// The represented exit status, if a specific one exists.
58+
var exitStatus: ExitStatus? {
59+
if case let .exitStatus(exitStatus) = _kind {
60+
return exitStatus
61+
}
62+
return nil
63+
}
5664
}
5765
}
5866

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,11 +527,27 @@ func callExitTest(
527527
}
528528

529529
// Plumb the exit test's result through the general expectation machinery.
530-
let expression = __Expression(String(describingForTest: expectedExitCondition))
530+
func expressionWithCapturedRuntimeValues() -> __Expression {
531+
var expression = expression.capturingRuntimeValues(result.exitStatus)
532+
533+
expression.subexpressions = [expectedExitCondition.exitStatus, result.exitStatus]
534+
.compactMap { exitStatus in
535+
guard let exitStatus, let exitStatusName = exitStatus.name else {
536+
return nil
537+
}
538+
return __Expression(
539+
exitStatusName,
540+
runtimeValue: __Expression.Value(describing: exitStatus.code)
541+
)
542+
}
543+
544+
return expression
545+
}
531546
return __checkValue(
532547
expectedExitCondition.isApproximatelyEqual(to: result.exitStatus),
533548
expression: expression,
534-
expressionWithCapturedRuntimeValues: expression.capturingRuntimeValues(result.exitStatus),
549+
expressionWithCapturedRuntimeValues: expressionWithCapturedRuntimeValues(),
550+
mismatchedExitConditionDescription: #"expected exit status "\#(expectedExitCondition)", but "\#(result.exitStatus)" was reported instead"#,
535551
comments: comments(),
536552
isRequired: isRequired,
537553
sourceLocation: sourceLocation

Sources/Testing/Expectations/Expectation+Macro.swift

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ public macro expect<E, R>(
195195
_ comment: @autoclosure () -> Comment? = nil,
196196
sourceLocation: SourceLocation = #_sourceLocation,
197197
performing expression: () throws -> R
198-
) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") where E: Error
198+
) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectThrowsMacro") where E: Error
199199

200200
/// Check that an expression always throws an error of a given type.
201201
///
@@ -259,7 +259,7 @@ public macro expect<E, R>(
259259
_ comment: @autoclosure () -> Comment? = nil,
260260
sourceLocation: SourceLocation = #_sourceLocation,
261261
performing expression: () async throws -> R
262-
) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") where E: Error
262+
) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectThrowsMacro") where E: Error
263263

264264
/// Check that an expression always throws an error of a given type, and throw
265265
/// an error if it does not.
@@ -441,7 +441,7 @@ public macro expect<E, R>(
441441
_ comment: @autoclosure () -> Comment? = nil,
442442
sourceLocation: SourceLocation = #_sourceLocation,
443443
performing expression: () throws -> R
444-
) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") where E: Error & Equatable
444+
) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectThrowsMacro") where E: Error & Equatable
445445

446446
/// Check that an expression always throws a specific error.
447447
///
@@ -481,7 +481,7 @@ public macro expect<E, R>(
481481
_ comment: @autoclosure () -> Comment? = nil,
482482
sourceLocation: SourceLocation = #_sourceLocation,
483483
performing expression: () async throws -> R
484-
) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") where E: Error & Equatable
484+
) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectThrowsMacro") where E: Error & Equatable
485485

486486
/// Check that an expression always throws a specific error, and throw an error
487487
/// if it does not.
@@ -527,7 +527,7 @@ public macro require<E, R>(
527527
_ comment: @autoclosure () -> Comment? = nil,
528528
sourceLocation: SourceLocation = #_sourceLocation,
529529
performing expression: () throws -> R
530-
) -> E = #externalMacro(module: "TestingMacros", type: "RequireMacro") where E: Error & Equatable
530+
) -> E = #externalMacro(module: "TestingMacros", type: "RequireThrowsMacro") where E: Error & Equatable
531531

532532
/// Check that an expression always throws a specific error, and throw an error
533533
/// if it does not.
@@ -571,7 +571,7 @@ public macro require<E, R>(
571571
_ comment: @autoclosure () -> Comment? = nil,
572572
sourceLocation: SourceLocation = #_sourceLocation,
573573
performing expression: () async throws -> R
574-
) -> E = #externalMacro(module: "TestingMacros", type: "RequireMacro") where E: Error & Equatable
574+
) -> E = #externalMacro(module: "TestingMacros", type: "RequireThrowsMacro") where E: Error & Equatable
575575

576576
// MARK: - Arbitrary error matching
577577

@@ -637,7 +637,7 @@ public macro expect<R>(
637637
sourceLocation: SourceLocation = #_sourceLocation,
638638
performing expression: () throws -> R,
639639
throws errorMatcher: (any Error) throws -> Bool
640-
) -> (any Error)? = #externalMacro(module: "TestingMacros", type: "ExpectMacro")
640+
) -> (any Error)? = #externalMacro(module: "TestingMacros", type: "ExpectThrowsMacro")
641641

642642
/// Check that an expression always throws an error matching some condition.
643643
///
@@ -699,7 +699,7 @@ public macro expect<R>(
699699
sourceLocation: SourceLocation = #_sourceLocation,
700700
performing expression: () async throws -> R,
701701
throws errorMatcher: (any Error) async throws -> Bool
702-
) -> (any Error)? = #externalMacro(module: "TestingMacros", type: "ExpectMacro")
702+
) -> (any Error)? = #externalMacro(module: "TestingMacros", type: "ExpectThrowsMacro")
703703

704704
/// Check that an expression always throws an error matching some condition, and
705705
/// throw an error if it does not.
@@ -770,7 +770,7 @@ public macro require<R>(
770770
sourceLocation: SourceLocation = #_sourceLocation,
771771
performing expression: () throws -> R,
772772
throws errorMatcher: (any Error) throws -> Bool
773-
) -> any Error = #externalMacro(module: "TestingMacros", type: "RequireMacro")
773+
) -> any Error = #externalMacro(module: "TestingMacros", type: "RequireThrowsMacro")
774774

775775
/// Check that an expression always throws an error matching some condition, and
776776
/// throw an error if it does not.
@@ -839,7 +839,7 @@ public macro require<R>(
839839
sourceLocation: SourceLocation = #_sourceLocation,
840840
performing expression: () async throws -> R,
841841
throws errorMatcher: (any Error) async throws -> Bool
842-
) -> any Error = #externalMacro(module: "TestingMacros", type: "RequireMacro")
842+
) -> any Error = #externalMacro(module: "TestingMacros", type: "RequireThrowsMacro")
843843

844844
// MARK: - Exit tests
845845

Sources/Testing/SourceAttribution/Expression+Macro.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ extension __Expression {
2020
/// - Warning: This function is used to implement the `@Test`, `@Suite`,
2121
/// `#expect()` and `#require()` macros. Do not call it directly.
2222
public static func __fromSyntaxNode(_ syntaxNode: String) -> Self {
23-
Self(kind: .generic(syntaxNode))
23+
Self(syntaxNode)
2424
}
2525

2626
/// Create an instance of this type representing a string literal.
@@ -35,7 +35,7 @@ extension __Expression {
3535
/// - Warning: This function is used to implement the `@Test`, `@Suite`,
3636
/// `#expect()` and `#require()` macros. Do not call it directly.
3737
public static func __fromStringLiteral(_ sourceCode: String, _ stringValue: String) -> Self {
38-
Self(kind: .generic(sourceCode))
38+
Self(sourceCode)
3939
}
4040

4141
/// Create an instance of this type representing a binary operation.
@@ -51,7 +51,7 @@ extension __Expression {
5151
/// `#expect()` and `#require()` macros. Do not call it directly.
5252
public static func __fromBinaryOperation(_ lhs: Self, _ op: String, _ rhs: Self) -> Self {
5353
return Self(
54-
kind: .generic("\(lhs) \(op) \(rhs)"),
54+
"\(lhs) \(op) \(rhs)",
5555
subexpressions: [lhs, rhs]
5656
)
5757
}
@@ -85,7 +85,7 @@ extension __Expression {
8585
sourceCode = "\(sourceCode)(\(argumentsSourceCode))"
8686

8787
return Self(
88-
kind: .generic(sourceCode),
88+
sourceCode,
8989
subexpressions: Array(value) + arguments.map(\.value)
9090
)
9191
}
@@ -103,7 +103,7 @@ extension __Expression {
103103
/// `#expect()` and `#require()` macros. Do not call it directly.
104104
public static func __fromPropertyAccess(_ value: Self, _ keyPath: Self) -> Self {
105105
Self(
106-
kind: .generic("\(value.sourceCode).\(keyPath.sourceCode)"),
106+
"\(value.sourceCode).\(keyPath.sourceCode)",
107107
subexpressions: [value, keyPath]
108108
)
109109
}
@@ -128,7 +128,7 @@ extension __Expression {
128128
"!\(expression.sourceCode)"
129129
}
130130
return Self(
131-
kind: .generic(sourceCode),
131+
sourceCode,
132132
isNegated: true,
133133
subexpressions: [expression]
134134
)

Sources/Testing/SourceAttribution/Expression.swift

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ public struct __Expression: Sendable {
4646
/// instance of this type.
4747
var kind: Kind
4848

49+
init(
50+
_ sourceCode: String,
51+
isNegated: Bool = false,
52+
runtimeValue: Value? = nil,
53+
subexpressions: [Self] = []
54+
) {
55+
self.kind = .generic(sourceCode)
56+
self.isNegated = isNegated
57+
self.runtimeValue = runtimeValue
58+
self._subexpressions = subexpressions
59+
}
60+
4961
/// Whether or not this instance represents a negated expression (`!foo`).
5062
var isNegated = false
5163

@@ -340,9 +352,32 @@ public struct __Expression: Sendable {
340352
return result
341353
}
342354

355+
/// Storage for ``subexpressions``.
356+
private var _subexpressions = [Self]()
357+
343358
/// The set of parsed and captured subexpressions contained in this instance.
344359
@_spi(ForToolsIntegrationOnly)
345-
public internal(set) var subexpressions = [Self]()
360+
public internal(set) var subexpressions: [Self] {
361+
get {
362+
if !_subexpressions.isEmpty {
363+
return _subexpressions
364+
}
365+
// If there were no explicitly-added subexpressions, look for any
366+
// subexpressions captured via reflection instead.
367+
if let children = runtimeValue?.children {
368+
return children.compactMap { child in
369+
guard let label = child.label else {
370+
return nil
371+
}
372+
return __Expression(label, runtimeValue: child)
373+
}
374+
}
375+
return []
376+
}
377+
set {
378+
_subexpressions = newValue
379+
}
380+
}
346381

347382
/// A description of the difference between the operands in this expression,
348383
/// if that difference could be determined.
@@ -383,7 +418,7 @@ extension __Expression: CustomStringConvertible, CustomDebugStringConvertible {
383418
/// This initializer does not attempt to parse `sourceCode`.
384419
@_spi(ForToolsIntegrationOnly)
385420
public init(_ sourceCode: String) {
386-
self.init(kind: .generic(sourceCode))
421+
self.init(sourceCode, isNegated: false)
387422
}
388423

389424
public var description: String {

0 commit comments

Comments
 (0)