Skip to content

Commit 4810d90

Browse files
authored
Add a return type for exit tests. (#762)
This PR adds a new structure, `ExitTest.Result`, that is returned from a call to `#expect(exitsWith:)` or `#require(exitsWith:)`. It currently contains the actual exit condition of the test (which should equal the expected exit condition on success) but in the future we can add additional properties such as the standard output and standard error streams. ### 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 907b2ce commit 4810d90

File tree

7 files changed

+264
-37
lines changed

7 files changed

+264
-37
lines changed

Sources/Testing/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ add_library(Testing
2929
Events/TimeValue.swift
3030
ExitTests/ExitCondition.swift
3131
ExitTests/ExitTest.swift
32+
ExitTests/ExitTest.Result.swift
3233
ExitTests/SpawnProcess.swift
3334
ExitTests/WaitFor.swift
3435
Expectations/Expectation.swift
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 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+
#if SWT_NO_EXIT_TESTS
12+
@available(*, unavailable, message: "Exit tests are not available on this platform.")
13+
#endif
14+
extension ExitTest {
15+
/// A type representing the result of an exit test after it has exited and
16+
/// returned control to the calling test function.
17+
///
18+
/// Both ``expect(exitsWith:_:sourceLocation:performing:)`` and
19+
/// ``require(exitsWith:_:sourceLocation:performing:)`` return instances of
20+
/// this type.
21+
public struct Result: Sendable {
22+
/// The exit condition the exit test exited with.
23+
///
24+
/// When the exit test passes, the value of this property is equal to the
25+
/// value of the `expectedExitCondition` argument passed to
26+
/// ``expect(exitsWith:_:sourceLocation:performing:)`` or to
27+
/// ``require(exitsWith:_:sourceLocation:performing:)``. You can compare two
28+
/// instances of ``ExitCondition`` with ``ExitCondition/==(lhs:rhs:)``.
29+
public var exitCondition: ExitCondition
30+
31+
/// Whatever error might have been thrown when trying to invoke the exit
32+
/// test that produced this result.
33+
///
34+
/// This property is not part of the public interface of the testing
35+
/// library.
36+
var caughtError: (any Error)?
37+
38+
@_spi(ForToolsIntegrationOnly)
39+
public init(exitCondition: ExitCondition) {
40+
self.exitCondition = exitCondition
41+
}
42+
43+
/// Initialize an instance of this type representing the result of an exit
44+
/// test that failed to run due to a system error or other failure.
45+
///
46+
/// - Parameters:
47+
/// - exitCondition: The exit condition the exit test exited with, if
48+
/// available. The default value of this argument is
49+
/// ``ExitCondition/failure`` for lack of a more accurate one.
50+
/// - error: The error associated with the exit test on failure, if any.
51+
///
52+
/// If an error (e.g. a failure calling `posix_spawn()`) occurs in the exit
53+
/// test handler configured by the exit test's host environment, the exit
54+
/// test handler should throw that error. The testing library will then
55+
/// record it appropriately.
56+
///
57+
/// When used with `#require(exitsWith:)`, an instance initialized with this
58+
/// initializer will throw `error`.
59+
///
60+
/// This initializer is not part of the public interface of the testing
61+
/// library.
62+
init(exitCondition: ExitCondition = .failure, catching error: any Error) {
63+
self.exitCondition = exitCondition
64+
self.caughtError = error
65+
}
66+
67+
/// Handle this instance as if it were returned from a call to `#expect()`.
68+
///
69+
/// - Warning: This function is used to implement the `#expect()` and
70+
/// `#require()` macros. Do not call it directly.
71+
@inlinable public func __expected() -> Self {
72+
self
73+
}
74+
75+
/// Handle this instance as if it were returned from a call to `#require()`.
76+
///
77+
/// - Warning: This function is used to implement the `#expect()` and
78+
/// `#require()` macros. Do not call it directly.
79+
public func __required() throws -> Self {
80+
if let caughtError {
81+
throw caughtError
82+
}
83+
return self
84+
}
85+
}
86+
}

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,18 @@
1010

1111
private import _TestingInternals
1212

13-
#if !SWT_NO_EXIT_TESTS
14-
#if SWT_NO_PIPES
15-
#error("Support for exit tests requires support for (anonymous) pipes.")
16-
#endif
17-
1813
/// A type describing an exit test.
1914
///
2015
/// Instances of this type describe an exit test defined by the test author and
2116
/// discovered or called at runtime.
22-
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
17+
@_spi(Experimental)
18+
#if SWT_NO_EXIT_TESTS
19+
@available(*, unavailable, message: "Exit tests are not available on this platform.")
20+
#endif
2321
public struct ExitTest: Sendable, ~Copyable {
22+
#if !SWT_NO_EXIT_TESTS
2423
/// The expected exit condition of the exit test.
24+
@_spi(ForToolsIntegrationOnly)
2525
public var expectedExitCondition: ExitCondition
2626

2727
/// The body closure of the exit test.
@@ -31,6 +31,7 @@ public struct ExitTest: Sendable, ~Copyable {
3131
///
3232
/// The source location is unique to each exit test and is consistent between
3333
/// processes, so it can be used to uniquely identify an exit test at runtime.
34+
@_spi(ForToolsIntegrationOnly)
3435
public var sourceLocation: SourceLocation
3536

3637
/// Disable crash reporting, crash logging, or core dumps for the current
@@ -83,6 +84,7 @@ public struct ExitTest: Sendable, ~Copyable {
8384
/// to terminate the process; if it does not, the testing library will
8485
/// terminate the process in a way that causes the corresponding expectation
8586
/// to fail.
87+
@_spi(ForToolsIntegrationOnly)
8688
public consuming func callAsFunction() async -> Never {
8789
Self._disableCrashReporting()
8890

@@ -98,8 +100,13 @@ public struct ExitTest: Sendable, ~Copyable {
98100
let expectingFailure = expectedExitCondition == .failure
99101
exit(expectingFailure ? EXIT_SUCCESS : EXIT_FAILURE)
100102
}
103+
#endif
101104
}
102105

106+
#if !SWT_NO_EXIT_TESTS
107+
#if SWT_NO_PIPES
108+
#error("Support for exit tests requires support for (anonymous) pipes.")
109+
#endif
103110
// MARK: - Discovery
104111

105112
/// A protocol describing a type that contains an exit test.
@@ -131,6 +138,7 @@ extension ExitTest {
131138
///
132139
/// - Returns: The specified exit test function, or `nil` if no such exit test
133140
/// could be found.
141+
@_spi(ForToolsIntegrationOnly)
134142
public static func find(at sourceLocation: SourceLocation) -> Self? {
135143
var result: Self?
136144

@@ -176,35 +184,47 @@ func callExitTest(
176184
isRequired: Bool,
177185
isolation: isolated (any Actor)? = #isolation,
178186
sourceLocation: SourceLocation
179-
) async -> Result<Void, any Error> {
187+
) async -> ExitTest.Result {
180188
guard let configuration = Configuration.current ?? Configuration.all.first else {
181189
preconditionFailure("A test must be running on the current task to use #expect(exitsWith:).")
182190
}
183191

184-
let actualExitCondition: ExitCondition
192+
var result: ExitTest.Result
185193
do {
186194
let exitTest = ExitTest(expectedExitCondition: expectedExitCondition, sourceLocation: sourceLocation)
187-
actualExitCondition = try await configuration.exitTestHandler(exitTest)
195+
result = try await configuration.exitTestHandler(exitTest)
188196
} catch {
189197
// An error here would indicate a problem in the exit test handler such as a
190198
// failure to find the process' path, to construct arguments to the
191-
// subprocess, or to spawn the subprocess. These are not expected to be
192-
// common issues, however they would constitute a failure of the test
193-
// infrastructure rather than the test itself and perhaps should not cause
194-
// the test to terminate early.
195-
let issue = Issue(kind: .errorCaught(error), comments: comments(), sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation))
199+
// subprocess, or to spawn the subprocess. Such failures are system issues,
200+
// not test issues, because they constitute failures of the test
201+
// infrastructure rather than the test itself.
202+
//
203+
// But here's a philosophical question: should the exit test also fail with
204+
// an expectation failure? Arguably no, because the issue is a systemic one
205+
// and (presumably) not a bug in the test. But also arguably yes, because
206+
// the exit test did not do what the test author expected it to do.
207+
let backtrace = Backtrace(forFirstThrowOf: error) ?? .current()
208+
let issue = Issue(
209+
kind: .system,
210+
comments: comments() + CollectionOfOne(Comment(rawValue: String(describingForTest: error))),
211+
sourceContext: SourceContext(backtrace: backtrace, sourceLocation: sourceLocation)
212+
)
196213
issue.record(configuration: configuration)
197214

198-
return __checkValue(
199-
false,
200-
expression: expression,
201-
comments: comments(),
202-
isRequired: isRequired,
203-
sourceLocation: sourceLocation
204-
)
215+
// For lack of a better way to handle an exit test failing in this way,
216+
// we record the system issue above, then let the expectation fail below by
217+
// reporting an exit condition that's the inverse of the expected one.
218+
result = ExitTest.Result(exitCondition: expectedExitCondition == .failure ? .success : .failure)
205219
}
206220

207-
return __checkValue(
221+
// How did the exit test actually exit?
222+
let actualExitCondition = result.exitCondition
223+
224+
// Plumb the resulting exit condition through the general expectation
225+
// machinery. If the expectation failed, capture the ExpectationFailedError
226+
// instance so that calls to #require(exitsWith:) throw it correctly.
227+
let checkResult = __checkValue(
208228
expectedExitCondition == actualExitCondition,
209229
expression: expression,
210230
expressionWithCapturedRuntimeValues: expression.capturingRuntimeValues(actualExitCondition),
@@ -213,6 +233,11 @@ func callExitTest(
213233
isRequired: isRequired,
214234
sourceLocation: sourceLocation
215235
)
236+
if case let .failure(error) = checkResult {
237+
result.caughtError = error
238+
}
239+
240+
return result
216241
}
217242

218243
// MARK: - SwiftPM/tools integration
@@ -223,7 +248,8 @@ extension ExitTest {
223248
/// - Parameters:
224249
/// - exitTest: The exit test that is starting.
225250
///
226-
/// - Returns: The condition under which the exit test exited.
251+
/// - Returns: The result of the exit test including the condition under which
252+
/// it exited.
227253
///
228254
/// - Throws: Any error that prevents the normal invocation or execution of
229255
/// the exit test.
@@ -239,7 +265,8 @@ extension ExitTest {
239265
/// are available or the child environment is otherwise terminated. The parent
240266
/// environment is then responsible for interpreting those results and
241267
/// recording any issues that occur.
242-
public typealias Handler = @Sendable (_ exitTest: borrowing ExitTest) async throws -> ExitCondition
268+
@_spi(ForToolsIntegrationOnly)
269+
public typealias Handler = @Sendable (_ exitTest: borrowing ExitTest) async throws -> ExitTest.Result
243270

244271
/// The back channel file handle set up by the parent process.
245272
///
@@ -337,7 +364,7 @@ extension ExitTest {
337364
// or unsetenv(), so we need to recompute the child environment each time.
338365
// The executable and XCTest bundle paths should not change over time, so we
339366
// can precompute them.
340-
let childProcessExecutablePath = Result { try CommandLine.executablePath }
367+
let childProcessExecutablePath = Swift.Result { try CommandLine.executablePath }
341368

342369
// Construct appropriate arguments for the child process. Generally these
343370
// arguments are going to be whatever's necessary to respawn the current
@@ -418,7 +445,7 @@ extension ExitTest {
418445
childEnvironment["SWT_EXPERIMENTAL_EXIT_TEST_SOURCE_LOCATION"] = String(decoding: json, as: UTF8.self)
419446
}
420447

421-
return try await withThrowingTaskGroup(of: ExitCondition?.self) { taskGroup in
448+
return try await withThrowingTaskGroup(of: ExitTest.Result?.self) { taskGroup in
422449
// Create a "back channel" pipe to handle events from the child process.
423450
let backChannel = try FileHandle.Pipe()
424451

@@ -453,7 +480,8 @@ extension ExitTest {
453480

454481
// Await termination of the child process.
455482
taskGroup.addTask {
456-
try await wait(for: processID)
483+
let exitCondition = try await wait(for: processID)
484+
return ExitTest.Result(exitCondition: exitCondition)
457485
}
458486

459487
// Read back all data written to the back channel by the child process

Sources/Testing/Expectations/Expectation+Macro.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,10 @@ public macro require<R>(
422422
/// issues should be attributed.
423423
/// - expression: The expression to be evaluated.
424424
///
425+
/// - Returns: An instance of ``ExitTest/Result`` describing the state of the
426+
/// exit test when it exited including the actual exit condition that it
427+
/// reported to the testing library.
428+
///
425429
/// Use this overload of `#expect()` when an expression will cause the current
426430
/// process to terminate and the nature of that termination will determine if
427431
/// the test passes or fails. For example, to test that calling `fatalError()`
@@ -479,12 +483,13 @@ public macro require<R>(
479483
#if SWT_NO_EXIT_TESTS
480484
@available(*, unavailable, message: "Exit tests are not available on this platform.")
481485
#endif
486+
@discardableResult
482487
@freestanding(expression) public macro expect(
483488
exitsWith expectedExitCondition: ExitCondition,
484489
_ comment: @autoclosure () -> Comment? = nil,
485490
sourceLocation: SourceLocation = #_sourceLocation,
486491
performing expression: @convention(thin) () async throws -> Void
487-
) = #externalMacro(module: "TestingMacros", type: "ExitTestExpectMacro")
492+
) -> ExitTest.Result = #externalMacro(module: "TestingMacros", type: "ExitTestExpectMacro")
488493

489494
/// Check that an expression causes the process to terminate in a given fashion
490495
/// and throw an error if it did not.
@@ -496,6 +501,10 @@ public macro require<R>(
496501
/// issues should be attributed.
497502
/// - expression: The expression to be evaluated.
498503
///
504+
/// - Returns: An instance of ``ExitTest/Result`` describing the state of the
505+
/// exit test when it exited including the actual exit condition that it
506+
/// reported to the testing library.
507+
///
499508
/// - Throws: An instance of ``ExpectationFailedError`` if the exit condition of
500509
/// the child process does not equal `expectedExitCondition`.
501510
///
@@ -556,9 +565,10 @@ public macro require<R>(
556565
#if SWT_NO_EXIT_TESTS
557566
@available(*, unavailable, message: "Exit tests are not available on this platform.")
558567
#endif
568+
@discardableResult
559569
@freestanding(expression) public macro require(
560570
exitsWith expectedExitCondition: ExitCondition,
561571
_ comment: @autoclosure () -> Comment? = nil,
562572
sourceLocation: SourceLocation = #_sourceLocation,
563573
performing expression: @convention(thin) () async throws -> Void
564-
) = #externalMacro(module: "TestingMacros", type: "ExitTestRequireMacro")
574+
) -> ExitTest.Result = #externalMacro(module: "TestingMacros", type: "ExitTestRequireMacro")

Sources/Testing/Expectations/ExpectationChecking+Macro.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1148,7 +1148,7 @@ public func __checkClosureCall(
11481148
isRequired: Bool,
11491149
isolation: isolated (any Actor)? = #isolation,
11501150
sourceLocation: SourceLocation
1151-
) async -> Result<Void, any Error> {
1151+
) async -> ExitTest.Result {
11521152
await callExitTest(
11531153
exitsWith: expectedExitCondition,
11541154
expression: expression,

Sources/Testing/Issues/Issue+Recording.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ extension Issue {
3232
if case let .errorCaught(error) = kind, let error = error as? SystemError {
3333
var selfCopy = self
3434
selfCopy.kind = .system
35-
selfCopy.comments.append(Comment(rawValue: String(describing: error)))
35+
selfCopy.comments.append(Comment(rawValue: String(describingForTest: error)))
3636
return selfCopy.record(configuration: configuration)
3737
}
3838

0 commit comments

Comments
 (0)