diff --git a/TUnit.Engine/Building/Collectors/AotTestDataCollector.cs b/TUnit.Engine/Building/Collectors/AotTestDataCollector.cs index b673db2071..ddae81e8b6 100644 --- a/TUnit.Engine/Building/Collectors/AotTestDataCollector.cs +++ b/TUnit.Engine/Building/Collectors/AotTestDataCollector.cs @@ -148,11 +148,26 @@ private Task CreateMetadataFromDynamicDiscoveryResult(DynamicDisco GenericTypeInfo = null, GenericMethodInfo = null, GenericMethodTypeArguments = null, - AttributeFactory = () => result.Attributes.ToArray(), + AttributeFactory = () => GetDynamicTestAttributes(result), PropertyInjections = PropertySourceRegistry.DiscoverInjectableProperties(result.TestClassType) }); } + private static Attribute[] GetDynamicTestAttributes(DynamicDiscoveryResult result) + { + // Merge explicitly provided attributes with inherited class/assembly attributes + // Order matches GetAllAttributes: method-level first (explicit), then class, then assembly + var attributes = new List(result.Attributes); + + if (result.TestClassType != null) + { + attributes.AddRange(result.TestClassType.GetCustomAttributes().OfType()); + attributes.AddRange(result.TestClassType.Assembly.GetCustomAttributes().OfType()); + } + + return attributes.ToArray(); + } + [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break functionality when AOT compiling.")] [UnconditionalSuppressMessage("Trimming", "IL2055:Either the type on which the MakeGenericType is called can\'t be statically determined, or the type parameters to be used for generic arguments can\'t be statically determined.")] private static Func? CreateAotDynamicInstanceFactory([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type testClass, object?[]? predefinedClassArgs) diff --git a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs index 6d2622ba62..2bdf3a6635 100644 --- a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs +++ b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs @@ -1923,13 +1923,28 @@ private Task CreateMetadataFromDynamicDiscoveryResult(DynamicDisco GenericTypeInfo = ReflectionGenericTypeResolver.ExtractGenericTypeInfo(result.TestClassType), GenericMethodInfo = ReflectionGenericTypeResolver.ExtractGenericMethodInfo(methodInfo), GenericMethodTypeArguments = methodInfo.IsGenericMethodDefinition ? null : methodInfo.GetGenericArguments(), - AttributeFactory = () => result.Attributes.ToArray(), + AttributeFactory = () => GetDynamicTestAttributes(result), PropertyInjections = PropertySourceRegistry.DiscoverInjectableProperties(result.TestClassType) }; return Task.FromResult(metadata); } + private static Attribute[] GetDynamicTestAttributes(DynamicDiscoveryResult result) + { + // Merge explicitly provided attributes with inherited class/assembly attributes + // Order matches GetAllAttributes: method-level first (explicit), then class, then assembly + var attributes = new List(result.Attributes); + + if (result.TestClassType != null) + { + attributes.AddRange(result.TestClassType.GetCustomAttributes().OfType()); + attributes.AddRange(result.TestClassType.Assembly.GetCustomAttributes().OfType()); + } + + return attributes.ToArray(); + } + private static Func CreateDynamicInstanceFactory(Type testClass, object?[]? predefinedClassArgs) { // For dynamic tests, we always use the predefined args (or empty array if null) diff --git a/TUnit.TestProject/DynamicTests/DynamicTestInheritedAttributesTests.cs b/TUnit.TestProject/DynamicTests/DynamicTestInheritedAttributesTests.cs new file mode 100644 index 0000000000..4695245ed6 --- /dev/null +++ b/TUnit.TestProject/DynamicTests/DynamicTestInheritedAttributesTests.cs @@ -0,0 +1,68 @@ +using System.Collections.Concurrent; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.DynamicTests; + +[NotInParallel] +public abstract class NotInParallelBaseClass; + +[EngineTest(ExpectedResult.Pass)] +[Retry(3)] +public class DynamicTestInheritedAttributesTests : NotInParallelBaseClass +{ + private static readonly ConcurrentBag TestDateTimeRanges = []; + + public async Task TestMethod() + { + await Task.Delay(300); + TestDateTimeRanges.Add(new DateTimeRange( + TestContext.Current!.Execution.TestStart!.Value.DateTime, + DateTime.UtcNow)); + } + + [After(Class)] + public static async Task VerifyNoOverlaps() + { + // Wait a bit to ensure all test times are recorded + await Task.Delay(100); + + foreach (var testDateTimeRange in TestDateTimeRanges) + { + await Assert.That(TestDateTimeRanges + .Except([testDateTimeRange]) + .Any(x => x.Overlap(testDateTimeRange))) + .IsFalse() + .Because("Dynamic tests should inherit [NotInParallel] from base class and not run in parallel"); + } + } + +#pragma warning disable TUnitWIP0001 + [DynamicTestBuilder] +#pragma warning restore TUnitWIP0001 + public static void BuildTests(DynamicTestBuilderContext context) + { + // Create multiple dynamic tests - they should NOT run in parallel + // because the base class has [NotInParallel] + for (var i = 0; i < 3; i++) + { + context.AddTest(new DynamicTest + { + TestMethod = @class => @class.TestMethod(), + TestMethodArguments = [], + DisplayName = $"DynamicTest_InheritedNotInParallel_{i}", + Attributes = [] + }); + } + } + + private class DateTimeRange(DateTime start, DateTime end) + { + public DateTime Start { get; } = start; + public DateTime End { get; } = end; + + public bool Overlap(DateTimeRange other) + { + return Start < other.End && other.Start < End; + } + } +}