Skip to content

Commit 2a5d100

Browse files
authored
feat: intelligent test failure diagnosis and categorization (#4953)
* feat: add intelligent test failure categorization Automatically categorize test failures to help users quickly understand what went wrong. Categories include: Assertion, Timeout, NullReference, Setup (Before hooks), Teardown (After hooks), Infrastructure (IO/network), and Unknown. The category label is prefixed in the failure explanation visible in test reports. * fix: use categorizer result consistently and unwrap AggregateException at top of GetFailureStateProperty The method was computing a category via FailureCategorizer but then ignoring it for routing, using separate type-checking logic against the original (potentially wrapped) exception. This caused two bugs: 1. AggregateException wrapping e.g. TaskCanceledException would be correctly categorized as Timeout by the categorizer, but the method's own `e is TaskCanceledException` check would fail since `e` was still the AggregateException. 2. The assertion check used a different string comparison (InvariantCulture) than the categorizer (Ordinal) and only checked for "Assertion" rather than also checking "Assert". Now AggregateException is unwrapped once at the top, the unwrapped exception is passed to the categorizer, and the FailureCategory enum drives all routing and is used for constructing state properties.
1 parent 55f8a58 commit 2a5d100

3 files changed

Lines changed: 149 additions & 6 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
namespace TUnit.Engine.Enums;
2+
3+
/// <summary>
4+
/// Categorizes test failures to help users quickly understand what went wrong.
5+
/// </summary>
6+
internal enum FailureCategory
7+
{
8+
/// <summary>
9+
/// TUnit assertion failure (AssertionException).
10+
/// </summary>
11+
Assertion,
12+
13+
/// <summary>
14+
/// Timeout or cancellation failure (OperationCanceledException, TaskCanceledException, TimeoutException).
15+
/// </summary>
16+
Timeout,
17+
18+
/// <summary>
19+
/// NullReferenceException in user code.
20+
/// </summary>
21+
NullReference,
22+
23+
/// <summary>
24+
/// Failure in a Before/BeforeEvery hook.
25+
/// </summary>
26+
Setup,
27+
28+
/// <summary>
29+
/// Failure in an After/AfterEvery hook.
30+
/// </summary>
31+
Teardown,
32+
33+
/// <summary>
34+
/// File, network, or other I/O exception.
35+
/// </summary>
36+
Infrastructure,
37+
38+
/// <summary>
39+
/// Unrecognized failure type.
40+
/// </summary>
41+
Unknown
42+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using TUnit.Core.Exceptions;
2+
using TUnit.Engine.Enums;
3+
4+
namespace TUnit.Engine.Services;
5+
6+
/// <summary>
7+
/// Examines exceptions from test failures and categorizes them to help users
8+
/// quickly understand what went wrong.
9+
/// </summary>
10+
internal static class FailureCategorizer
11+
{
12+
/// <summary>
13+
/// Categorizes the given exception into a <see cref="FailureCategory"/>.
14+
/// Unwraps <see cref="AggregateException"/> to inspect the first inner exception.
15+
/// </summary>
16+
public static FailureCategory Categorize(Exception exception)
17+
{
18+
// Unwrap AggregateException to get the real cause
19+
var ex = exception is AggregateException { InnerExceptions.Count: > 0 } agg
20+
? agg.InnerExceptions[0]
21+
: exception;
22+
23+
// Setup hooks (Before*)
24+
if (ex is BeforeTestException
25+
or BeforeClassException
26+
or BeforeAssemblyException
27+
or BeforeTestSessionException
28+
or BeforeTestDiscoveryException)
29+
{
30+
return FailureCategory.Setup;
31+
}
32+
33+
// Teardown hooks (After*)
34+
if (ex is AfterTestException
35+
or AfterClassException
36+
or AfterAssemblyException
37+
or AfterTestSessionException
38+
or AfterTestDiscoveryException)
39+
{
40+
return FailureCategory.Teardown;
41+
}
42+
43+
// Assertion failures - check by type name to support third-party assertion libraries
44+
if (ex.GetType().Name.Contains("Assertion", StringComparison.Ordinal)
45+
|| ex.GetType().Name.Contains("Assert", StringComparison.Ordinal))
46+
{
47+
return FailureCategory.Assertion;
48+
}
49+
50+
// Timeout / cancellation
51+
if (ex is OperationCanceledException
52+
or TaskCanceledException
53+
or System.TimeoutException
54+
or TUnit.Core.Exceptions.TimeoutException)
55+
{
56+
return FailureCategory.Timeout;
57+
}
58+
59+
// NullReference
60+
if (ex is NullReferenceException)
61+
{
62+
return FailureCategory.NullReference;
63+
}
64+
65+
// Infrastructure (I/O, network, file system)
66+
if (ex is IOException
67+
or System.Net.Sockets.SocketException
68+
or System.Net.Http.HttpRequestException
69+
or UnauthorizedAccessException)
70+
{
71+
return FailureCategory.Infrastructure;
72+
}
73+
74+
return FailureCategory.Unknown;
75+
}
76+
77+
/// <summary>
78+
/// Returns a short human-readable label for the category,
79+
/// suitable for prefixing failure messages in reports.
80+
/// </summary>
81+
public static string GetLabel(FailureCategory category) => category switch
82+
{
83+
FailureCategory.Assertion => "Assertion Failure",
84+
FailureCategory.Timeout => "Timeout",
85+
FailureCategory.NullReference => "Null Reference",
86+
FailureCategory.Setup => "Setup Failure",
87+
FailureCategory.Teardown => "Teardown Failure",
88+
FailureCategory.Infrastructure => "Infrastructure Failure",
89+
FailureCategory.Unknown => "Test Failure",
90+
_ => "Test Failure"
91+
};
92+
}

TUnit.Engine/TUnitMessageBus.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Microsoft.Testing.Platform.TestHost;
77
using TUnit.Core;
88
using TUnit.Engine.CommandLineProviders;
9+
using TUnit.Engine.Enums;
910
using TUnit.Engine.Exceptions;
1011
using TUnit.Engine.Extensions;
1112
using TUnit.Engine.Services;
@@ -143,19 +144,27 @@ public ValueTask PublishOutputUpdate(TestNode testNode)
143144

144145
private static TestNodeStateProperty GetFailureStateProperty(TestContext testContext, Exception e, TimeSpan duration)
145146
{
146-
if (testContext.Metadata.TestDetails.Timeout != null
147-
&& e is TaskCanceledException or OperationCanceledException or TimeoutException
147+
// Unwrap AggregateException once so all downstream logic sees the real cause
148+
var unwrapped = e is AggregateException { InnerExceptions.Count: > 0 } agg
149+
? agg.InnerExceptions[0]
150+
: e;
151+
152+
var category = FailureCategorizer.Categorize(unwrapped);
153+
var categoryLabel = FailureCategorizer.GetLabel(category);
154+
155+
if (category == FailureCategory.Timeout
156+
&& testContext.Metadata.TestDetails.Timeout != null
148157
&& duration >= testContext.Metadata.TestDetails.Timeout.Value)
149158
{
150-
return new TimeoutTestNodeStateProperty($"Test timed out after {testContext.Metadata.TestDetails.Timeout.Value.TotalMilliseconds}ms");
159+
return new TimeoutTestNodeStateProperty($"[{categoryLabel}] Test timed out after {testContext.Metadata.TestDetails.Timeout.Value.TotalMilliseconds}ms");
151160
}
152161

153-
if (e.GetType().Name.Contains("Assertion", StringComparison.InvariantCulture))
162+
if (category == FailureCategory.Assertion)
154163
{
155-
return new FailedTestNodeStateProperty(e);
164+
return new FailedTestNodeStateProperty(unwrapped, $"[{categoryLabel}] {unwrapped.Message}");
156165
}
157166

158-
return new ErrorTestNodeStateProperty(e);
167+
return new ErrorTestNodeStateProperty(unwrapped, $"[{categoryLabel}] {unwrapped.Message}");
159168
}
160169

161170
public Task<bool> IsEnabledAsync()

0 commit comments

Comments
 (0)