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";
+}