-
-
Notifications
You must be signed in to change notification settings - Fork 110
Description
Description
When a [TestExecutor] attribute is applied at multiple scopes (assembly, class, and/or method level), the method-level executor should take precedence over class-level, which should take precedence over assembly-level. However, due to how TestExecutorAttribute implements ITestRegisteredEventReceiver, the actual executor used is non-deterministic.
Expected Behavior
Method-level [TestExecutor] attributes should always take precedence over class-level, which should take precedence over assembly-level. This follows the standard attribute scope hierarchy used elsewhere in TUnit.
Example:
[assembly: TestExecutor<AssemblyExecutor>]
[TestExecutor<ClassExecutor>]
public class MyTests
{
[Test]
[TestExecutor<MethodExecutor>] // This should ALWAYS win
public void MyTest() { }
[Test] // This should use ClassExecutor
public void AnotherTest() { }
}Expected results:
MyTesttoMethodExecutor(method overrides class)AnotherTesttoClassExecutor(class overrides assembly)
Actual Behavior
The effective executor does not respect the expected scope hierarchy:
- The assembly-level executor incorrectly takes precedence over both class-level and method-level executors
- In some environments/configurations, the behavior may be non-deterministic (randomly choosing between scopes on each test run)
- In AOT compilation mode, the behavior appears consistently wrong (always using the wrong executor rather than varying)
Steps to Reproduce
Run the included test program.
dotnet run TestExecutorBugDemo.cs -- --log-level Trace
#:package TUnit@*
using TUnit.Core;
using TUnit.Core.Executors;
using TUnit.Core.Interfaces;
[assembly: TestExecutor<AssemblyExecutor>]
// Track which executor actually runs
public static class ExecutorTracker
{
public static string? LastExecutorUsed { get; set; }
public static Dictionary<string, string?> Results { get; } = new();
}
public class AssemblyExecutor : ITestExecutor
{
public async ValueTask ExecuteTest(TestContext context, Func<ValueTask> action)
{
ExecutorTracker.LastExecutorUsed = "AssemblyExecutor";
Console.WriteLine("AssemblyExecutor running");
await action();
}
}
public class ClassExecutor : ITestExecutor
{
public async ValueTask ExecuteTest(TestContext context, Func<ValueTask> action)
{
ExecutorTracker.LastExecutorUsed = "ClassExecutor";
Console.WriteLine("ClassExecutor running");
await action();
}
}
public class MethodExecutor : ITestExecutor
{
public async ValueTask ExecuteTest(TestContext context, Func<ValueTask> action)
{
ExecutorTracker.LastExecutorUsed = "MethodExecutor";
Console.WriteLine("MethodExecutor running");
await action();
}
}
[TestExecutor<ClassExecutor>]
public class ExecutorBugTests
{
[Test]
[TestExecutor<MethodExecutor>]
public async Task TestWithMethodExecutor()
{
await Task.CompletedTask;
var testName = "TestWithMethodExecutor";
ExecutorTracker.Results[testName] = ExecutorTracker.LastExecutorUsed;
Console.WriteLine($"Test: {testName}");
Console.WriteLine($"Executor used: {ExecutorTracker.LastExecutorUsed}");
if (ExecutorTracker.LastExecutorUsed != "MethodExecutor")
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"❌ BUG DETECTED: Expected MethodExecutor but got {ExecutorTracker.LastExecutorUsed}!");
Console.ResetColor();
}
else
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("✓ Correct: MethodExecutor was used");
Console.ResetColor();
}
}
[Test]
public async Task TestWithOnlyClassExecutor()
{
await Task.CompletedTask;
var testName = "TestWithOnlyClassExecutor";
ExecutorTracker.Results[testName] = ExecutorTracker.LastExecutorUsed;
Console.WriteLine($"Test: {testName}");
Console.WriteLine($"Executor used: {ExecutorTracker.LastExecutorUsed}");
if (ExecutorTracker.LastExecutorUsed != "ClassExecutor")
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"❌ BUG DETECTED: Expected ClassExecutor but got {ExecutorTracker.LastExecutorUsed}!");
Console.ResetColor();
}
else
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("✓ Correct: ClassExecutor was used");
Console.ResetColor();
}
}
[After(Class)]
public static async Task ShowResults()
{
await Task.CompletedTask;
Console.WriteLine("\n" + new string('=', 70));
Console.WriteLine("TEST RESULTS SUMMARY");
Console.WriteLine(new string('=', 70));
foreach (var result in ExecutorTracker.Results)
{
Console.WriteLine($"{result.Key}: {result.Value}");
}
Console.WriteLine("\n" + new string('=', 70));
Console.WriteLine("EXPECTED BEHAVIOR:");
Console.WriteLine(" TestWithMethodExecutor -> MethodExecutor (method overrides class)");
Console.WriteLine(" TestWithOnlyClassExecutor -> ClassExecutor (class overrides assembly)");
Console.WriteLine();
Console.WriteLine("If you see different executors, the bug is reproduced!");
Console.WriteLine("Run this test multiple times - results may vary due to non-determinism.");
Console.WriteLine(new string('=', 70));
}
}
public class NoExecutorTests
{
[Test]
public async Task TestWithOnlyAssemblyExecutor()
{
await Task.CompletedTask;
var testName = "TestWithOnlyAssemblyExecutor";
ExecutorTracker.Results[testName] = ExecutorTracker.LastExecutorUsed;
Console.WriteLine($"Test: {testName}");
Console.WriteLine($"Executor used: {ExecutorTracker.LastExecutorUsed}");
if (ExecutorTracker.LastExecutorUsed != "AssemblyExecutor")
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"❌ Unexpected: Got {ExecutorTracker.LastExecutorUsed}!");
Console.ResetColor();
}
else
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("✓ Correct: AssemblyExecutor was used");
Console.ResetColor();
}
}
}TUnit Version
1.9.85
.NET Version
net10.0
Operating System
Windows
IDE / Test Runner
dotnet CLI (dotnet test / dotnet run)
Error Output / Stack Trace
Additional Context
No response
IDE-Specific Issue?
- I've confirmed this issue occurs when running via
dotnet testordotnet run, not just in my IDE