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()