Skip to content

Commit 73f87ae

Browse files
committed
Add failure summary to ConsoleOutputRecorder. Implemented end-of-run failure summary using expandedDebugDescription for detailed output, with support for custom display names and parameterized tests.
1 parent 38067d6 commit 73f87ae

File tree

2 files changed

+179
-0
lines changed

2 files changed

+179
-0
lines changed

Sources/Testing/Events/Recorder/Event.ConsoleOutputRecorder.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,15 @@ extension Event.ConsoleOutputRecorder {
306306
/// destination.
307307
@discardableResult public func record(_ event: borrowing Event, in context: borrowing Event.Context) -> Bool {
308308
let messages = _humanReadableOutputRecorder.record(event, in: context)
309+
310+
// Print failure summary when run ends
311+
if case .runEnded = event.kind {
312+
let summary = _humanReadableOutputRecorder.generateFailureSummary(options: options)
313+
if !summary.isEmpty {
314+
// Add blank line before summary and after summary for visual separation
315+
write("\n\(summary)\n")
316+
}
317+
}
309318

310319
// Padding to use in place of a symbol for messages that don't have one.
311320
var padding = " "

Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ extension Event {
6464

6565
/// A type describing data tracked on a per-test basis.
6666
struct TestData {
67+
/// A lightweight struct containing information about a single issue.
68+
struct IssueInfo: Sendable {
69+
/// The source location where the issue occurred.
70+
var sourceLocation: SourceLocation?
71+
72+
/// A detailed description of what failed (using expanded debug description).
73+
var description: String
74+
75+
/// Whether this issue is a known issue.
76+
var isKnown: Bool
77+
}
78+
6779
/// The instant at which the test started.
6880
var startInstant: Test.Clock.Instant
6981

@@ -76,6 +88,16 @@ extension Event {
7688

7789
/// Information about the cancellation of this test or test case.
7890
var cancellationInfo: SkipInfo?
91+
92+
/// Array of all issues recorded for this test (for failure summary).
93+
/// Each issue is stored individually with its own source location.
94+
var issues: [IssueInfo] = []
95+
96+
/// The test's display name, if any.
97+
var testDisplayName: String?
98+
99+
/// The test case arguments, formatted for display (for parameterized tests).
100+
var testCaseArguments: String?
79101
}
80102

81103
/// Data tracked on a per-test basis.
@@ -317,6 +339,36 @@ extension Event.HumanReadableOutputRecorder {
317339
let issueCount = testData.issueCount[issue.severity] ?? 0
318340
testData.issueCount[issue.severity] = issueCount + 1
319341
}
342+
343+
// Store individual issue information for failure summary (only for errors)
344+
if issue.severity == .error {
345+
// Extract detailed failure message using expandedDebugDescription
346+
let description: String
347+
if case let .expectationFailed(expectation) = issue.kind {
348+
// Use expandedDebugDescription for full detail (variable values expanded)
349+
description = expectation.evaluatedExpression.expandedDebugDescription()
350+
} else if let comment = issue.comments.first {
351+
description = comment.rawValue
352+
} else {
353+
description = "Test failed"
354+
}
355+
356+
let issueInfo = Context.TestData.IssueInfo(
357+
sourceLocation: issue.sourceLocation,
358+
description: description,
359+
isKnown: issue.isKnown
360+
)
361+
testData.issues.append(issueInfo)
362+
363+
// Capture test display name and test case arguments once per test (not per issue)
364+
if testData.testDisplayName == nil {
365+
testData.testDisplayName = test?.displayName
366+
}
367+
if testData.testCaseArguments == nil {
368+
testData.testCaseArguments = testCase?.labeledArguments()
369+
}
370+
}
371+
320372
context.testData[keyPath] = testData
321373

322374
case .testCaseStarted:
@@ -629,6 +681,124 @@ extension Event.HumanReadableOutputRecorder {
629681

630682
return []
631683
}
684+
685+
/// Generate a failure summary string with all failed tests and their issues.
686+
///
687+
/// This method traverses the test graph and formats a summary of all failures
688+
/// that occurred during the test run. It includes the fully qualified test name
689+
/// (with suite path), individual issues with their source locations, and uses
690+
/// indentation to clearly delineate issue boundaries.
691+
///
692+
/// - Parameters:
693+
/// - options: Options for formatting (e.g., for ANSI colors and symbols).
694+
///
695+
/// - Returns: A formatted string containing the failure summary, or an empty
696+
/// string if there were no failures.
697+
public func generateFailureSummary(options: Event.ConsoleOutputRecorder.Options) -> String {
698+
let context = _context.rawValue
699+
700+
// Collect all failed tests (tests with error issues)
701+
struct FailedTestInfo {
702+
var testPath: [String] // Full path including suite names
703+
var testName: String
704+
var issues: [Context.TestData.IssueInfo]
705+
var testDisplayName: String?
706+
var testCaseArguments: String?
707+
}
708+
709+
var failedTests: [FailedTestInfo] = []
710+
711+
// Traverse the graph to find all tests with failures
712+
func traverse(graph: Graph<Context.TestDataKey, Context.TestData?>, path: [String]) {
713+
// Check if this node has test data with failures
714+
if let testData = graph.value, !testData.issues.isEmpty {
715+
let testName = path.last ?? "Unknown"
716+
717+
failedTests.append(FailedTestInfo(
718+
testPath: path,
719+
testName: testName,
720+
issues: testData.issues,
721+
testDisplayName: testData.testDisplayName,
722+
testCaseArguments: testData.testCaseArguments
723+
))
724+
}
725+
726+
// Recursively traverse children
727+
for (key, childGraph) in graph.children {
728+
let pathComponent: String?
729+
switch key {
730+
case let .string(s):
731+
let parts = s.split(separator: ":")
732+
if s.hasSuffix(".swift:") || (parts.count >= 2 && parts[0].hasSuffix(".swift")) {
733+
pathComponent = nil
734+
} else {
735+
pathComponent = s
736+
}
737+
case let .testCaseID(id):
738+
// Only include parameterized test case IDs in path, skip non-parameterized ones
739+
if let argumentIDs = id.argumentIDs, let discriminator = id.discriminator {
740+
pathComponent = "arguments: \(argumentIDs), discriminator: \(discriminator)"
741+
} else {
742+
// Non-parameterized test - don't add to path
743+
pathComponent = nil
744+
}
745+
}
746+
747+
let newPath = pathComponent.map { path + [$0] } ?? path
748+
traverse(graph: childGraph, path: newPath)
749+
}
750+
}
751+
752+
// Start traversal from root
753+
traverse(graph: context.testData, path: [])
754+
755+
// If no failures, return empty string
756+
guard !failedTests.isEmpty else {
757+
return ""
758+
}
759+
760+
var summary = ""
761+
762+
// Header with failure count
763+
let testWord = failedTests.count == 1 ? "test" : "tests"
764+
let totalIssues = failedTests.reduce(0) { $0 + $1.issues.count }
765+
let issueWord = totalIssues == 1 ? "issue" : "issues"
766+
summary += "Test run had \(failedTests.count) failed \(testWord) with \(totalIssues) \(issueWord):\n"
767+
768+
// Get the failure symbol
769+
let failSymbol = Event.Symbol.fail.stringValue(options: options)
770+
771+
// List each failed test
772+
for failedTest in failedTests {
773+
// Build fully qualified name with suite path
774+
var fullyQualifiedName = failedTest.testPath.joined(separator: "/")
775+
776+
// Use display name for the last component if available
777+
if let displayName = failedTest.testDisplayName,
778+
!failedTest.testPath.isEmpty {
779+
// Replace the last component (test name) with display name
780+
let pathWithoutLast = failedTest.testPath.dropLast()
781+
fullyQualifiedName = (pathWithoutLast + [#""\#(displayName)""#]).joined(separator: "/")
782+
}
783+
784+
summary += "\(failSymbol) \(fullyQualifiedName)\n"
785+
786+
// Show test case arguments for parameterized tests (once per test, not per issue)
787+
if let arguments = failedTest.testCaseArguments, !arguments.isEmpty {
788+
summary += " (\(arguments))\n"
789+
}
790+
791+
// List each issue for this test with indentation
792+
for issue in failedTest.issues {
793+
summary += " - \(issue.description)\n"
794+
if let location = issue.sourceLocation {
795+
summary += " at \(location.fileID):\(location.line)\n"
796+
}
797+
}
798+
}
799+
800+
return summary
801+
}
632802
}
633803

634804
extension Test.ID {

0 commit comments

Comments
 (0)