diff --git a/TUnit.Core/Attributes/Executors/CultureAttribute.cs b/TUnit.Core/Attributes/Executors/CultureAttribute.cs index be5f45c35e..a5bf31748b 100644 --- a/TUnit.Core/Attributes/Executors/CultureAttribute.cs +++ b/TUnit.Core/Attributes/Executors/CultureAttribute.cs @@ -4,15 +4,20 @@ namespace TUnit.Core.Executors; [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] -public class CultureAttribute(CultureInfo cultureInfo) : TUnitAttribute, ITestRegisteredEventReceiver +public class CultureAttribute(CultureInfo cultureInfo) : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute { public CultureAttribute(string cultureName) : this(CultureInfo.GetCultureInfo(cultureName)) { } + /// public int Order => 0; -public ValueTask OnTestRegistered(TestRegisteredContext context) + /// + public Type ScopeType => typeof(ITestExecutor); + + /// + public ValueTask OnTestRegistered(TestRegisteredContext context) { context.SetTestExecutor(new CultureExecutor(cultureInfo)); return default(ValueTask); diff --git a/TUnit.Core/Attributes/Executors/STAThreadExecutorAttribute.cs b/TUnit.Core/Attributes/Executors/STAThreadExecutorAttribute.cs index a5a9f3a3ed..ba8e48fba1 100644 --- a/TUnit.Core/Attributes/Executors/STAThreadExecutorAttribute.cs +++ b/TUnit.Core/Attributes/Executors/STAThreadExecutorAttribute.cs @@ -5,11 +5,16 @@ namespace TUnit.Core.Executors; [SupportedOSPlatform("windows")] [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] -public class STAThreadExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver +public class STAThreadExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute { + /// public int Order => 0; -public ValueTask OnTestRegistered(TestRegisteredContext context) + /// + public Type ScopeType => typeof(ITestExecutor); + + /// + public ValueTask OnTestRegistered(TestRegisteredContext context) { var executor = new STAThreadExecutor(); context.SetTestExecutor(executor); diff --git a/TUnit.Core/Attributes/Executors/TestExecutorAttribute.cs b/TUnit.Core/Attributes/Executors/TestExecutorAttribute.cs index 368c79bce2..f2d7bf9247 100644 --- a/TUnit.Core/Attributes/Executors/TestExecutorAttribute.cs +++ b/TUnit.Core/Attributes/Executors/TestExecutorAttribute.cs @@ -4,11 +4,16 @@ namespace TUnit.Core.Executors; [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] -public sealed class TestExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver where T : ITestExecutor, new() +public sealed class TestExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute where T : ITestExecutor, new() { + /// public int Order => 0; -public ValueTask OnTestRegistered(TestRegisteredContext context) + /// + public Type ScopeType => typeof(ITestExecutor); + + /// + public ValueTask OnTestRegistered(TestRegisteredContext context) { context.SetTestExecutor(new T()); return default(ValueTask); @@ -16,11 +21,16 @@ public ValueTask OnTestRegistered(TestRegisteredContext context) } [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] -public sealed class TestExecutorAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) : TUnitAttribute, ITestRegisteredEventReceiver +public sealed class TestExecutorAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute { + /// public int Order => 0; -public ValueTask OnTestRegistered(TestRegisteredContext context) + /// + public Type ScopeType => typeof(ITestExecutor); + + /// + public ValueTask OnTestRegistered(TestRegisteredContext context) { context.SetTestExecutor((ITestExecutor) Activator.CreateInstance(type)!); return default(ValueTask); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index a37b740d48..d14c5dd60b 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1913,11 +1913,12 @@ namespace .Exceptions namespace .Executors { [(.Assembly | .Class | .Method)] - public class CultureAttribute : .TUnitAttribute, ., . + public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public CultureAttribute(.CultureInfo cultureInfo) { } public CultureAttribute(string cultureName) { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } public class HookExecutorAttribute : .TUnitAttribute @@ -1938,25 +1939,28 @@ namespace .Executors } [(.Assembly | .Class | .Method)] [.("windows")] - public class STAThreadExecutorAttribute : .TUnitAttribute, ., . + public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public STAThreadExecutorAttribute() { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } [(.Assembly | .Class | .Method)] - public sealed class TestExecutorAttribute : .TUnitAttribute, ., . + public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public TestExecutorAttribute([.(..PublicConstructors)] type) { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } [(.Assembly | .Class | .Method)] - public sealed class TestExecutorAttribute : .TUnitAttribute, ., . + public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . where T : ., new () { public TestExecutorAttribute() { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index f6dc6eb0b9..855998a426 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1913,11 +1913,12 @@ namespace .Exceptions namespace .Executors { [(.Assembly | .Class | .Method)] - public class CultureAttribute : .TUnitAttribute, ., . + public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public CultureAttribute(.CultureInfo cultureInfo) { } public CultureAttribute(string cultureName) { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } public class HookExecutorAttribute : .TUnitAttribute @@ -1938,25 +1939,28 @@ namespace .Executors } [(.Assembly | .Class | .Method)] [.("windows")] - public class STAThreadExecutorAttribute : .TUnitAttribute, ., . + public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public STAThreadExecutorAttribute() { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } [(.Assembly | .Class | .Method)] - public sealed class TestExecutorAttribute : .TUnitAttribute, ., . + public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public TestExecutorAttribute([.(..PublicConstructors)] type) { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } [(.Assembly | .Class | .Method)] - public sealed class TestExecutorAttribute : .TUnitAttribute, ., . + public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . where T : ., new () { public TestExecutorAttribute() { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 33bedbec01..3f9990e990 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1913,11 +1913,12 @@ namespace .Exceptions namespace .Executors { [(.Assembly | .Class | .Method)] - public class CultureAttribute : .TUnitAttribute, ., . + public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public CultureAttribute(.CultureInfo cultureInfo) { } public CultureAttribute(string cultureName) { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } public class HookExecutorAttribute : .TUnitAttribute @@ -1938,25 +1939,28 @@ namespace .Executors } [(.Assembly | .Class | .Method)] [.("windows")] - public class STAThreadExecutorAttribute : .TUnitAttribute, ., . + public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public STAThreadExecutorAttribute() { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } [(.Assembly | .Class | .Method)] - public sealed class TestExecutorAttribute : .TUnitAttribute, ., . + public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public TestExecutorAttribute([.(..PublicConstructors)] type) { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } [(.Assembly | .Class | .Method)] - public sealed class TestExecutorAttribute : .TUnitAttribute, ., . + public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . where T : ., new () { public TestExecutorAttribute() { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 220dd7716f..03a16f9ba2 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1866,11 +1866,12 @@ namespace .Exceptions namespace .Executors { [(.Assembly | .Class | .Method)] - public class CultureAttribute : .TUnitAttribute, ., . + public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public CultureAttribute(.CultureInfo cultureInfo) { } public CultureAttribute(string cultureName) { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } public class HookExecutorAttribute : .TUnitAttribute @@ -1890,25 +1891,28 @@ namespace .Executors public InvariantCultureAttribute() { } } [(.Assembly | .Class | .Method)] - public class STAThreadExecutorAttribute : .TUnitAttribute, ., . + public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public STAThreadExecutorAttribute() { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } [(.Assembly | .Class | .Method)] - public sealed class TestExecutorAttribute : .TUnitAttribute, ., . + public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public TestExecutorAttribute( type) { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } [(.Assembly | .Class | .Method)] - public sealed class TestExecutorAttribute : .TUnitAttribute, ., . + public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . where T : ., new () { public TestExecutorAttribute() { } public int Order { get; } + public ScopeType { get; } public . OnTestRegistered(.TestRegisteredContext context) { } } } diff --git a/TUnit.TestProject/TestExecutorScopeHierarchyTests.cs b/TUnit.TestProject/TestExecutorScopeHierarchyTests.cs new file mode 100644 index 0000000000..09724c6dcc --- /dev/null +++ b/TUnit.TestProject/TestExecutorScopeHierarchyTests.cs @@ -0,0 +1,109 @@ +using TUnit.Core.Executors; +using TUnit.TestProject.Attributes; +using TUnit.TestProject.TestExecutors; + +namespace TUnit.TestProject; + +/// +/// Tests to verify that TestExecutorAttribute respects scope hierarchy: +/// method-level overrides class-level, which overrides assembly-level. +/// See: https://github.com/thomhurst/TUnit/issues/4351 +/// +[EngineTest(ExpectedResult.Pass)] +[TestExecutor] +public class TestExecutorScopeHierarchyTests +{ + [Test] + [TestExecutor] + public async Task MethodLevelExecutor_ShouldOverride_ClassLevelExecutor() + { + // Method-level [TestExecutor] should take precedence + // over class-level [TestExecutor] + var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(MethodLevelExecutor_ShouldOverride_ClassLevelExecutor)); + + await Assert.That(executorUsed) + .IsNotNull() + .And + .IsEqualTo("Method"); + } + + [Test] + public async Task ClassLevelExecutor_ShouldBeUsed_WhenNoMethodLevelExists() + { + // Without a method-level attribute, the class-level [TestExecutor] + // should be used + var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(ClassLevelExecutor_ShouldBeUsed_WhenNoMethodLevelExists)); + + await Assert.That(executorUsed) + .IsNotNull() + .And + .IsEqualTo("Class"); + } +} + +/// +/// Second test class to verify class-level executor is properly scoped per class. +/// This class has a different class-level executor than TestExecutorScopeHierarchyTests. +/// +[EngineTest(ExpectedResult.Pass)] +[TestExecutor] // Using MethodScopeExecutor at class level to verify isolation +public class TestExecutorScopeHierarchyTests2 +{ + [Test] + [TestExecutor] // Intentionally "reversed" to verify it works both ways + public async Task MethodLevelExecutor_OverridesClassLevel_EvenWhenReversed() + { + // Method-level should still win even when we use "ClassScopeExecutor" at method level + // and "MethodScopeExecutor" at class level - the scope hierarchy is about precedence, + // not the executor names + var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(MethodLevelExecutor_OverridesClassLevel_EvenWhenReversed)); + + await Assert.That(executorUsed) + .IsNotNull() + .And + .IsEqualTo("Class"); // "Class" because ClassScopeExecutor.ScopeName == "Class" + } + + [Test] + public async Task ClassLevelExecutor_UsedWhenNoMethodLevel() + { + // Should use the class-level executor (which happens to be MethodScopeExecutor in this class) + var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(ClassLevelExecutor_UsedWhenNoMethodLevel)); + + await Assert.That(executorUsed) + .IsNotNull() + .And + .IsEqualTo("Method"); // "Method" because MethodScopeExecutor.ScopeName == "Method" + } +} + +/// +/// Tests using the non-generic TestExecutorAttribute(typeof(...)) overload. +/// +[EngineTest(ExpectedResult.Pass)] +[TestExecutor(typeof(ClassScopeExecutor))] +public class TestExecutorScopeHierarchyNonGenericTests +{ + [Test] + [TestExecutor(typeof(MethodScopeExecutor))] + public async Task NonGeneric_MethodLevelExecutor_ShouldOverride_ClassLevelExecutor() + { + var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(NonGeneric_MethodLevelExecutor_ShouldOverride_ClassLevelExecutor)); + + await Assert.That(executorUsed) + .IsNotNull() + .And + .IsEqualTo("Method"); + } + + [Test] + public async Task NonGeneric_ClassLevelExecutor_ShouldBeUsed_WhenNoMethodLevelExists() + { + var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(NonGeneric_ClassLevelExecutor_ShouldBeUsed_WhenNoMethodLevelExists)); + + await Assert.That(executorUsed) + .IsNotNull() + .And + .IsEqualTo("Class"); + } +} diff --git a/TUnit.TestProject/TestExecutors/ScopeTrackingExecutor.cs b/TUnit.TestProject/TestExecutors/ScopeTrackingExecutor.cs new file mode 100644 index 0000000000..27e7b1a78f --- /dev/null +++ b/TUnit.TestProject/TestExecutors/ScopeTrackingExecutor.cs @@ -0,0 +1,69 @@ +using TUnit.Core.Interfaces; + +namespace TUnit.TestProject.TestExecutors; + +/// +/// Base class for scope-tracking test executors. +/// Each executor records which scope level was used to execute the test. +/// +public abstract class ScopeTrackingExecutor : ITestExecutor +{ + /// + /// Thread-local storage for tracking which executor actually ran for each test. + /// Key: test method name, Value: executor scope name. + /// + public static readonly Dictionary ExecutorUsedByTest = new(); + private static readonly object Lock = new(); + + protected abstract string ScopeName { get; } + + public async ValueTask ExecuteTest(TestContext context, Func action) + { + lock (Lock) + { + ExecutorUsedByTest[context.Metadata.TestDetails.MethodName] = ScopeName; + } + + await action(); + } + + public static string? GetExecutorUsed(string testName) + { + lock (Lock) + { + return ExecutorUsedByTest.GetValueOrDefault(testName); + } + } + + public static void Clear() + { + lock (Lock) + { + ExecutorUsedByTest.Clear(); + } + } +} + +/// +/// Executor that marks itself as "Method" scope level. +/// +public class MethodScopeExecutor : ScopeTrackingExecutor +{ + protected override string ScopeName => "Method"; +} + +/// +/// Executor that marks itself as "Class" scope level. +/// +public class ClassScopeExecutor : ScopeTrackingExecutor +{ + protected override string ScopeName => "Class"; +} + +/// +/// Executor that marks itself as "Assembly" scope level. +/// +public class AssemblyScopeExecutor : ScopeTrackingExecutor +{ + protected override string ScopeName => "Assembly"; +}