diff --git a/TUnit.Engine/Enums/FailureCategory.cs b/TUnit.Engine/Enums/FailureCategory.cs new file mode 100644 index 0000000000..6b84ad7150 --- /dev/null +++ b/TUnit.Engine/Enums/FailureCategory.cs @@ -0,0 +1,42 @@ +namespace TUnit.Engine.Enums; + +/// +/// Categorizes test failures to help users quickly understand what went wrong. +/// +internal enum FailureCategory +{ + /// + /// TUnit assertion failure (AssertionException). + /// + Assertion, + + /// + /// Timeout or cancellation failure (OperationCanceledException, TaskCanceledException, TimeoutException). + /// + Timeout, + + /// + /// NullReferenceException in user code. + /// + NullReference, + + /// + /// Failure in a Before/BeforeEvery hook. + /// + Setup, + + /// + /// Failure in an After/AfterEvery hook. + /// + Teardown, + + /// + /// File, network, or other I/O exception. + /// + Infrastructure, + + /// + /// Unrecognized failure type. + /// + Unknown +} diff --git a/TUnit.Engine/Services/FailureCategorizer.cs b/TUnit.Engine/Services/FailureCategorizer.cs new file mode 100644 index 0000000000..5b088a257e --- /dev/null +++ b/TUnit.Engine/Services/FailureCategorizer.cs @@ -0,0 +1,92 @@ +using TUnit.Core.Exceptions; +using TUnit.Engine.Enums; + +namespace TUnit.Engine.Services; + +/// +/// Examines exceptions from test failures and categorizes them to help users +/// quickly understand what went wrong. +/// +internal static class FailureCategorizer +{ + /// + /// Categorizes the given exception into a . + /// Unwraps to inspect the first inner exception. + /// + public static FailureCategory Categorize(Exception exception) + { + // Unwrap AggregateException to get the real cause + var ex = exception is AggregateException { InnerExceptions.Count: > 0 } agg + ? agg.InnerExceptions[0] + : exception; + + // Setup hooks (Before*) + if (ex is BeforeTestException + or BeforeClassException + or BeforeAssemblyException + or BeforeTestSessionException + or BeforeTestDiscoveryException) + { + return FailureCategory.Setup; + } + + // Teardown hooks (After*) + if (ex is AfterTestException + or AfterClassException + or AfterAssemblyException + or AfterTestSessionException + or AfterTestDiscoveryException) + { + return FailureCategory.Teardown; + } + + // Assertion failures - check by type name to support third-party assertion libraries + if (ex.GetType().Name.Contains("Assertion", StringComparison.Ordinal) + || ex.GetType().Name.Contains("Assert", StringComparison.Ordinal)) + { + return FailureCategory.Assertion; + } + + // Timeout / cancellation + if (ex is OperationCanceledException + or TaskCanceledException + or System.TimeoutException + or TUnit.Core.Exceptions.TimeoutException) + { + return FailureCategory.Timeout; + } + + // NullReference + if (ex is NullReferenceException) + { + return FailureCategory.NullReference; + } + + // Infrastructure (I/O, network, file system) + if (ex is IOException + or System.Net.Sockets.SocketException + or System.Net.Http.HttpRequestException + or UnauthorizedAccessException) + { + return FailureCategory.Infrastructure; + } + + return FailureCategory.Unknown; + } + + /// + /// Returns a short human-readable label for the category, + /// suitable for prefixing failure messages in reports. + /// + public static string GetLabel(FailureCategory category) => category switch + { + FailureCategory.Assertion => "Assertion Failure", + FailureCategory.Timeout => "Timeout", + FailureCategory.NullReference => "Null Reference", + FailureCategory.Setup => "Setup Failure", + FailureCategory.Teardown => "Teardown Failure", + FailureCategory.Infrastructure => "Infrastructure Failure", + FailureCategory.Unknown => "Test Failure", + _ => "Test Failure" + }; +} diff --git a/TUnit.Engine/TUnitMessageBus.cs b/TUnit.Engine/TUnitMessageBus.cs index e85505dd03..24b9bb2fa4 100644 --- a/TUnit.Engine/TUnitMessageBus.cs +++ b/TUnit.Engine/TUnitMessageBus.cs @@ -6,6 +6,7 @@ using Microsoft.Testing.Platform.TestHost; using TUnit.Core; using TUnit.Engine.CommandLineProviders; +using TUnit.Engine.Enums; using TUnit.Engine.Exceptions; using TUnit.Engine.Extensions; using TUnit.Engine.Services; @@ -143,19 +144,27 @@ public ValueTask PublishOutputUpdate(TestNode testNode) private static TestNodeStateProperty GetFailureStateProperty(TestContext testContext, Exception e, TimeSpan duration) { - if (testContext.Metadata.TestDetails.Timeout != null - && e is TaskCanceledException or OperationCanceledException or TimeoutException + // Unwrap AggregateException once so all downstream logic sees the real cause + var unwrapped = e is AggregateException { InnerExceptions.Count: > 0 } agg + ? agg.InnerExceptions[0] + : e; + + var category = FailureCategorizer.Categorize(unwrapped); + var categoryLabel = FailureCategorizer.GetLabel(category); + + if (category == FailureCategory.Timeout + && testContext.Metadata.TestDetails.Timeout != null && duration >= testContext.Metadata.TestDetails.Timeout.Value) { - return new TimeoutTestNodeStateProperty($"Test timed out after {testContext.Metadata.TestDetails.Timeout.Value.TotalMilliseconds}ms"); + return new TimeoutTestNodeStateProperty($"[{categoryLabel}] Test timed out after {testContext.Metadata.TestDetails.Timeout.Value.TotalMilliseconds}ms"); } - if (e.GetType().Name.Contains("Assertion", StringComparison.InvariantCulture)) + if (category == FailureCategory.Assertion) { - return new FailedTestNodeStateProperty(e); + return new FailedTestNodeStateProperty(unwrapped, $"[{categoryLabel}] {unwrapped.Message}"); } - return new ErrorTestNodeStateProperty(e); + return new ErrorTestNodeStateProperty(unwrapped, $"[{categoryLabel}] {unwrapped.Message}"); } public Task IsEnabledAsync()