From e102698ba648b227a0fada90738d5aefd8132af3 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:51:48 +0100 Subject: [PATCH] fix(reporters): unwrap TestFailedException for failure grouping GitHub/HTML/JUnit reporters were grouping and labelling failures by the TestFailedException wrapper type instead of the real cause, so every failed test rolled up as "TestFailedException" in the Quick diagnosis and Failures by Cause sections. The wrapper exists to filter TUnit-internal frames from console stack traces (added in SimplifyStacktrace when stdout is a console). Keep that behaviour, but expose the original exception via a new WrappedException property and a static Unwrap helper, then unwrap at the reporter boundary so type detection sees e.g. InvalidOperationException. Unwrap loops to handle wrapper-of-wrapper and is null-tolerant via NotNullIfNotNull. --- TUnit.Engine.Tests/GitHubReporterTests.cs | 22 +++++++++++++++++++ .../Exceptions/TUnitFailedException.cs | 16 +++++++++++++- TUnit.Engine/Reporters/GitHubReporter.cs | 9 ++++---- TUnit.Engine/Reporters/Html/HtmlReporter.cs | 3 +++ TUnit.Engine/Xml/JUnitXmlWriter.cs | 5 +++-- 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/TUnit.Engine.Tests/GitHubReporterTests.cs b/TUnit.Engine.Tests/GitHubReporterTests.cs index 81cf2382c9..19a25df118 100644 --- a/TUnit.Engine.Tests/GitHubReporterTests.cs +++ b/TUnit.Engine.Tests/GitHubReporterTests.cs @@ -1,6 +1,7 @@ using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.TestHost; using Shouldly; +using TUnit.Engine.Exceptions; using TUnit.Engine.Reporters; namespace TUnit.Engine.Tests; @@ -260,6 +261,27 @@ await FeedTestMessages(reporter, output.ShouldContain("Timeout"); } + [Test] + public async Task AfterRunAsync_Unwraps_TestFailedException_For_Grouping() + { + var (reporter, outputFile) = await SetupReporter(); + + var inner1 = new InvalidOperationException("Docker image not created"); + var inner2 = new InvalidOperationException("Docker image not created"); + await FeedTestMessages(reporter, + CreateFailedTestMessage("1", "T1", "Svc", new TestFailedException(inner1)), + CreateFailedTestMessage("2", "T2", "Svc", new TestFailedException(inner2)) + ); + + await reporter.AfterRunAsync(1, CancellationToken.None); + + var output = await File.ReadAllTextAsync(outputFile); + output.ShouldContain("InvalidOperationException (2 tests)"); + output.ShouldNotContain("TestFailedException ("); + output.ShouldContain("Docker image not created"); + output.ShouldContain("2 × `InvalidOperationException`"); + } + [Test] public async Task AfterRunAsync_Other_NonPassing_Tests_Remain_Separate() { diff --git a/TUnit.Engine/Exceptions/TUnitFailedException.cs b/TUnit.Engine/Exceptions/TUnitFailedException.cs index 32ab50321f..8cad6602d2 100644 --- a/TUnit.Engine/Exceptions/TUnitFailedException.cs +++ b/TUnit.Engine/Exceptions/TUnitFailedException.cs @@ -1,4 +1,5 @@ -using TUnit.Core.Exceptions; +using System.Diagnostics.CodeAnalysis; +using TUnit.Core.Exceptions; using TUnit.Core.Helpers; using TUnit.Engine.CommandLineProviders; @@ -6,8 +7,11 @@ namespace TUnit.Engine.Exceptions; public abstract class TUnitFailedException : TUnitException { + public Exception? WrappedException { get; } + protected TUnitFailedException(Exception exception) : base($"{exception.GetType().Name}: {exception.Message}", exception.InnerException) { + WrappedException = exception; StackTrace = FilterStackTrace(exception.StackTrace); } @@ -18,6 +22,16 @@ protected TUnitFailedException(string? message, Exception? innerException) : bas public override string StackTrace { get; } + [return: NotNullIfNotNull(nameof(exception))] + public static Exception? Unwrap(Exception? exception) + { + while (exception is TUnitFailedException { WrappedException: { } wrapped }) + { + exception = wrapped; + } + return exception; + } + // The hint only mentions --detailed-stacktrace because filtering is bypassed // entirely when --log-level Debug/Trace is set (see TUnitMessageBus.SimplifyStacktrace), // so users on debug logging will never see this message. diff --git a/TUnit.Engine/Reporters/GitHubReporter.cs b/TUnit.Engine/Reporters/GitHubReporter.cs index 0ed6afd379..da742484a0 100644 --- a/TUnit.Engine/Reporters/GitHubReporter.cs +++ b/TUnit.Engine/Reporters/GitHubReporter.cs @@ -9,6 +9,7 @@ using Microsoft.Testing.Platform.Extensions.TestHost; using TUnit.Engine.Configuration; using TUnit.Engine.Constants; +using TUnit.Engine.Exceptions; using TUnit.Engine.Framework; using TUnit.Engine.Helpers; @@ -528,9 +529,9 @@ or TimeoutTestNodeStateProperty return stateProperty switch { FailedTestNodeStateProperty failedTestNodeStateProperty => - GetTruncatedExceptionMessage(failedTestNodeStateProperty.Exception) ?? "Test failed", + GetTruncatedExceptionMessage(TUnitFailedException.Unwrap(failedTestNodeStateProperty.Exception)) ?? "Test failed", ErrorTestNodeStateProperty errorTestNodeStateProperty => - GetTruncatedExceptionMessage(errorTestNodeStateProperty.Exception) ?? "Test failed", + GetTruncatedExceptionMessage(TUnitFailedException.Unwrap(errorTestNodeStateProperty.Exception)) ?? "Test failed", TimeoutTestNodeStateProperty timeoutTestNodeStateProperty => timeoutTestNodeStateProperty.Explanation, #pragma warning disable CS0618 // CancelledTestNodeStateProperty is obsolete CancelledTestNodeStateProperty => "Test was cancelled", @@ -637,8 +638,8 @@ internal void SetReporterStyle(GitHubReporterStyle style) private static string GetExceptionTypeName(IProperty? stateProperty) => stateProperty switch { - FailedTestNodeStateProperty f => f.Exception?.GetType().Name ?? "Unknown", - ErrorTestNodeStateProperty e => e.Exception?.GetType().Name ?? "Unknown", + FailedTestNodeStateProperty f => TUnitFailedException.Unwrap(f.Exception)?.GetType().Name ?? "Unknown", + ErrorTestNodeStateProperty e => TUnitFailedException.Unwrap(e.Exception)?.GetType().Name ?? "Unknown", TimeoutTestNodeStateProperty => "Timeout", _ => "Unknown" }; diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 98bbc2eee4..5c3213503f 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -13,6 +13,7 @@ using TUnit.Core; using TUnit.Engine.Configuration; using TUnit.Engine.Constants; +using TUnit.Engine.Exceptions; using TUnit.Engine.Framework; #pragma warning disable TPEXP @@ -593,6 +594,8 @@ private static (string Status, ReportExceptionData? Exception, string? SkipReaso return null; } + ex = TUnitFailedException.Unwrap(ex); + return new ReportExceptionData { Type = ex.GetType().FullName ?? ex.GetType().Name, diff --git a/TUnit.Engine/Xml/JUnitXmlWriter.cs b/TUnit.Engine/Xml/JUnitXmlWriter.cs index bb7b27c015..978fe65a70 100644 --- a/TUnit.Engine/Xml/JUnitXmlWriter.cs +++ b/TUnit.Engine/Xml/JUnitXmlWriter.cs @@ -5,6 +5,7 @@ using System.Text; using System.Xml; using Microsoft.Testing.Platform.Extensions.Messages; +using TUnit.Engine.Exceptions; namespace TUnit.Engine.Xml; @@ -245,7 +246,7 @@ private static void WriteFailure(XmlWriter writer, FailedTestNodeStateProperty f { writer.WriteStartElement("failure"); - var exception = failed.Exception; + var exception = TUnitFailedException.Unwrap(failed.Exception); if (exception != null) { writer.WriteAttributeString("message", SanitizeForXml(exception.Message)); @@ -267,7 +268,7 @@ private static void WriteError(XmlWriter writer, ErrorTestNodeStateProperty erro { writer.WriteStartElement("error"); - var exception = error.Exception; + var exception = TUnitFailedException.Unwrap(error.Exception); if (exception != null) { writer.WriteAttributeString("message", SanitizeForXml(exception.Message));