Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions TUnit.Core/Attributes/Executors/CultureAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
}

/// <inheritdoc />
public int Order => 0;

public ValueTask OnTestRegistered(TestRegisteredContext context)
/// <inheritdoc />
public Type ScopeType => typeof(ITestExecutor);

/// <inheritdoc />
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
context.SetTestExecutor(new CultureExecutor(cultureInfo));
return default(ValueTask);
Expand Down
9 changes: 7 additions & 2 deletions TUnit.Core/Attributes/Executors/STAThreadExecutorAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/// <inheritdoc />
public int Order => 0;

public ValueTask OnTestRegistered(TestRegisteredContext context)
/// <inheritdoc />
public Type ScopeType => typeof(ITestExecutor);

/// <inheritdoc />
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
var executor = new STAThreadExecutor();
context.SetTestExecutor(executor);
Expand Down
18 changes: 14 additions & 4 deletions TUnit.Core/Attributes/Executors/TestExecutorAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,33 @@
namespace TUnit.Core.Executors;

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

public ValueTask OnTestRegistered(TestRegisteredContext context)
/// <inheritdoc />
public Type ScopeType => typeof(ITestExecutor);

/// <inheritdoc />
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
context.SetTestExecutor(new T());
return default(ValueTask);
}
}

[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
{
/// <inheritdoc />
public int Order => 0;

public ValueTask OnTestRegistered(TestRegisteredContext context)
/// <inheritdoc />
public Type ScopeType => typeof(ITestExecutor);

/// <inheritdoc />
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
context.SetTestExecutor((ITestExecutor) Activator.CreateInstance(type)!);
return default(ValueTask);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<T> : .TUnitAttribute, ., .
public sealed class TestExecutorAttribute<T> : .TUnitAttribute, .IScopedAttribute, ., .
where T : ., new ()
{
public TestExecutorAttribute() { }
public int Order { get; }
public ScopeType { get; }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<T> : .TUnitAttribute, ., .
public sealed class TestExecutorAttribute<T> : .TUnitAttribute, .IScopedAttribute, ., .
where T : ., new ()
{
public TestExecutorAttribute() { }
public int Order { get; }
public ScopeType { get; }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<T> : .TUnitAttribute, ., .
public sealed class TestExecutorAttribute<T> : .TUnitAttribute, .IScopedAttribute, ., .
where T : ., new ()
{
public TestExecutorAttribute() { }
public int Order { get; }
public ScopeType { get; }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<T> : .TUnitAttribute, ., .
public sealed class TestExecutorAttribute<T> : .TUnitAttribute, .IScopedAttribute, ., .
where T : ., new ()
{
public TestExecutorAttribute() { }
public int Order { get; }
public ScopeType { get; }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
}
Expand Down
109 changes: 109 additions & 0 deletions TUnit.TestProject/TestExecutorScopeHierarchyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using TUnit.Core.Executors;
using TUnit.TestProject.Attributes;
using TUnit.TestProject.TestExecutors;

namespace TUnit.TestProject;

/// <summary>
/// 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
/// </summary>
[EngineTest(ExpectedResult.Pass)]
[TestExecutor<ClassScopeExecutor>]
public class TestExecutorScopeHierarchyTests
{
[Test]
[TestExecutor<MethodScopeExecutor>]
public async Task MethodLevelExecutor_ShouldOverride_ClassLevelExecutor()
{
// Method-level [TestExecutor<MethodScopeExecutor>] should take precedence
// over class-level [TestExecutor<ClassScopeExecutor>]
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<ClassScopeExecutor>]
// should be used
var executorUsed = ScopeTrackingExecutor.GetExecutorUsed(nameof(ClassLevelExecutor_ShouldBeUsed_WhenNoMethodLevelExists));

await Assert.That(executorUsed)
.IsNotNull()
.And
.IsEqualTo("Class");
}
}

/// <summary>
/// Second test class to verify class-level executor is properly scoped per class.
/// This class has a different class-level executor than TestExecutorScopeHierarchyTests.
/// </summary>
[EngineTest(ExpectedResult.Pass)]
[TestExecutor<MethodScopeExecutor>] // Using MethodScopeExecutor at class level to verify isolation
public class TestExecutorScopeHierarchyTests2
{
[Test]
[TestExecutor<ClassScopeExecutor>] // 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"
}
}

/// <summary>
/// Tests using the non-generic TestExecutorAttribute(typeof(...)) overload.
/// </summary>
[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");
}
}
Loading
Loading