Skip to content

Commit 5be8c39

Browse files
authored
feat: introduce ReflectionMode attribute and enhance execution mode handling (#3504)
1 parent dbe7b35 commit 5be8c39

10 files changed

+211
-56
lines changed

TUnit.Assertions/Sources/DelegateAssertion.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,16 @@ public DelegateAssertion(Action action, string? expression)
2121
Action = action ?? throw new ArgumentNullException(nameof(action));
2222
var expressionBuilder = new StringBuilder();
2323
expressionBuilder.Append($"Assert.That({expression ?? "?"})");
24-
var evaluationContext = new EvaluationContext<object?>(async () =>
24+
var evaluationContext = new EvaluationContext<object?>(() =>
2525
{
2626
try
2727
{
2828
action();
29-
return (null, null);
29+
return Task.FromResult<(object?, Exception?)>((null, null));
3030
}
3131
catch (Exception ex)
3232
{
33-
return (null, ex);
33+
return Task.FromResult<(object?, Exception?)>((null, ex));
3434
}
3535
});
3636
Context = new AssertionContext<object?>(evaluationContext, expressionBuilder);

TUnit.Assertions/Sources/FuncAssertion.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,16 @@ public FuncAssertion(Func<TValue?> func, string? expression)
1818
{
1919
var expressionBuilder = new StringBuilder();
2020
expressionBuilder.Append($"Assert.That({expression ?? "?"})");
21-
var evaluationContext = new EvaluationContext<TValue>(async () =>
21+
var evaluationContext = new EvaluationContext<TValue>(() =>
2222
{
2323
try
2424
{
2525
var result = func();
26-
return (result, null);
26+
return Task.FromResult<(TValue?, Exception?)>((result, null));
2727
}
2828
catch (Exception ex)
2929
{
30-
return (default(TValue), ex);
30+
return Task.FromResult<(TValue?, Exception?)>((default(TValue), ex));
3131
}
3232
});
3333
Context = new AssertionContext<TValue>(evaluationContext, expressionBuilder);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
namespace TUnit.Core;
2+
3+
/// <summary>
4+
/// Attribute that forces the test assembly to use reflection mode for test discovery and execution.
5+
/// </summary>
6+
/// <remarks>
7+
/// <para>
8+
/// Use this attribute when source generation cannot be used for test discovery, such as when
9+
/// working with dynamically generated types (e.g., Razor components in bUnit tests).
10+
/// </para>
11+
///
12+
/// <para>
13+
/// This attribute should be applied at the assembly level and affects all tests in the assembly.
14+
/// Command-line options (--reflection) can still override this setting.
15+
/// </para>
16+
///
17+
/// <para>
18+
/// <strong>Performance Note:</strong> Reflection mode is slower than source-generated mode.
19+
/// Only use this attribute when source generation is incompatible with your test scenarios.
20+
/// </para>
21+
/// </remarks>
22+
/// <example>
23+
/// <code>
24+
/// // Add to your test project (e.g., in AssemblyInfo.cs or at the top of any .cs file)
25+
/// using TUnit.Core;
26+
///
27+
/// [assembly: ReflectionMode]
28+
///
29+
/// // All tests in this assembly will now use reflection mode
30+
/// public class MyBunitTests
31+
/// {
32+
/// [Test]
33+
/// public void TestRazorComponent()
34+
/// {
35+
/// // Test Razor components that are source-generated at compile time
36+
/// }
37+
/// }
38+
/// </code>
39+
/// </example>
40+
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)]
41+
public sealed class ReflectionModeAttribute : Attribute
42+
{
43+
}

TUnit.Engine/Framework/TUnitServiceProvider.cs

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public TUnitServiceProvider(IExtension extension,
8080
var logLevelProvider = Register(new LogLevelProvider(CommandLineOptions));
8181

8282
// Determine execution mode early to create appropriate services
83-
var useSourceGeneration = SourceRegistrar.IsEnabled = GetUseSourceGeneration(CommandLineOptions);
83+
var useSourceGeneration = SourceRegistrar.IsEnabled = ExecutionModeHelper.IsSourceGenerationMode(CommandLineOptions);
8484

8585
// Create and register mode-specific hook discovery service
8686
IHookDiscoveryService hookDiscoveryService;
@@ -275,51 +275,6 @@ private T Register<T>(T service) where T : class
275275
return service;
276276
}
277277

278-
private static bool GetUseSourceGeneration(ICommandLineOptions commandLineOptions)
279-
{
280-
#if NET
281-
if (!RuntimeFeature.IsDynamicCodeSupported)
282-
{
283-
return true; // Force source generation on AOT platforms
284-
}
285-
#endif
286-
287-
if (commandLineOptions.TryGetOptionArgumentList(ReflectionModeCommandProvider.ReflectionMode, out _))
288-
{
289-
return false; // Reflection mode explicitly requested
290-
}
291-
292-
// Check for command line option
293-
if (commandLineOptions.TryGetOptionArgumentList("tunit-execution-mode", out var modes) && modes.Length > 0)
294-
{
295-
var mode = modes[0].ToLowerInvariant();
296-
if (mode == "sourcegeneration" || mode == "aot")
297-
{
298-
return true;
299-
}
300-
else if (mode == "reflection")
301-
{
302-
return false;
303-
}
304-
}
305-
306-
// Check environment variable
307-
var envMode = EnvironmentVariableCache.Get("TUNIT_EXECUTION_MODE");
308-
if (!string.IsNullOrEmpty(envMode))
309-
{
310-
var mode = envMode!.ToLowerInvariant();
311-
if (mode == "sourcegeneration" || mode == "aot")
312-
{
313-
return true;
314-
}
315-
else if (mode == "reflection")
316-
{
317-
return false;
318-
}
319-
}
320-
321-
return SourceRegistrar.IsEnabled;
322-
}
323278

324279
public async ValueTask DisposeAsync()
325280
{
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System.Reflection;
2+
using System.Runtime.CompilerServices;
3+
using Microsoft.Testing.Platform.CommandLine;
4+
using TUnit.Core;
5+
using TUnit.Engine.CommandLineProviders;
6+
7+
namespace TUnit.Engine.Helpers;
8+
9+
/// <summary>
10+
/// Helper class for determining test execution mode (source generation vs reflection).
11+
/// </summary>
12+
internal static class ExecutionModeHelper
13+
{
14+
/// <summary>
15+
/// Determines whether to use source generation mode for test discovery and execution.
16+
/// </summary>
17+
/// <param name="commandLineOptions">Command line options from the test platform.</param>
18+
/// <returns>
19+
/// True if source generation mode should be used; false if reflection mode should be used.
20+
/// </returns>
21+
/// <remarks>
22+
/// Priority order:
23+
/// 1. AOT platform check (forces source generation)
24+
/// 2. Command line --reflection flag
25+
/// 3. Command line --tunit-execution-mode
26+
/// 4. Assembly-level [ReflectionMode] attribute
27+
/// 5. Environment variable TUNIT_EXECUTION_MODE
28+
/// 6. Default (SourceRegistrar.IsEnabled)
29+
/// </remarks>
30+
public static bool IsSourceGenerationMode(ICommandLineOptions commandLineOptions)
31+
{
32+
#if NET
33+
if (!RuntimeFeature.IsDynamicCodeSupported)
34+
{
35+
return true; // Force source generation on AOT platforms
36+
}
37+
#endif
38+
39+
if (commandLineOptions.TryGetOptionArgumentList(ReflectionModeCommandProvider.ReflectionMode, out _))
40+
{
41+
return false; // Reflection mode explicitly requested
42+
}
43+
44+
// Check for command line option
45+
if (commandLineOptions.TryGetOptionArgumentList("tunit-execution-mode", out var modes) && modes.Length > 0)
46+
{
47+
var mode = modes[0].ToLowerInvariant();
48+
if (mode == "sourcegeneration" || mode == "aot")
49+
{
50+
return true;
51+
}
52+
else if (mode == "reflection")
53+
{
54+
return false;
55+
}
56+
}
57+
58+
// Check for assembly-level ReflectionMode attribute
59+
var entryAssembly = Assembly.GetEntryAssembly();
60+
if (entryAssembly != null)
61+
{
62+
var hasReflectionModeAttribute = entryAssembly.GetCustomAttributes(typeof(ReflectionModeAttribute), inherit: false).Length > 0;
63+
if (hasReflectionModeAttribute)
64+
{
65+
return false; // Assembly is marked for reflection mode
66+
}
67+
}
68+
69+
// Check environment variable
70+
var envMode = EnvironmentVariableCache.Get("TUNIT_EXECUTION_MODE");
71+
if (!string.IsNullOrEmpty(envMode))
72+
{
73+
var mode = envMode!.ToLowerInvariant();
74+
if (mode == "sourcegeneration" || mode == "aot")
75+
{
76+
return true;
77+
}
78+
else if (mode == "reflection")
79+
{
80+
return false;
81+
}
82+
}
83+
84+
return SourceRegistrar.IsEnabled;
85+
}
86+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,11 @@ namespace
11041104
public static ..IPropertySource? GetSource( type) { }
11051105
public static void Register( type, ..IPropertySource source) { }
11061106
}
1107+
[(.Assembly, AllowMultiple=false, Inherited=false)]
1108+
public sealed class ReflectionModeAttribute :
1109+
{
1110+
public ReflectionModeAttribute() { }
1111+
}
11071112
[(.Assembly | .Class | .Method)]
11081113
public sealed class RepeatAttribute : .TUnitAttribute, .IScopedAttribute
11091114
{

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,11 @@ namespace
11041104
public static ..IPropertySource? GetSource( type) { }
11051105
public static void Register( type, ..IPropertySource source) { }
11061106
}
1107+
[(.Assembly, AllowMultiple=false, Inherited=false)]
1108+
public sealed class ReflectionModeAttribute :
1109+
{
1110+
public ReflectionModeAttribute() { }
1111+
}
11071112
[(.Assembly | .Class | .Method)]
11081113
public sealed class RepeatAttribute : .TUnitAttribute, .IScopedAttribute
11091114
{

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,11 @@ namespace
11041104
public static ..IPropertySource? GetSource( type) { }
11051105
public static void Register( type, ..IPropertySource source) { }
11061106
}
1107+
[(.Assembly, AllowMultiple=false, Inherited=false)]
1108+
public sealed class ReflectionModeAttribute :
1109+
{
1110+
public ReflectionModeAttribute() { }
1111+
}
11071112
[(.Assembly | .Class | .Method)]
11081113
public sealed class RepeatAttribute : .TUnitAttribute, .IScopedAttribute
11091114
{

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,11 @@ namespace
10631063
public static ..IPropertySource? GetSource( type) { }
10641064
public static void Register( type, ..IPropertySource source) { }
10651065
}
1066+
[(.Assembly, AllowMultiple=false, Inherited=false)]
1067+
public sealed class ReflectionModeAttribute :
1068+
{
1069+
public ReflectionModeAttribute() { }
1070+
}
10661071
[(.Assembly | .Class | .Method)]
10671072
public sealed class RepeatAttribute : .TUnitAttribute, .IScopedAttribute
10681073
{

docs/docs/execution/engine-modes.md

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,70 @@ This is the standard mode used for all builds, whether debugging, running tests,
2626

2727
## Reflection Mode
2828

29-
Reflection mode can be explicitly enabled using the `--reflection` command-line flag:
29+
Reflection mode can be explicitly enabled in several ways:
3030

3131
- **Runtime Discovery**: Tests are discovered at runtime using reflection
3232
- **Dynamic Execution**: Uses traditional reflection-based test invocation
33-
- **Compatibility**: Useful for scenarios where source generation may not be suitable
33+
- **Compatibility**: Useful for scenarios where source generation may not be suitable (e.g., bUnit with Razor components)
3434
- **Legacy Support**: Maintains compatibility with reflection-dependent test patterns
3535

36-
Enable reflection mode by running:
36+
### Enabling Reflection Mode
37+
38+
There are three ways to enable reflection mode, listed in priority order:
39+
40+
#### 1. Command-Line Flag (Highest Priority)
3741
```bash
3842
dotnet test -- --reflection
3943
```
4044

41-
Alternatively, setting the environment variable `TUNIT_EXECUTION_MODE` to `reflection` enables the reflection engine mode globally.
45+
#### 2. Assembly Attribute (Recommended for Per-Project Configuration)
46+
Add to any `.cs` file in your test project (e.g., `AssemblyInfo.cs`):
47+
```csharp
48+
using TUnit.Core;
49+
50+
[assembly: ReflectionMode]
51+
```
52+
53+
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.
54+
55+
**Example: bUnit Test Project**
56+
```csharp
57+
// Add this to enable reflection mode for your bUnit tests
58+
[assembly: ReflectionMode]
59+
60+
namespace MyApp.Tests;
61+
62+
public class CounterComponentTests : TestContext
63+
{
64+
[Test]
65+
public void CounterStartsAtZero()
66+
{
67+
// Test Razor components that are source-generated at compile time
68+
var cut = RenderComponent<Counter>();
69+
cut.Find("p").TextContent.ShouldBe("Current count: 0");
70+
}
71+
}
72+
```
73+
74+
#### 3. Environment Variable (Global Configuration)
75+
```bash
76+
# Windows
77+
set TUNIT_EXECUTION_MODE=reflection
78+
79+
# Linux/macOS
80+
export TUNIT_EXECUTION_MODE=reflection
81+
```
82+
83+
Alternatively, you can configure this in a `.runsettings` file:
84+
```xml
85+
<RunSettings>
86+
<RunConfiguration>
87+
<EnvironmentVariables>
88+
<TUNIT_EXECUTION_MODE>reflection</TUNIT_EXECUTION_MODE>
89+
</EnvironmentVariables>
90+
</RunConfiguration>
91+
</RunSettings>
92+
```
4293

4394
## Native AOT Support
4495

0 commit comments

Comments
 (0)