Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions TUnit.Engine/Enums/FailureCategory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace TUnit.Engine.Enums;

/// <summary>
/// Categorizes test failures to help users quickly understand what went wrong.
/// </summary>
internal enum FailureCategory
{
/// <summary>
/// TUnit assertion failure (AssertionException).
/// </summary>
Assertion,

/// <summary>
/// Timeout or cancellation failure (OperationCanceledException, TaskCanceledException, TimeoutException).
/// </summary>
Timeout,

/// <summary>
/// NullReferenceException in user code.
/// </summary>
NullReference,

/// <summary>
/// Failure in a Before/BeforeEvery hook.
/// </summary>
Setup,

/// <summary>
/// Failure in an After/AfterEvery hook.
/// </summary>
Teardown,

/// <summary>
/// File, network, or other I/O exception.
/// </summary>
Infrastructure,

/// <summary>
/// Unrecognized failure type.
/// </summary>
Unknown
}
92 changes: 92 additions & 0 deletions TUnit.Engine/Services/FailureCategorizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using TUnit.Core.Exceptions;
using TUnit.Engine.Enums;

namespace TUnit.Engine.Services;

/// <summary>
/// Examines exceptions from test failures and categorizes them to help users
/// quickly understand what went wrong.
/// </summary>
internal static class FailureCategorizer
{
/// <summary>
/// Categorizes the given exception into a <see cref="FailureCategory"/>.
/// Unwraps <see cref="AggregateException"/> to inspect the first inner exception.
/// </summary>
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;
}

/// <summary>
/// Returns a short human-readable label for the category,
/// suitable for prefixing failure messages in reports.
/// </summary>
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"
};
}
21 changes: 15 additions & 6 deletions TUnit.Engine/TUnitMessageBus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<bool> IsEnabledAsync()
Expand Down
Loading