Skip to content

[Bug]: TestExecutorAttribute Does Not Respect Attribute Scope Hierarchy #4351

@glennawatson

Description

@glennawatson

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:

  • MyTest to MethodExecutor (method overrides class)
  • AnotherTest to ClassExecutor (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 test or dotnet run, not just in my IDE

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions