Skip to content

Commit bedb28d

Browse files
thomhurstclaude
andauthored
fix: TestExecutorAttribute respects scope hierarchy (method > class > assembly) (#4374)
* fix: TestExecutorAttribute respects scope hierarchy (method > class > assembly) Add IScopedAttribute to TestExecutorAttribute, STAThreadExecutorAttribute, and CultureAttribute to enable proper scope-based filtering. This ensures that method-level executor attributes take precedence over class-level, which take precedence over assembly-level. Previously, all executor attributes from all scopes were invoked and each would call SetTestExecutor(), with the last one winning (typically assembly-level due to attribute collection order). Now, the existing ScopedAttributeFilter mechanism properly filters executor attributes to keep only the first occurrence (method-level due to method -> class -> assembly collection order). Fixes #4351 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: add tests for TestExecutorAttribute scope hierarchy Add tests to verify that TestExecutorAttribute respects scope hierarchy: - Method-level executor overrides class-level - Class-level executor is used when no method-level exists - Tests both generic and non-generic TestExecutor attributes Verifies fix for #4351 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 451aad2 commit bedb28d

9 files changed

+238
-24
lines changed

TUnit.Core/Attributes/Executors/CultureAttribute.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@
44
namespace TUnit.Core.Executors;
55

66
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)]
7-
public class CultureAttribute(CultureInfo cultureInfo) : TUnitAttribute, ITestRegisteredEventReceiver
7+
public class CultureAttribute(CultureInfo cultureInfo) : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute
88
{
99
public CultureAttribute(string cultureName) : this(CultureInfo.GetCultureInfo(cultureName))
1010
{
1111
}
1212

13+
/// <inheritdoc />
1314
public int Order => 0;
1415

15-
public ValueTask OnTestRegistered(TestRegisteredContext context)
16+
/// <inheritdoc />
17+
public Type ScopeType => typeof(ITestExecutor);
18+
19+
/// <inheritdoc />
20+
public ValueTask OnTestRegistered(TestRegisteredContext context)
1621
{
1722
context.SetTestExecutor(new CultureExecutor(cultureInfo));
1823
return default(ValueTask);

TUnit.Core/Attributes/Executors/STAThreadExecutorAttribute.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ namespace TUnit.Core.Executors;
55

66
[SupportedOSPlatform("windows")]
77
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)]
8-
public class STAThreadExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver
8+
public class STAThreadExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute
99
{
10+
/// <inheritdoc />
1011
public int Order => 0;
1112

12-
public ValueTask OnTestRegistered(TestRegisteredContext context)
13+
/// <inheritdoc />
14+
public Type ScopeType => typeof(ITestExecutor);
15+
16+
/// <inheritdoc />
17+
public ValueTask OnTestRegistered(TestRegisteredContext context)
1318
{
1419
var executor = new STAThreadExecutor();
1520
context.SetTestExecutor(executor);

TUnit.Core/Attributes/Executors/TestExecutorAttribute.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,33 @@
44
namespace TUnit.Core.Executors;
55

66
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)]
7-
public sealed class TestExecutorAttribute<T> : TUnitAttribute, ITestRegisteredEventReceiver where T : ITestExecutor, new()
7+
public sealed class TestExecutorAttribute<T> : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute where T : ITestExecutor, new()
88
{
9+
/// <inheritdoc />
910
public int Order => 0;
1011

11-
public ValueTask OnTestRegistered(TestRegisteredContext context)
12+
/// <inheritdoc />
13+
public Type ScopeType => typeof(ITestExecutor);
14+
15+
/// <inheritdoc />
16+
public ValueTask OnTestRegistered(TestRegisteredContext context)
1217
{
1318
context.SetTestExecutor(new T());
1419
return default(ValueTask);
1520
}
1621
}
1722

1823
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)]
19-
public sealed class TestExecutorAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) : TUnitAttribute, ITestRegisteredEventReceiver
24+
public sealed class TestExecutorAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute
2025
{
26+
/// <inheritdoc />
2127
public int Order => 0;
2228

23-
public ValueTask OnTestRegistered(TestRegisteredContext context)
29+
/// <inheritdoc />
30+
public Type ScopeType => typeof(ITestExecutor);
31+
32+
/// <inheritdoc />
33+
public ValueTask OnTestRegistered(TestRegisteredContext context)
2434
{
2535
context.SetTestExecutor((ITestExecutor) Activator.CreateInstance(type)!);
2636
return default(ValueTask);

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,11 +1913,12 @@ namespace .Exceptions
19131913
namespace .Executors
19141914
{
19151915
[(.Assembly | .Class | .Method)]
1916-
public class CultureAttribute : .TUnitAttribute, ., .
1916+
public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., .
19171917
{
19181918
public CultureAttribute(.CultureInfo cultureInfo) { }
19191919
public CultureAttribute(string cultureName) { }
19201920
public int Order { get; }
1921+
public ScopeType { get; }
19211922
public . OnTestRegistered(.TestRegisteredContext context) { }
19221923
}
19231924
public class HookExecutorAttribute : .TUnitAttribute
@@ -1938,25 +1939,28 @@ namespace .Executors
19381939
}
19391940
[(.Assembly | .Class | .Method)]
19401941
[.("windows")]
1941-
public class STAThreadExecutorAttribute : .TUnitAttribute, ., .
1942+
public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
19421943
{
19431944
public STAThreadExecutorAttribute() { }
19441945
public int Order { get; }
1946+
public ScopeType { get; }
19451947
public . OnTestRegistered(.TestRegisteredContext context) { }
19461948
}
19471949
[(.Assembly | .Class | .Method)]
1948-
public sealed class TestExecutorAttribute : .TUnitAttribute, ., .
1950+
public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
19491951
{
19501952
public TestExecutorAttribute([.(..PublicConstructors)] type) { }
19511953
public int Order { get; }
1954+
public ScopeType { get; }
19521955
public . OnTestRegistered(.TestRegisteredContext context) { }
19531956
}
19541957
[(.Assembly | .Class | .Method)]
1955-
public sealed class TestExecutorAttribute<T> : .TUnitAttribute, ., .
1958+
public sealed class TestExecutorAttribute<T> : .TUnitAttribute, .IScopedAttribute, ., .
19561959
where T : ., new ()
19571960
{
19581961
public TestExecutorAttribute() { }
19591962
public int Order { get; }
1963+
public ScopeType { get; }
19601964
public . OnTestRegistered(.TestRegisteredContext context) { }
19611965
}
19621966
}

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,11 +1913,12 @@ namespace .Exceptions
19131913
namespace .Executors
19141914
{
19151915
[(.Assembly | .Class | .Method)]
1916-
public class CultureAttribute : .TUnitAttribute, ., .
1916+
public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., .
19171917
{
19181918
public CultureAttribute(.CultureInfo cultureInfo) { }
19191919
public CultureAttribute(string cultureName) { }
19201920
public int Order { get; }
1921+
public ScopeType { get; }
19211922
public . OnTestRegistered(.TestRegisteredContext context) { }
19221923
}
19231924
public class HookExecutorAttribute : .TUnitAttribute
@@ -1938,25 +1939,28 @@ namespace .Executors
19381939
}
19391940
[(.Assembly | .Class | .Method)]
19401941
[.("windows")]
1941-
public class STAThreadExecutorAttribute : .TUnitAttribute, ., .
1942+
public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
19421943
{
19431944
public STAThreadExecutorAttribute() { }
19441945
public int Order { get; }
1946+
public ScopeType { get; }
19451947
public . OnTestRegistered(.TestRegisteredContext context) { }
19461948
}
19471949
[(.Assembly | .Class | .Method)]
1948-
public sealed class TestExecutorAttribute : .TUnitAttribute, ., .
1950+
public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
19491951
{
19501952
public TestExecutorAttribute([.(..PublicConstructors)] type) { }
19511953
public int Order { get; }
1954+
public ScopeType { get; }
19521955
public . OnTestRegistered(.TestRegisteredContext context) { }
19531956
}
19541957
[(.Assembly | .Class | .Method)]
1955-
public sealed class TestExecutorAttribute<T> : .TUnitAttribute, ., .
1958+
public sealed class TestExecutorAttribute<T> : .TUnitAttribute, .IScopedAttribute, ., .
19561959
where T : ., new ()
19571960
{
19581961
public TestExecutorAttribute() { }
19591962
public int Order { get; }
1963+
public ScopeType { get; }
19601964
public . OnTestRegistered(.TestRegisteredContext context) { }
19611965
}
19621966
}

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,11 +1913,12 @@ namespace .Exceptions
19131913
namespace .Executors
19141914
{
19151915
[(.Assembly | .Class | .Method)]
1916-
public class CultureAttribute : .TUnitAttribute, ., .
1916+
public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., .
19171917
{
19181918
public CultureAttribute(.CultureInfo cultureInfo) { }
19191919
public CultureAttribute(string cultureName) { }
19201920
public int Order { get; }
1921+
public ScopeType { get; }
19211922
public . OnTestRegistered(.TestRegisteredContext context) { }
19221923
}
19231924
public class HookExecutorAttribute : .TUnitAttribute
@@ -1938,25 +1939,28 @@ namespace .Executors
19381939
}
19391940
[(.Assembly | .Class | .Method)]
19401941
[.("windows")]
1941-
public class STAThreadExecutorAttribute : .TUnitAttribute, ., .
1942+
public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
19421943
{
19431944
public STAThreadExecutorAttribute() { }
19441945
public int Order { get; }
1946+
public ScopeType { get; }
19451947
public . OnTestRegistered(.TestRegisteredContext context) { }
19461948
}
19471949
[(.Assembly | .Class | .Method)]
1948-
public sealed class TestExecutorAttribute : .TUnitAttribute, ., .
1950+
public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
19491951
{
19501952
public TestExecutorAttribute([.(..PublicConstructors)] type) { }
19511953
public int Order { get; }
1954+
public ScopeType { get; }
19521955
public . OnTestRegistered(.TestRegisteredContext context) { }
19531956
}
19541957
[(.Assembly | .Class | .Method)]
1955-
public sealed class TestExecutorAttribute<T> : .TUnitAttribute, ., .
1958+
public sealed class TestExecutorAttribute<T> : .TUnitAttribute, .IScopedAttribute, ., .
19561959
where T : ., new ()
19571960
{
19581961
public TestExecutorAttribute() { }
19591962
public int Order { get; }
1963+
public ScopeType { get; }
19601964
public . OnTestRegistered(.TestRegisteredContext context) { }
19611965
}
19621966
}

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1866,11 +1866,12 @@ namespace .Exceptions
18661866
namespace .Executors
18671867
{
18681868
[(.Assembly | .Class | .Method)]
1869-
public class CultureAttribute : .TUnitAttribute, ., .
1869+
public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., .
18701870
{
18711871
public CultureAttribute(.CultureInfo cultureInfo) { }
18721872
public CultureAttribute(string cultureName) { }
18731873
public int Order { get; }
1874+
public ScopeType { get; }
18741875
public . OnTestRegistered(.TestRegisteredContext context) { }
18751876
}
18761877
public class HookExecutorAttribute : .TUnitAttribute
@@ -1890,25 +1891,28 @@ namespace .Executors
18901891
public InvariantCultureAttribute() { }
18911892
}
18921893
[(.Assembly | .Class | .Method)]
1893-
public class STAThreadExecutorAttribute : .TUnitAttribute, ., .
1894+
public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
18941895
{
18951896
public STAThreadExecutorAttribute() { }
18961897
public int Order { get; }
1898+
public ScopeType { get; }
18971899
public . OnTestRegistered(.TestRegisteredContext context) { }
18981900
}
18991901
[(.Assembly | .Class | .Method)]
1900-
public sealed class TestExecutorAttribute : .TUnitAttribute, ., .
1902+
public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
19011903
{
19021904
public TestExecutorAttribute( type) { }
19031905
public int Order { get; }
1906+
public ScopeType { get; }
19041907
public . OnTestRegistered(.TestRegisteredContext context) { }
19051908
}
19061909
[(.Assembly | .Class | .Method)]
1907-
public sealed class TestExecutorAttribute<T> : .TUnitAttribute, ., .
1910+
public sealed class TestExecutorAttribute<T> : .TUnitAttribute, .IScopedAttribute, ., .
19081911
where T : ., new ()
19091912
{
19101913
public TestExecutorAttribute() { }
19111914
public int Order { get; }
1915+
public ScopeType { get; }
19121916
public . OnTestRegistered(.TestRegisteredContext context) { }
19131917
}
19141918
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using TUnit.Core.Executors;
2+
using TUnit.TestProject.Attributes;
3+
using TUnit.TestProject.TestExecutors;
4+
5+
namespace TUnit.TestProject;
6+
7+
/// <summary>
8+
/// Tests to verify that TestExecutorAttribute respects scope hierarchy:
9+
/// method-level overrides class-level, which overrides assembly-level.
10+
/// See: https://github.com/thomhurst/TUnit/issues/4351
11+
/// </summary>
12+
[EngineTest(ExpectedResult.Pass)]
13+
[TestExecutor<ClassScopeExecutor>]
14+
public class TestExecutorScopeHierarchyTests
15+
{
16+
[Test]
17+
[TestExecutor<MethodScopeExecutor>]
18+
public async Task MethodLevelExecutor_ShouldOverride_ClassLevelExecutor()
19+
{
20+
// Method-level [TestExecutor<MethodScopeExecutor>] should take precedence
21+
// over class-level [TestExecutor<ClassScopeExecutor>]
22+
var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(MethodLevelExecutor_ShouldOverride_ClassLevelExecutor));
23+
24+
await Assert.That(executorUsed)
25+
.IsNotNull()
26+
.And
27+
.IsEqualTo("Method");
28+
}
29+
30+
[Test]
31+
public async Task ClassLevelExecutor_ShouldBeUsed_WhenNoMethodLevelExists()
32+
{
33+
// Without a method-level attribute, the class-level [TestExecutor<ClassScopeExecutor>]
34+
// should be used
35+
var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(ClassLevelExecutor_ShouldBeUsed_WhenNoMethodLevelExists));
36+
37+
await Assert.That(executorUsed)
38+
.IsNotNull()
39+
.And
40+
.IsEqualTo("Class");
41+
}
42+
}
43+
44+
/// <summary>
45+
/// Second test class to verify class-level executor is properly scoped per class.
46+
/// This class has a different class-level executor than TestExecutorScopeHierarchyTests.
47+
/// </summary>
48+
[EngineTest(ExpectedResult.Pass)]
49+
[TestExecutor<MethodScopeExecutor>] // Using MethodScopeExecutor at class level to verify isolation
50+
public class TestExecutorScopeHierarchyTests2
51+
{
52+
[Test]
53+
[TestExecutor<ClassScopeExecutor>] // Intentionally "reversed" to verify it works both ways
54+
public async Task MethodLevelExecutor_OverridesClassLevel_EvenWhenReversed()
55+
{
56+
// Method-level should still win even when we use "ClassScopeExecutor" at method level
57+
// and "MethodScopeExecutor" at class level - the scope hierarchy is about precedence,
58+
// not the executor names
59+
var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(MethodLevelExecutor_OverridesClassLevel_EvenWhenReversed));
60+
61+
await Assert.That(executorUsed)
62+
.IsNotNull()
63+
.And
64+
.IsEqualTo("Class"); // "Class" because ClassScopeExecutor.ScopeName == "Class"
65+
}
66+
67+
[Test]
68+
public async Task ClassLevelExecutor_UsedWhenNoMethodLevel()
69+
{
70+
// Should use the class-level executor (which happens to be MethodScopeExecutor in this class)
71+
var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(ClassLevelExecutor_UsedWhenNoMethodLevel));
72+
73+
await Assert.That(executorUsed)
74+
.IsNotNull()
75+
.And
76+
.IsEqualTo("Method"); // "Method" because MethodScopeExecutor.ScopeName == "Method"
77+
}
78+
}
79+
80+
/// <summary>
81+
/// Tests using the non-generic TestExecutorAttribute(typeof(...)) overload.
82+
/// </summary>
83+
[EngineTest(ExpectedResult.Pass)]
84+
[TestExecutor(typeof(ClassScopeExecutor))]
85+
public class TestExecutorScopeHierarchyNonGenericTests
86+
{
87+
[Test]
88+
[TestExecutor(typeof(MethodScopeExecutor))]
89+
public async Task NonGeneric_MethodLevelExecutor_ShouldOverride_ClassLevelExecutor()
90+
{
91+
var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(NonGeneric_MethodLevelExecutor_ShouldOverride_ClassLevelExecutor));
92+
93+
await Assert.That(executorUsed)
94+
.IsNotNull()
95+
.And
96+
.IsEqualTo("Method");
97+
}
98+
99+
[Test]
100+
public async Task NonGeneric_ClassLevelExecutor_ShouldBeUsed_WhenNoMethodLevelExists()
101+
{
102+
var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(NonGeneric_ClassLevelExecutor_ShouldBeUsed_WhenNoMethodLevelExists));
103+
104+
await Assert.That(executorUsed)
105+
.IsNotNull()
106+
.And
107+
.IsEqualTo("Class");
108+
}
109+
}

0 commit comments

Comments
 (0)