diff --git a/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs b/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs
index 5c0214dde2..48e70d9a27 100644
--- a/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs
+++ b/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs
@@ -540,7 +540,7 @@ private static string GenerateCustomDataProvider(AttributeData attr)
///
- /// Generates all test-related attributes for the TestMetadata.Attributes field.
+ /// Generates all test-related attributes for the TestMetadata.AttributesByType field as a dictionary.
///
public static string GenerateTestAttributes(IMethodSymbol methodSymbol)
{
@@ -552,24 +552,42 @@ public static string GenerateTestAttributes(IMethodSymbol methodSymbol)
if (allAttributes.Count == 0)
{
- return "System.Array.Empty()";
+ return "new System.Collections.Generic.Dictionary>().AsReadOnly()";
}
- // Generate as a single line array to avoid CS8802 parser issues
+ // Group attributes by type
+ var attributesByType = allAttributes
+ .GroupBy(attr => attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? "System.Attribute")
+ .ToList();
+
using var writer = new CodeWriter("", includeHeader: false);
- // Generate inline array to avoid parser issues
- using (writer.BeginArrayInitializer("new System.Attribute[]", terminator: ""))
+ // Generate dictionary initializer
+ writer.Append("new System.Collections.Generic.Dictionary>()");
+ writer.AppendLine();
+ writer.AppendLine("{");
+ writer.Indent();
+
+ foreach (var group in attributesByType)
{
+ var typeString = group.Key;
+ var attrs = group.ToList();
+
+ writer.Append($"[typeof({typeString})] = new System.Attribute[] {{ ");
+
var attributeStrings = new List();
- foreach (var attr in allAttributes)
+ foreach (var attr in attrs)
{
- // Use unified approach for all attributes
attributeStrings.Add(GenerateAttributeInstantiation(attr));
}
+
writer.Append(string.Join(", ", attributeStrings));
+ writer.AppendLine(" },");
}
+ writer.Unindent();
+ writer.Append("}.AsReadOnly()");
+
return writer.ToString().Trim();
}
diff --git a/TUnit.Core/Helpers/AttributeDictionaryHelper.cs b/TUnit.Core/Helpers/AttributeDictionaryHelper.cs
new file mode 100644
index 0000000000..c0824199f9
--- /dev/null
+++ b/TUnit.Core/Helpers/AttributeDictionaryHelper.cs
@@ -0,0 +1,46 @@
+using System.Collections.ObjectModel;
+
+namespace TUnit.Core.Helpers;
+
+///
+/// Helper methods for working with attribute dictionaries.
+///
+public static class AttributeDictionaryHelper
+{
+ private static readonly IReadOnlyDictionary> EmptyDictionary =
+ new ReadOnlyDictionary>(new Dictionary>());
+
+ ///
+ /// Converts an array of attributes to a read-only dictionary grouped by type.
+ ///
+ public static IReadOnlyDictionary> ToAttributeDictionary(this Attribute[] attributes)
+ {
+ if (attributes.Length == 0)
+ {
+ return EmptyDictionary;
+ }
+
+ var result = new Dictionary>();
+
+ foreach (var attr in attributes)
+ {
+ var type = attr.GetType();
+ if (!result.TryGetValue(type, out var list))
+ {
+ var newList = new List { attr };
+ result[type] = newList;
+ }
+ else
+ {
+ ((List)list).Add(attr);
+ }
+ }
+
+ return new ReadOnlyDictionary>(result);
+ }
+
+ ///
+ /// Gets an empty read-only attribute dictionary.
+ ///
+ public static IReadOnlyDictionary> Empty => EmptyDictionary;
+}
diff --git a/TUnit.Core/TestDetails.cs b/TUnit.Core/TestDetails.cs
index 801ba823ef..e39b16704e 100644
--- a/TUnit.Core/TestDetails.cs
+++ b/TUnit.Core/TestDetails.cs
@@ -29,15 +29,56 @@ public class TestDetails
public Dictionary> CustomProperties { get; } = new();
public Type[]? TestClassParameterTypes { get; set; }
- public required IReadOnlyList Attributes { get; init; }
+ public required IReadOnlyDictionary> AttributesByType { get; init; }
+
+ private readonly Lazy> _cachedAllAttributes;
+
+ public TestDetails()
+ {
+ _cachedAllAttributes = new Lazy>(() =>
+ {
+ var allAttrs = new List();
+ foreach (var attrList in AttributesByType?.Values ?? [])
+ {
+ allAttrs.AddRange(attrList);
+ }
+ return allAttrs;
+ });
+ }
+
+ ///
+ /// Checks if the test has an attribute of the specified type.
+ ///
+ /// The attribute type to check for.
+ /// True if the test has at least one attribute of the specified type; otherwise, false.
+ public bool HasAttribute() where T : Attribute
+ => AttributesByType.ContainsKey(typeof(T));
+
+ ///
+ /// Gets all attributes of the specified type.
+ ///
+ /// The attribute type to retrieve.
+ /// An enumerable of attributes of the specified type.
+ public IEnumerable GetAttributes() where T : Attribute
+ => AttributesByType.TryGetValue(typeof(T), out var attrs)
+ ? attrs.OfType()
+ : Enumerable.Empty();
+
+ ///
+ /// Gets all attributes as a flattened collection.
+ /// Cached after first access for performance.
+ ///
+ /// All attributes associated with this test.
+ public IReadOnlyList GetAllAttributes() => _cachedAllAttributes.Value;
+
public object?[] ClassMetadataArguments => TestClassArguments;
-
+
///
/// Resolved generic type arguments for the test method.
/// Will be Type.EmptyTypes if the method is not generic.
///
public Type[] MethodGenericArguments { get; set; } = Type.EmptyTypes;
-
+
///
/// Resolved generic type arguments for the test class.
/// Will be Type.EmptyTypes if the class is not generic.
diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs
index 78fa82ca94..fa69e3c536 100644
--- a/TUnit.Engine/Building/TestBuilder.cs
+++ b/TUnit.Engine/Building/TestBuilder.cs
@@ -876,7 +876,7 @@ private async ValueTask CreateTestContextAsync(string testId, TestM
TestLineNumber = metadata.LineNumber,
ReturnType = metadata.MethodMetadata.ReturnType ?? typeof(void),
MethodMetadata = metadata.MethodMetadata,
- Attributes = attributes,
+ AttributesByType = attributes.ToAttributeDictionary(),
MethodGenericArguments = testData.ResolvedMethodGenericArguments,
ClassGenericArguments = testData.ResolvedClassGenericArguments,
Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout (can be overridden by TimeoutAttribute)
@@ -973,7 +973,7 @@ private async Task CreateFailedTestDetails(TestMetadata metadata, s
TestLineNumber = metadata.LineNumber,
ReturnType = typeof(Task),
MethodMetadata = metadata.MethodMetadata,
- Attributes = await InitializeAttributesAsync(metadata.AttributeFactory.Invoke()),
+ AttributesByType = (await InitializeAttributesAsync(metadata.AttributeFactory.Invoke())).ToAttributeDictionary(),
Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout (can be overridden by TimeoutAttribute)
};
}
diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs
index ddeb311691..35bc732c91 100644
--- a/TUnit.Engine/Building/TestBuilderPipeline.cs
+++ b/TUnit.Engine/Building/TestBuilderPipeline.cs
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using EnumerableAsyncProcessor.Extensions;
using TUnit.Core;
+using TUnit.Core.Helpers;
using TUnit.Core.Interfaces;
using TUnit.Core.Services;
using TUnit.Engine.Building.Interfaces;
@@ -173,7 +174,7 @@ private async Task GenerateDynamicTests(TestMetadata m
TestLineNumber = metadata.LineNumber,
ReturnType = typeof(Task),
MethodMetadata = metadata.MethodMetadata,
- Attributes = attributes,
+ AttributesByType = attributes.ToAttributeDictionary(),
Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout (can be overridden by TimeoutAttribute)
// Don't set RetryLimit here - let discovery event receivers set it
};
@@ -293,7 +294,7 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad
TestLineNumber = resolvedMetadata.LineNumber,
ReturnType = typeof(Task),
MethodMetadata = resolvedMetadata.MethodMetadata,
- Attributes = attributes,
+ AttributesByType = attributes.ToAttributeDictionary(),
Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout (can be overridden by TimeoutAttribute)
// Don't set Timeout and RetryLimit here - let discovery event receivers set them
};
@@ -367,7 +368,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada
TestLineNumber = metadata.LineNumber,
ReturnType = typeof(Task),
MethodMetadata = metadata.MethodMetadata,
- Attributes = [],
+ AttributesByType = AttributeDictionaryHelper.Empty,
Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout
};
@@ -419,7 +420,7 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet
TestLineNumber = metadata.LineNumber,
ReturnType = typeof(Task),
MethodMetadata = metadata.MethodMetadata,
- Attributes = [],
+ AttributesByType = AttributeDictionaryHelper.Empty,
Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout
};
diff --git a/TUnit.Engine/Discovery/ReflectionTestMetadata.cs b/TUnit.Engine/Discovery/ReflectionTestMetadata.cs
index 9354c9525f..4866d23e2e 100644
--- a/TUnit.Engine/Discovery/ReflectionTestMetadata.cs
+++ b/TUnit.Engine/Discovery/ReflectionTestMetadata.cs
@@ -52,7 +52,7 @@ async Task