Skip to content

Commit 6a425f4

Browse files
bkhourigrynspan
andauthored
Update xUnit to display output on failures (#8147)
Modify the XCTest xUnit failure message to display the test result. ### Motivation: The generated xUnit XML file is not helpful when tests fail as it contains the message "failure", which is redundant with the test results. ### Modifications: Update the XML output to display the result output instead of static "failure" message. The failure message may contain irrelevant output, but it's better than the current message of `failure`. ### Result: ``` swift test ``` Requires: swiftlang/swift#78300 --------- Co-authored-by: Jonathan Grynspan <[email protected]>
1 parent 2215d09 commit 6a425f4

File tree

11 files changed

+396
-7
lines changed

11 files changed

+396
-7
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// swift-tools-version: 6.0
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "TestMultipleFailureSwiftTesting",
8+
targets: [
9+
.testTarget(
10+
name: "TestMultipleFailureSwiftTestingTests"
11+
)
12+
]
13+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Testing
2+
3+
@Test func testFailure1() async throws {
4+
#expect(Bool(false), "ST Test failure 1")
5+
}
6+
7+
@Test func testFailure2() async throws {
8+
#expect(Bool(false), "ST Test failure 2")
9+
}
10+
11+
@Test func testFailure3() async throws {
12+
#expect(Bool(false), "ST Test failure 3")
13+
}
14+
15+
@Test func testFailure4() async throws {
16+
#expect(Bool(false), "ST Test failure 4")
17+
}
18+
19+
@Test func testFailure5() async throws {
20+
#expect(Bool(false), "ST Test failure 5")
21+
}
22+
23+
@Test func testFailure6() async throws {
24+
#expect(Bool(false), "ST Test failure 6")
25+
}
26+
27+
@Test func testFailure7() async throws {
28+
#expect(Bool(false), "ST Test failure 7")
29+
}
30+
31+
@Test func testFailure8() async throws {
32+
#expect(Bool(false), "ST Test failure 8")
33+
}
34+
35+
@Test func testFailure9() async throws {
36+
#expect(Bool(false), "ST Test failure 9")
37+
}
38+
39+
@Test func testFailure10() async throws {
40+
#expect(Bool(false), "ST Test failure 10")
41+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// swift-tools-version: 5.9
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "TestMultipleFailureXCTest",
8+
targets: [
9+
.testTarget(
10+
name: "TestMultipleFailureXCTestTests"
11+
)
12+
]
13+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import XCTest
2+
3+
final class TestMultipleFailureXCTestTests: XCTestCase {
4+
func testFailure1() throws {
5+
XCTAssertFalse(true, "Test failure 1")
6+
}
7+
8+
func testFailure2() throws {
9+
XCTAssertFalse(true, "Test failure 2")
10+
}
11+
12+
func testFailure3() throws {
13+
XCTAssertFalse(true, "Test failure 3")
14+
}
15+
16+
func testFailure4() throws {
17+
XCTAssertFalse(true, "Test failure 4")
18+
}
19+
20+
func testFailure5() throws {
21+
XCTAssertFalse(true, "Test failure 5")
22+
}
23+
24+
func testFailure6() throws {
25+
XCTAssertFalse(true, "Test failure 6")
26+
}
27+
28+
func testFailure7() throws {
29+
XCTAssertFalse(true, "Test failure 7")
30+
}
31+
32+
func testFailure8() throws {
33+
XCTAssertFalse(true, "Test failure 8")
34+
}
35+
36+
func testFailure9() throws {
37+
XCTAssertFalse(true, "Test failure 9")
38+
}
39+
40+
func testFailure10() throws {
41+
XCTAssertFalse(true, "Test failure 10")
42+
}
43+
44+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// swift-tools-version: 5.9
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "TestFailuresSwiftTesting",
8+
targets: [
9+
.testTarget(
10+
name: "TestFailuresSwiftTestingTests"
11+
)
12+
]
13+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Testing
2+
3+
@Test func example() async throws {
4+
#expect(Bool(false), "Purposely failing & validating XML espace \"'<>")
5+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// swift-tools-version: 5.9
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "TestFailures",
8+
targets: [
9+
.testTarget(
10+
name: "TestFailuresTests"
11+
)
12+
]
13+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import XCTest
2+
3+
final class TestFailuresTests: XCTestCase {
4+
func testExample() throws {
5+
XCTAssertFalse(true, "Purposely failing & validating XML espace \"'<>")
6+
}
7+
}
8+

Sources/Commands/SwiftTestCommand.swift

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,15 @@ struct TestCommandOptions: ParsableArguments {
188188
help: "Path where the xUnit xml file should be generated.")
189189
var xUnitOutput: AbsolutePath?
190190

191+
@Flag(
192+
name: .customLong("experimental-xunit-message-failure"),
193+
help: ArgumentHelp(
194+
"When set, include the content of stdout/stderr in failure messages (XCTest only, experimental).",
195+
visibility: .hidden
196+
)
197+
)
198+
var shouldShowDetailedFailureMessage: Bool = false
199+
191200
/// Generate LinuxMain entries and exit.
192201
@Flag(name: .customLong("testable-imports"), inversion: .prefixedEnableDisable, help: "Enable or disable testable imports. Enabled by default.")
193202
var enableTestableImports: Bool = true
@@ -416,7 +425,10 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
416425
fileSystem: swiftCommandState.fileSystem,
417426
results: testResults
418427
)
419-
try generator.generate(at: xUnitOutput)
428+
try generator.generate(
429+
at: xUnitOutput,
430+
detailedFailureMessage: self.options.shouldShowDetailedFailureMessage
431+
)
420432
}
421433

422434
// MARK: - Common implementation
@@ -1379,7 +1391,7 @@ final class XUnitGenerator {
13791391
}
13801392

13811393
/// Generate the file at the given path.
1382-
func generate(at path: AbsolutePath) throws {
1394+
func generate(at path: AbsolutePath, detailedFailureMessage: Bool) throws {
13831395
var content =
13841396
"""
13851397
<?xml version="1.0" encoding="UTF-8"?>
@@ -1412,7 +1424,8 @@ final class XUnitGenerator {
14121424
"""
14131425

14141426
if !result.success {
1415-
content += "<failure message=\"failed\"></failure>\n"
1427+
let failureMessage = detailedFailureMessage ? result.output.map(_escapeForXML).joined() : "failure"
1428+
content += "<failure message=\"\(failureMessage)\"></failure>\n"
14161429
}
14171430

14181431
content += "</testcase>\n"
@@ -1429,6 +1442,32 @@ final class XUnitGenerator {
14291442
}
14301443
}
14311444

1445+
/// Escape a single Unicode character for use in an XML-encoded string.
1446+
///
1447+
/// - Parameters:
1448+
/// - character: The character to escape.
1449+
///
1450+
/// - Returns: `character`, or a string containing its escaped form.
1451+
private func _escapeForXML(_ character: Character) -> String {
1452+
switch character {
1453+
case "\"":
1454+
"&quot;"
1455+
case "<":
1456+
"&lt;"
1457+
case ">":
1458+
"&gt;"
1459+
case "&":
1460+
"&amp;"
1461+
case _ where !character.isASCII || character.isNewline:
1462+
character.unicodeScalars.lazy
1463+
.map(\.value)
1464+
.map { "&#\($0);" }
1465+
.joined()
1466+
default:
1467+
String(character)
1468+
}
1469+
}
1470+
14321471
extension SwiftCommandState {
14331472
func buildParametersForTest(
14341473
options: TestCommandOptions

Sources/_InternalTestSupport/SwiftPMProduct.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,15 @@ extension SwiftPM {
7676
/// - args: The arguments to pass.
7777
/// - env: Additional environment variables to pass. The values here are merged with default env.
7878
/// - packagePath: Adds argument `--package-path <path>` if not nil.
79+
/// - throwIfCommandFails: If set, will throw an error if the command does not have a 0 return code.
7980
///
8081
/// - Returns: The output of the process.
8182
@discardableResult
8283
public func execute(
8384
_ args: [String] = [],
8485
packagePath: AbsolutePath? = nil,
85-
env: Environment? = nil
86+
env: Environment? = nil,
87+
throwIfCommandFails: Bool = true
8688
) async throws -> (stdout: String, stderr: String) {
8789
let result = try await executeProcess(
8890
args,
@@ -93,8 +95,11 @@ extension SwiftPM {
9395
let stdout = try result.utf8Output()
9496
let stderr = try result.utf8stderrOutput()
9597

98+
let returnValue = (stdout: stdout, stderr: stderr)
99+
if (!throwIfCommandFails) { return returnValue }
100+
96101
if result.exitStatus == .terminated(code: 0) {
97-
return (stdout: stdout, stderr: stderr)
102+
return returnValue
98103
}
99104
throw SwiftPMError.executionFailure(
100105
underlying: AsyncProcessResult.Error.nonZeroExit(result),

0 commit comments

Comments
 (0)