Skip to content

Commit 5a0a451

Browse files
committed
Add failure summary to ConsoleOutputRecorder
1 parent fd29262 commit 5a0a451

2 files changed

Lines changed: 323 additions & 0 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,17 @@ extension Event.ConsoleOutputRecorder {
340340
}
341341

342342
write(lines.joined())
343+
344+
// Print failure summary when run ends, unless an environment variable is
345+
// set to explicitly disable it. The summary is printed after the main
346+
// output so it appears at the very end of the console output.
347+
if case .runEnded = event.kind, Environment.flag(named: "SWT_FAILURE_SUMMARY_ENABLED") != false {
348+
if let summary = _humanReadableOutputRecorder.generateFailureSummary(options: options) {
349+
// Add blank line before summary for visual separation
350+
write("\n\(summary)")
351+
}
352+
}
353+
343354
return !messages.isEmpty
344355
}
345356

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

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,228 @@ private import Synchronization
1313
#endif
1414

1515
extension Event {
16+
/// A type that generates a failure summary from test run data.
17+
///
18+
/// This type encapsulates the logic for collecting failed tests from a test
19+
/// data graph and formatting them into a human-readable failure summary.
20+
private struct TestRunSummary: Sendable {
21+
/// Information about a single failed test case (for parameterized tests).
22+
struct FailedTestCase: Sendable {
23+
/// The test case arguments for this parameterized test case.
24+
var arguments: String
25+
26+
/// All issues recorded for this test case.
27+
var issues: [HumanReadableOutputRecorder.Context.TestData.IssueInfo]
28+
}
29+
30+
/// Information about a single failed test.
31+
struct FailedTest: Sendable {
32+
/// The name components from ``Test/ID`` (excludes the module name).
33+
var nameComponents: [String]
34+
35+
/// The human-readable name for this test, consistent with
36+
/// ``Test/humanReadableName(withVerbosity:)``.
37+
var humanReadableName: String
38+
39+
/// Whether this is a suite rather than an individual test.
40+
var isSuite: Bool
41+
42+
/// For non-parameterized tests: issues recorded directly on the test.
43+
var issues: [HumanReadableOutputRecorder.Context.TestData.IssueInfo]
44+
45+
/// For parameterized tests: test cases with their issues.
46+
var testCases: [FailedTestCase]
47+
}
48+
49+
/// The list of failed tests collected from the test run.
50+
private let failedTests: [FailedTest]
51+
52+
/// Initialize a test run summary by collecting failures from a test data
53+
/// graph.
54+
///
55+
/// - Parameters:
56+
/// - testData: The root test data graph to traverse.
57+
fileprivate init(from testData: Graph<HumanReadableOutputRecorder.Context.TestDataKey, HumanReadableOutputRecorder.Context.TestData?>) {
58+
var testMap: [String: FailedTest] = [:]
59+
60+
testData.forEach { keyPath, value in
61+
guard let testData = value, !testData.issues.isEmpty else { return }
62+
guard let testID = testData.testID else { return }
63+
64+
// Determine if this node represents a parameterized test case by
65+
// checking if the key path contains a parameterized test case ID.
66+
// Non-parameterized test cases have nil argument IDs and should be
67+
// treated as regular tests.
68+
let isTestCase = keyPath.contains {
69+
if case let .testCaseID(id) = $0 {
70+
return id.argumentIDs != nil
71+
}
72+
return false
73+
}
74+
75+
let nameComponents = testID.nameComponents
76+
let pathKey = nameComponents.joined(separator: "/")
77+
let humanReadableName = testData.humanReadableName ?? nameComponents.last ?? "Unknown"
78+
79+
if isTestCase {
80+
// This represents a parameterized test case — group it under
81+
// its parent test.
82+
if var parentTest = testMap[pathKey] {
83+
if let arguments = testData.testCaseArguments, !arguments.isEmpty {
84+
parentTest.testCases.append(FailedTestCase(
85+
arguments: arguments,
86+
issues: testData.issues
87+
))
88+
testMap[pathKey] = parentTest
89+
}
90+
} else {
91+
let parentTest = FailedTest(
92+
nameComponents: nameComponents,
93+
humanReadableName: humanReadableName,
94+
isSuite: testData.isSuite,
95+
issues: [],
96+
testCases: (testData.testCaseArguments?.isEmpty ?? true) ? [] : [FailedTestCase(
97+
arguments: testData.testCaseArguments ?? "",
98+
issues: testData.issues
99+
)]
100+
)
101+
testMap[pathKey] = parentTest
102+
}
103+
} else {
104+
// This represents a test function, not a parameterized test case.
105+
let failedTest = FailedTest(
106+
nameComponents: nameComponents,
107+
humanReadableName: humanReadableName,
108+
isSuite: testData.isSuite,
109+
issues: testData.issues,
110+
testCases: []
111+
)
112+
testMap[pathKey] = failedTest
113+
}
114+
}
115+
116+
self.failedTests = Array(testMap.values).filter { !$0.issues.isEmpty || !$0.testCases.isEmpty }
117+
}
118+
119+
/// Generate a formatted failure summary string.
120+
///
121+
/// - Parameters:
122+
/// - options: Options for formatting (e.g., for ANSI colors and symbols).
123+
///
124+
/// - Returns: A formatted string containing the failure summary, or `nil`
125+
/// if there were no failures.
126+
public func formatted(with options: Event.ConsoleOutputRecorder.Options) -> String? {
127+
// If no failures, return nil
128+
guard !failedTests.isEmpty else {
129+
return nil
130+
}
131+
132+
// Begin with the summary header.
133+
var summary = header()
134+
135+
// Get the failure symbol
136+
let failSymbol = Event.Symbol.fail.stringValue(options: options)
137+
138+
// Format each failed test
139+
for failedTest in failedTests {
140+
summary += formatFailedTest(failedTest, withSymbol: failSymbol)
141+
}
142+
143+
return summary
144+
}
145+
146+
/// Generate the summary header with failure counts.
147+
///
148+
/// - Returns: A string containing the header line.
149+
private func header() -> String {
150+
let failedTestsPhrase = failedTests.count.counting("test")
151+
var totalIssuesCount = 0
152+
for test in failedTests {
153+
totalIssuesCount += test.issues.count
154+
for testCase in test.testCases {
155+
totalIssuesCount += testCase.issues.count
156+
}
157+
}
158+
let issuesPhrase = totalIssuesCount.counting("issue")
159+
return "Test run had \(failedTestsPhrase) which recorded \(issuesPhrase) total:\n"
160+
}
161+
162+
/// Format a single failed test entry.
163+
///
164+
/// - Parameters:
165+
/// - failedTest: The failed test to format.
166+
/// - symbol: The failure symbol string to use.
167+
///
168+
/// - Returns: A formatted string representing the failed test and its
169+
/// issues.
170+
private func formatFailedTest(_ failedTest: FailedTest, withSymbol symbol: String) -> String {
171+
var result = ""
172+
173+
// Build fully qualified name
174+
let fullyQualifiedName = fullyQualifiedName(for: failedTest)
175+
176+
// Use "Suite" or "Test" based on whether this is a suite
177+
let label = failedTest.isSuite ? "Suite" : "Test"
178+
result += "\(symbol) \(label) \(fullyQualifiedName)\n"
179+
180+
// For parameterized tests: show test cases grouped under the parent test
181+
if !failedTest.testCases.isEmpty {
182+
for testCase in failedTest.testCases {
183+
// Show test case with argument count phrase and arguments
184+
let argumentCount = testCase.arguments.split(separator: ",").count
185+
let argumentPhrase = argumentCount.counting("argument")
186+
result += " Test case with \(argumentPhrase): (\(testCase.arguments))\n"
187+
// List each issue for this test case with additional indentation
188+
for issue in testCase.issues {
189+
result += formatIssue(issue, indentLevel: 2)
190+
}
191+
}
192+
} else {
193+
// For non-parameterized tests: show issues directly
194+
for issue in failedTest.issues {
195+
result += formatIssue(issue)
196+
}
197+
}
198+
199+
return result
200+
}
201+
202+
/// Build the fully qualified name for a failed test.
203+
///
204+
/// - Parameters:
205+
/// - failedTest: The failed test.
206+
///
207+
/// - Returns: The fully qualified name, using the test's
208+
/// ``Test/humanReadableName(withVerbosity:)`` for the final component.
209+
private func fullyQualifiedName(for failedTest: FailedTest) -> String {
210+
// Use the name components from Test.ID (which already exclude the module
211+
// name) and replace the last component with the human-readable name to
212+
// be consistent with how tests are described elsewhere in the output.
213+
var components = failedTest.nameComponents
214+
if !components.isEmpty {
215+
components[components.count - 1] = failedTest.humanReadableName
216+
}
217+
return components.joined(separator: "/")
218+
}
219+
220+
/// Format a single issue entry.
221+
///
222+
/// - Parameters:
223+
/// - issue: The issue to format.
224+
/// - indentLevel: The number of indentation levels (each level is 2 spaces).
225+
/// Defaults to 1.
226+
///
227+
/// - Returns: A formatted string representing the issue with indentation.
228+
private func formatIssue(_ issue: HumanReadableOutputRecorder.Context.TestData.IssueInfo, indentLevel: Int = 1) -> String {
229+
let indent = String(repeating: " ", count: indentLevel)
230+
var result = "\(indent)- \(issue.description)\n"
231+
if let location = issue.sourceLocation {
232+
result += "\(indent) at \(location)\n"
233+
}
234+
return result
235+
}
236+
}
237+
16238
/// A type which handles ``Event`` instances and outputs representations of
17239
/// them as human-readable messages.
18240
///
@@ -68,6 +290,21 @@ extension Event {
68290

69291
/// A type describing data tracked on a per-test basis.
70292
struct TestData {
293+
/// A lightweight struct containing information about a single issue.
294+
struct IssueInfo: Sendable {
295+
/// The source location where the issue occurred.
296+
var sourceLocation: SourceLocation?
297+
298+
/// A detailed description of what failed (using expanded description).
299+
var description: String
300+
301+
/// Whether this issue is a known issue.
302+
var isKnown: Bool
303+
304+
/// The severity of this issue.
305+
var severity: Issue.Severity
306+
}
307+
71308
/// The instant at which the test started.
72309
var startInstant: Test.Clock.Instant
73310

@@ -80,6 +317,25 @@ extension Event {
80317

81318
/// Information about the cancellation of this test or test case.
82319
var cancellationInfo: SkipInfo?
320+
321+
/// Array of all issues recorded for this test (for failure summary).
322+
/// Each issue is stored individually with its own source location.
323+
var issues: [IssueInfo] = []
324+
325+
/// The ID of the test, used to construct its fully qualified name in
326+
/// the failure summary.
327+
var testID: Test.ID?
328+
329+
/// The human-readable name for this test, consistent with
330+
/// ``Test/humanReadableName(withVerbosity:)``.
331+
var humanReadableName: String?
332+
333+
/// The test case arguments, formatted for display (for parameterized
334+
/// tests).
335+
var testCaseArguments: String?
336+
337+
/// Whether this is a suite rather than an individual test.
338+
var isSuite: Bool = false
83339
}
84340

85341
/// Data tracked on a per-test basis.
@@ -321,6 +577,46 @@ extension Event.HumanReadableOutputRecorder {
321577
let issueCount = testData.issueCount[issue.severity] ?? 0
322578
testData.issueCount[issue.severity] = issueCount + 1
323579
}
580+
581+
// Store individual issue information for failure summary, but only for
582+
// issues whose severity is error or greater.
583+
if issue.severity >= .error {
584+
// Extract detailed failure message
585+
let description: String
586+
if case let .expectationFailed(expectation) = issue.kind {
587+
// Use expandedDebugDescription only when verbose, otherwise use expandedDescription
588+
description = if verbosity > 0 {
589+
expectation.evaluatedExpression.expandedDebugDescription()
590+
} else {
591+
expectation.evaluatedExpression.expandedDescription()
592+
}
593+
} else if let comment = issue.comments.first {
594+
description = comment.rawValue
595+
} else {
596+
description = "Test failed"
597+
}
598+
599+
let issueInfo = Context.TestData.IssueInfo(
600+
sourceLocation: issue.sourceLocation,
601+
description: description,
602+
isKnown: issue.isKnown,
603+
severity: issue.severity
604+
)
605+
testData.issues.append(issueInfo)
606+
607+
// Capture test metadata once per test (not per issue).
608+
if testData.testID == nil {
609+
testData.testID = test?.id
610+
}
611+
if testData.humanReadableName == nil {
612+
testData.humanReadableName = test?.humanReadableName(withVerbosity: verbosity)
613+
}
614+
if testData.testCaseArguments == nil {
615+
testData.testCaseArguments = testCase?.labeledArguments()
616+
}
617+
testData.isSuite = test?.isSuite ?? false
618+
}
619+
324620
context.testData[keyPath] = testData
325621

326622
case .testCaseStarted:
@@ -636,6 +932,22 @@ extension Event.HumanReadableOutputRecorder {
636932

637933
return []
638934
}
935+
936+
/// Generate a failure summary string with all failed tests and their issues.
937+
///
938+
/// This method creates a ``TestRunSummary`` from the test data graph and
939+
/// formats it for display.
940+
///
941+
/// - Parameters:
942+
/// - options: Options for formatting (e.g., for ANSI colors and symbols).
943+
///
944+
/// - Returns: A formatted string containing the failure summary, or `nil`
945+
/// if there were no failures.
946+
func generateFailureSummary(options: Event.ConsoleOutputRecorder.Options) -> String? {
947+
let testData = _context.value.withLock { $0.testData }
948+
let summary = Event.TestRunSummary(from: testData)
949+
return summary.formatted(with: options)
950+
}
639951
}
640952

641953
extension Test.ID {

0 commit comments

Comments
 (0)