From f523fa5e5fcb1184fb600c4448920c2884541346 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 24 Oct 2025 20:13:10 +0100 Subject: [PATCH] feat: introduce ReflectionMode attribute and enhance execution mode handling --- TUnit.Assertions/Sources/DelegateAssertion.cs | 6 +- TUnit.Assertions/Sources/FuncAssertion.cs | 6 +- .../Attributes/ReflectionModeAttribute.cs | 43 ++++++++++ .../Framework/TUnitServiceProvider.cs | 47 +--------- TUnit.Engine/Helpers/ExecutionModeHelper.cs | 86 +++++++++++++++++++ ...Has_No_API_Changes.DotNet10_0.verified.txt | 5 ++ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 5 ++ ..._Has_No_API_Changes.DotNet9_0.verified.txt | 5 ++ ...ary_Has_No_API_Changes.Net4_7.verified.txt | 5 ++ docs/docs/execution/engine-modes.md | 59 ++++++++++++- 10 files changed, 211 insertions(+), 56 deletions(-) create mode 100644 TUnit.Core/Attributes/ReflectionModeAttribute.cs create mode 100644 TUnit.Engine/Helpers/ExecutionModeHelper.cs diff --git a/TUnit.Assertions/Sources/DelegateAssertion.cs b/TUnit.Assertions/Sources/DelegateAssertion.cs index babb7a1802..e3f0b543b4 100644 --- a/TUnit.Assertions/Sources/DelegateAssertion.cs +++ b/TUnit.Assertions/Sources/DelegateAssertion.cs @@ -21,16 +21,16 @@ public DelegateAssertion(Action action, string? expression) Action = action ?? throw new ArgumentNullException(nameof(action)); var expressionBuilder = new StringBuilder(); expressionBuilder.Append($"Assert.That({expression ?? "?"})"); - var evaluationContext = new EvaluationContext(async () => + var evaluationContext = new EvaluationContext(() => { try { action(); - return (null, null); + return Task.FromResult<(object?, Exception?)>((null, null)); } catch (Exception ex) { - return (null, ex); + return Task.FromResult<(object?, Exception?)>((null, ex)); } }); Context = new AssertionContext(evaluationContext, expressionBuilder); diff --git a/TUnit.Assertions/Sources/FuncAssertion.cs b/TUnit.Assertions/Sources/FuncAssertion.cs index c5e86f5add..85cdbee096 100644 --- a/TUnit.Assertions/Sources/FuncAssertion.cs +++ b/TUnit.Assertions/Sources/FuncAssertion.cs @@ -18,16 +18,16 @@ public FuncAssertion(Func func, string? expression) { var expressionBuilder = new StringBuilder(); expressionBuilder.Append($"Assert.That({expression ?? "?"})"); - var evaluationContext = new EvaluationContext(async () => + var evaluationContext = new EvaluationContext(() => { try { var result = func(); - return (result, null); + return Task.FromResult<(TValue?, Exception?)>((result, null)); } catch (Exception ex) { - return (default(TValue), ex); + return Task.FromResult<(TValue?, Exception?)>((default(TValue), ex)); } }); Context = new AssertionContext(evaluationContext, expressionBuilder); diff --git a/TUnit.Core/Attributes/ReflectionModeAttribute.cs b/TUnit.Core/Attributes/ReflectionModeAttribute.cs new file mode 100644 index 0000000000..0fc42f4128 --- /dev/null +++ b/TUnit.Core/Attributes/ReflectionModeAttribute.cs @@ -0,0 +1,43 @@ +namespace TUnit.Core; + +/// +/// Attribute that forces the test assembly to use reflection mode for test discovery and execution. +/// +/// +/// +/// Use this attribute when source generation cannot be used for test discovery, such as when +/// working with dynamically generated types (e.g., Razor components in bUnit tests). +/// +/// +/// +/// This attribute should be applied at the assembly level and affects all tests in the assembly. +/// Command-line options (--reflection) can still override this setting. +/// +/// +/// +/// Performance Note: Reflection mode is slower than source-generated mode. +/// Only use this attribute when source generation is incompatible with your test scenarios. +/// +/// +/// +/// +/// // Add to your test project (e.g., in AssemblyInfo.cs or at the top of any .cs file) +/// using TUnit.Core; +/// +/// [assembly: ReflectionMode] +/// +/// // All tests in this assembly will now use reflection mode +/// public class MyBunitTests +/// { +/// [Test] +/// public void TestRazorComponent() +/// { +/// // Test Razor components that are source-generated at compile time +/// } +/// } +/// +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] +public sealed class ReflectionModeAttribute : Attribute +{ +} diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index 55e36afd38..a266526c39 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -80,7 +80,7 @@ public TUnitServiceProvider(IExtension extension, var logLevelProvider = Register(new LogLevelProvider(CommandLineOptions)); // Determine execution mode early to create appropriate services - var useSourceGeneration = SourceRegistrar.IsEnabled = GetUseSourceGeneration(CommandLineOptions); + var useSourceGeneration = SourceRegistrar.IsEnabled = ExecutionModeHelper.IsSourceGenerationMode(CommandLineOptions); // Create and register mode-specific hook discovery service IHookDiscoveryService hookDiscoveryService; @@ -275,51 +275,6 @@ private T Register(T service) where T : class return service; } - private static bool GetUseSourceGeneration(ICommandLineOptions commandLineOptions) - { -#if NET - if (!RuntimeFeature.IsDynamicCodeSupported) - { - return true; // Force source generation on AOT platforms - } -#endif - - if (commandLineOptions.TryGetOptionArgumentList(ReflectionModeCommandProvider.ReflectionMode, out _)) - { - return false; // Reflection mode explicitly requested - } - - // Check for command line option - if (commandLineOptions.TryGetOptionArgumentList("tunit-execution-mode", out var modes) && modes.Length > 0) - { - var mode = modes[0].ToLowerInvariant(); - if (mode == "sourcegeneration" || mode == "aot") - { - return true; - } - else if (mode == "reflection") - { - return false; - } - } - - // Check environment variable - var envMode = EnvironmentVariableCache.Get("TUNIT_EXECUTION_MODE"); - if (!string.IsNullOrEmpty(envMode)) - { - var mode = envMode!.ToLowerInvariant(); - if (mode == "sourcegeneration" || mode == "aot") - { - return true; - } - else if (mode == "reflection") - { - return false; - } - } - - return SourceRegistrar.IsEnabled; - } public async ValueTask DisposeAsync() { diff --git a/TUnit.Engine/Helpers/ExecutionModeHelper.cs b/TUnit.Engine/Helpers/ExecutionModeHelper.cs new file mode 100644 index 0000000000..bc654ca3cf --- /dev/null +++ b/TUnit.Engine/Helpers/ExecutionModeHelper.cs @@ -0,0 +1,86 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using Microsoft.Testing.Platform.CommandLine; +using TUnit.Core; +using TUnit.Engine.CommandLineProviders; + +namespace TUnit.Engine.Helpers; + +/// +/// Helper class for determining test execution mode (source generation vs reflection). +/// +internal static class ExecutionModeHelper +{ + /// + /// Determines whether to use source generation mode for test discovery and execution. + /// + /// Command line options from the test platform. + /// + /// True if source generation mode should be used; false if reflection mode should be used. + /// + /// + /// Priority order: + /// 1. AOT platform check (forces source generation) + /// 2. Command line --reflection flag + /// 3. Command line --tunit-execution-mode + /// 4. Assembly-level [ReflectionMode] attribute + /// 5. Environment variable TUNIT_EXECUTION_MODE + /// 6. Default (SourceRegistrar.IsEnabled) + /// + public static bool IsSourceGenerationMode(ICommandLineOptions commandLineOptions) + { +#if NET + if (!RuntimeFeature.IsDynamicCodeSupported) + { + return true; // Force source generation on AOT platforms + } +#endif + + if (commandLineOptions.TryGetOptionArgumentList(ReflectionModeCommandProvider.ReflectionMode, out _)) + { + return false; // Reflection mode explicitly requested + } + + // Check for command line option + if (commandLineOptions.TryGetOptionArgumentList("tunit-execution-mode", out var modes) && modes.Length > 0) + { + var mode = modes[0].ToLowerInvariant(); + if (mode == "sourcegeneration" || mode == "aot") + { + return true; + } + else if (mode == "reflection") + { + return false; + } + } + + // Check for assembly-level ReflectionMode attribute + var entryAssembly = Assembly.GetEntryAssembly(); + if (entryAssembly != null) + { + var hasReflectionModeAttribute = entryAssembly.GetCustomAttributes(typeof(ReflectionModeAttribute), inherit: false).Length > 0; + if (hasReflectionModeAttribute) + { + return false; // Assembly is marked for reflection mode + } + } + + // Check environment variable + var envMode = EnvironmentVariableCache.Get("TUNIT_EXECUTION_MODE"); + if (!string.IsNullOrEmpty(envMode)) + { + var mode = envMode!.ToLowerInvariant(); + if (mode == "sourcegeneration" || mode == "aot") + { + return true; + } + else if (mode == "reflection") + { + return false; + } + } + + return SourceRegistrar.IsEnabled; + } +} 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 ac536d62a6..8186499f2e 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 @@ -1104,6 +1104,11 @@ namespace public static ..IPropertySource? GetSource( type) { } public static void Register( type, ..IPropertySource source) { } } + [(.Assembly, AllowMultiple=false, Inherited=false)] + public sealed class ReflectionModeAttribute : + { + public ReflectionModeAttribute() { } + } [(.Assembly | .Class | .Method)] public sealed class RepeatAttribute : .TUnitAttribute, .IScopedAttribute { 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 9582362ed0..0e54ceb230 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 @@ -1104,6 +1104,11 @@ namespace public static ..IPropertySource? GetSource( type) { } public static void Register( type, ..IPropertySource source) { } } + [(.Assembly, AllowMultiple=false, Inherited=false)] + public sealed class ReflectionModeAttribute : + { + public ReflectionModeAttribute() { } + } [(.Assembly | .Class | .Method)] public sealed class RepeatAttribute : .TUnitAttribute, .IScopedAttribute { 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 fb2c06e332..68477ecf97 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 @@ -1104,6 +1104,11 @@ namespace public static ..IPropertySource? GetSource( type) { } public static void Register( type, ..IPropertySource source) { } } + [(.Assembly, AllowMultiple=false, Inherited=false)] + public sealed class ReflectionModeAttribute : + { + public ReflectionModeAttribute() { } + } [(.Assembly | .Class | .Method)] public sealed class RepeatAttribute : .TUnitAttribute, .IScopedAttribute { 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 0f7cf90f4c..401d09948e 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 @@ -1063,6 +1063,11 @@ namespace public static ..IPropertySource? GetSource( type) { } public static void Register( type, ..IPropertySource source) { } } + [(.Assembly, AllowMultiple=false, Inherited=false)] + public sealed class ReflectionModeAttribute : + { + public ReflectionModeAttribute() { } + } [(.Assembly | .Class | .Method)] public sealed class RepeatAttribute : .TUnitAttribute, .IScopedAttribute { diff --git a/docs/docs/execution/engine-modes.md b/docs/docs/execution/engine-modes.md index c85225d061..009d9f59a6 100644 --- a/docs/docs/execution/engine-modes.md +++ b/docs/docs/execution/engine-modes.md @@ -26,19 +26,70 @@ This is the standard mode used for all builds, whether debugging, running tests, ## Reflection Mode -Reflection mode can be explicitly enabled using the `--reflection` command-line flag: +Reflection mode can be explicitly enabled in several ways: - **Runtime Discovery**: Tests are discovered at runtime using reflection - **Dynamic Execution**: Uses traditional reflection-based test invocation -- **Compatibility**: Useful for scenarios where source generation may not be suitable +- **Compatibility**: Useful for scenarios where source generation may not be suitable (e.g., bUnit with Razor components) - **Legacy Support**: Maintains compatibility with reflection-dependent test patterns -Enable reflection mode by running: +### Enabling Reflection Mode + +There are three ways to enable reflection mode, listed in priority order: + +#### 1. Command-Line Flag (Highest Priority) ```bash dotnet test -- --reflection ``` -Alternatively, setting the environment variable `TUNIT_EXECUTION_MODE` to `reflection` enables the reflection engine mode globally. +#### 2. Assembly Attribute (Recommended for Per-Project Configuration) +Add to any `.cs` file in your test project (e.g., `AssemblyInfo.cs`): +```csharp +using TUnit.Core; + +[assembly: ReflectionMode] +``` + +This is the recommended approach when you need reflection mode for a specific test assembly, such as bUnit projects that test Razor components. The configuration is version-controlled and doesn't require external configuration files. + +**Example: bUnit Test Project** +```csharp +// Add this to enable reflection mode for your bUnit tests +[assembly: ReflectionMode] + +namespace MyApp.Tests; + +public class CounterComponentTests : TestContext +{ + [Test] + public void CounterStartsAtZero() + { + // Test Razor components that are source-generated at compile time + var cut = RenderComponent(); + cut.Find("p").TextContent.ShouldBe("Current count: 0"); + } +} +``` + +#### 3. Environment Variable (Global Configuration) +```bash +# Windows +set TUNIT_EXECUTION_MODE=reflection + +# Linux/macOS +export TUNIT_EXECUTION_MODE=reflection +``` + +Alternatively, you can configure this in a `.runsettings` file: +```xml + + + + reflection + + + +``` ## Native AOT Support