diff --git a/TUnit.Analyzers/GlobalTestHooksAnalyzer.cs b/TUnit.Analyzers/GlobalTestHooksAnalyzer.cs
index 285659f750..82060a783d 100644
--- a/TUnit.Analyzers/GlobalTestHooksAnalyzer.cs
+++ b/TUnit.Analyzers/GlobalTestHooksAnalyzer.cs
@@ -35,7 +35,7 @@ private void AnalyzeSymbol(SymbolAnalysisContext context)
var attributes = methodSymbol.GetAttributes();
var globalHooks = attributes
- .Where(x => IsGlobalHook(context, x, out _))
+ .Where(x => IsGlobalHook(context, x, out _, out _))
.ToList();
if (!globalHooks.Any())
@@ -66,55 +66,38 @@ private void AnalyzeSymbol(SymbolAnalysisContext context)
foreach (var attributeData in globalHooks)
{
- IsGlobalHook(context, attributeData, out var hookLevel);
+ IsGlobalHook(context, attributeData, out var hookLevel, out var hookType);
- var contextType = hookLevel switch
- {
- HookLevel.Test => WellKnown.AttributeFullyQualifiedClasses.TestContext.WithGlobalPrefix,
- HookLevel.Class => WellKnown.AttributeFullyQualifiedClasses.ClassHookContext.WithGlobalPrefix,
- HookLevel.Assembly => WellKnown.AttributeFullyQualifiedClasses.AssemblyHookContext.WithGlobalPrefix,
- _ => null
- };
+ var (contextType, contextTypeName) = GetExpectedContext(hookLevel, hookType);
if (contextType != null)
{
var parameterStatus = CheckHookParameters(methodSymbol, contextType);
-
+
switch (parameterStatus)
{
case HookParameterStatus.NoParameters:
- // Informational diagnostic - suggest adding context parameter
- var contextTypeName = hookLevel switch
+ // TestSession/TestDiscovery hooks rarely need their context — don't nudge.
+ // Test/Class/Assembly still get the info-level suggestion.
+ if (hookLevel is HookLevel.TestSession or HookLevel.TestDiscovery)
{
- HookLevel.Test => "TestContext",
- HookLevel.Class => "ClassHookContext",
- HookLevel.Assembly => "AssemblyHookContext",
- _ => "context"
- };
+ break;
+ }
context.ReportDiagnostic(Diagnostic.Create(
- Rules.HookContextParameterOptional,
+ Rules.HookContextParameterOptional,
methodSymbol.Locations.FirstOrDefault(),
contextTypeName));
break;
-
+
case HookParameterStatus.Valid:
- // No diagnostic needed - parameters are correct
break;
-
+
case HookParameterStatus.UnknownParameters:
- // Error diagnostic - unknown parameters
- var expectedContextTypeName = hookLevel switch
- {
- HookLevel.Test => "TestContext",
- HookLevel.Class => "ClassHookContext",
- HookLevel.Assembly => "AssemblyHookContext",
- _ => "context"
- };
- var firstBadParam = FindFirstUnknownParameter(methodSymbol, contextType!);
+ var firstBadParam = FindFirstUnknownParameter(methodSymbol, contextType);
context.ReportDiagnostic(Diagnostic.Create(
Rules.HookUnknownParameters,
firstBadParam?.Locations.FirstOrDefault() ?? methodSymbol.Locations.FirstOrDefault(),
- expectedContextTypeName));
+ contextTypeName));
break;
}
}
@@ -128,17 +111,33 @@ private void AnalyzeSymbol(SymbolAnalysisContext context)
}
}
- private static bool IsGlobalHook(SymbolAnalysisContext context, AttributeData x, [NotNullWhen(true)] out HookLevel? hookLevel)
+ private static bool IsGlobalHook(SymbolAnalysisContext context, AttributeData x, [NotNullWhen(true)] out HookLevel? hookLevel, [NotNullWhen(true)] out HookType? hookType)
{
// For standard hooks (Before/After), only Assembly, TestSession, and TestDiscovery are global
- if (x.IsStandardHook(context.Compilation, out _, out hookLevel, out _)
+ if (x.IsStandardHook(context.Compilation, out _, out hookLevel, out hookType)
&& hookLevel is HookLevel.Assembly or HookLevel.TestSession or HookLevel.TestDiscovery)
{
return true;
}
// For Every hooks (BeforeEvery/AfterEvery), all levels (Test, Class, Assembly) are considered global
- return x.IsEveryHook(context.Compilation, out _, out hookLevel, out _);
+ return x.IsEveryHook(context.Compilation, out _, out hookLevel, out hookType);
+ }
+
+ private static (string? ContextType, string? ContextTypeName) GetExpectedContext(HookLevel? hookLevel, HookType? hookType)
+ {
+ return hookLevel switch
+ {
+ HookLevel.Test => (WellKnown.AttributeFullyQualifiedClasses.TestContext.WithGlobalPrefix, "TestContext"),
+ HookLevel.Class => (WellKnown.AttributeFullyQualifiedClasses.ClassHookContext.WithGlobalPrefix, "ClassHookContext"),
+ HookLevel.Assembly => (WellKnown.AttributeFullyQualifiedClasses.AssemblyHookContext.WithGlobalPrefix, "AssemblyHookContext"),
+ HookLevel.TestSession => (WellKnown.AttributeFullyQualifiedClasses.TestSessionContext.WithGlobalPrefix, "TestSessionContext"),
+ HookLevel.TestDiscovery when hookType == HookType.Before
+ => (WellKnown.AttributeFullyQualifiedClasses.BeforeTestDiscoveryContext.WithGlobalPrefix, "BeforeTestDiscoveryContext"),
+ HookLevel.TestDiscovery
+ => (WellKnown.AttributeFullyQualifiedClasses.TestDiscoveryContext.WithGlobalPrefix, "TestDiscoveryContext"),
+ _ => (null, null)
+ };
}
private enum HookParameterStatus
diff --git a/TUnit.Aspire.Tests/TestTraceExporterTests.cs b/TUnit.Aspire.Tests/TestTraceExporterTests.cs
index 85e136a780..9f2023b9d9 100644
--- a/TUnit.Aspire.Tests/TestTraceExporterTests.cs
+++ b/TUnit.Aspire.Tests/TestTraceExporterTests.cs
@@ -41,7 +41,7 @@ public async Task AddToBuilder_ExportsTracesForRegisteredSource()
var endpoint = new Uri($"http://127.0.0.1:{server.Port}");
var builder = Sdk.CreateTracerProviderBuilder().AddSource(sourceName);
- TestTraceExporter.AddToBuilder(builder, GetCurrentSessionContext(), endpoint);
+ TestTraceExporter.AddToBuilder(builder, endpoint);
using (var activitySource = new ActivitySource(sourceName))
using (var provider = builder.Build())
@@ -65,9 +65,4 @@ public async Task GetTracesEndpoint_AppendsSignalPathWithoutDroppingBasePath()
await Assert.That(tracesEndpoint.ToString()).IsEqualTo("http://127.0.0.1:5341/ingest/otlp/v1/traces");
}
-
- private static TestSessionContext GetCurrentSessionContext()
- {
- return TestContext.Current!.ClassContext.AssemblyContext.TestSessionContext;
- }
}
diff --git a/TUnit.Aspire/AspireTelemetryHooks.cs b/TUnit.Aspire/AspireTelemetryHooks.cs
index 0802f13cba..59acdcbdd4 100644
--- a/TUnit.Aspire/AspireTelemetryHooks.cs
+++ b/TUnit.Aspire/AspireTelemetryHooks.cs
@@ -12,7 +12,7 @@ namespace TUnit.Aspire;
public static class AspireTelemetryHooks
{
[Before(HookType.TestDiscovery, Order = AutoStart.AutoStartOrder - 1)]
- public static void RegisterAspireExporter(TestSessionContext context)
+ public static void RegisterAspireExporter()
{
var endpoint = TestTraceExporter.TryGetDashboardEndpoint();
if (endpoint is null)
@@ -21,6 +21,6 @@ public static void RegisterAspireExporter(TestSessionContext context)
}
TUnitOpenTelemetry.Configure(builder =>
- TestTraceExporter.AddToBuilder(builder, context, endpoint));
+ TestTraceExporter.AddToBuilder(builder, endpoint));
}
}
diff --git a/TUnit.Aspire/Telemetry/TestTraceExporter.cs b/TUnit.Aspire/Telemetry/TestTraceExporter.cs
index 7d64697bec..cbf93e8897 100644
--- a/TUnit.Aspire/Telemetry/TestTraceExporter.cs
+++ b/TUnit.Aspire/Telemetry/TestTraceExporter.cs
@@ -2,7 +2,6 @@
using OpenTelemetry.Exporter;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
-using TUnit.Core;
namespace TUnit.Aspire.Telemetry;
@@ -14,15 +13,16 @@ namespace TUnit.Aspire.Telemetry;
internal static class TestTraceExporter
{
private const string DashboardOtlpEndpointEnvVar = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL";
+ private const string ServiceNameEnvVar = "OTEL_SERVICE_NAME";
private const string DefaultServiceName = "TUnit.Tests";
internal static Uri? TryGetDashboardEndpoint()
=> TryParseDashboardEndpoint(Environment.GetEnvironmentVariable(DashboardOtlpEndpointEnvVar));
- internal static void AddToBuilder(TracerProviderBuilder builder, TestSessionContext context, Uri endpoint)
+ internal static void AddToBuilder(TracerProviderBuilder builder, Uri endpoint)
{
builder
- .SetResourceBuilder(CreateResourceBuilder(context))
+ .SetResourceBuilder(CreateResourceBuilder())
.AddOtlpExporter(options =>
{
options.Endpoint = GetTracesEndpoint(endpoint);
@@ -57,26 +57,24 @@ internal static Uri GetTracesEndpoint(Uri endpoint)
return builder.Uri;
}
- private static ResourceBuilder CreateResourceBuilder(TestSessionContext context)
+ private static ResourceBuilder CreateResourceBuilder()
{
- var serviceName = GetServiceName(context);
+ var serviceName = GetServiceName();
var serviceVersion = typeof(TestTraceExporter).Assembly.GetName().Version?.ToString();
return ResourceBuilder.CreateDefault()
.AddService(serviceName: serviceName, serviceVersion: serviceVersion);
}
- private static string GetServiceName(TestSessionContext context)
+ private static string GetServiceName()
{
- var assemblyNames = context.Assemblies
- .Select(static assemblyContext => assemblyContext.Assembly.GetName().Name)
- .Where(static name => !string.IsNullOrWhiteSpace(name))
- .Distinct(StringComparer.Ordinal)
- .Take(2)
- .ToArray();
+ var fromEnv = Environment.GetEnvironmentVariable(ServiceNameEnvVar);
+ if (!string.IsNullOrWhiteSpace(fromEnv))
+ {
+ return fromEnv!;
+ }
- return assemblyNames.Length == 1
- ? assemblyNames[0]!
- : DefaultServiceName;
+ return System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name
+ ?? DefaultServiceName;
}
}
diff --git a/TUnit.Core.SourceGenerator.Tests/TestDiscoveryHookTests.Test.verified.txt b/TUnit.Core.SourceGenerator.Tests/TestDiscoveryHookTests.Test.verified.txt
index 5f282702bb..ab6e001e8d 100644
--- a/TUnit.Core.SourceGenerator.Tests/TestDiscoveryHookTests.Test.verified.txt
+++ b/TUnit.Core.SourceGenerator.Tests/TestDiscoveryHookTests.Test.verified.txt
@@ -1 +1,87 @@
-
\ No newline at end of file
+//
+#pragma warning disable
+
+#nullable enable
+namespace TUnit.Generated;
+[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute]
+[global::System.CodeDom.Compiler.GeneratedCode("TUnit", "VERSION_SCRUBBED")]
+internal static class TUnit_TestProject_TestDiscoveryHookTests__TestSource
+{
+ private static readonly global::TUnit.Core.ClassMetadata __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd("TestsBase`1:global::TUnit.TestProject.TestDiscoveryHookTests", new global::TUnit.Core.ClassMetadata
+ {
+ Type = typeof(global::TUnit.TestProject.TestDiscoveryHookTests),
+ TypeInfo = new global::TUnit.Core.ConcreteType(typeof(global::TUnit.TestProject.TestDiscoveryHookTests)),
+ Name = "TestDiscoveryHookTests",
+ Namespace = "TUnit.TestProject",
+ Assembly = global::TUnit.Core.AssemblyMetadata.GetOrAdd("TestsBase`1", "TestsBase`1"),
+ Parameters = global::System.Array.Empty(),
+ Properties = global::System.Array.Empty(),
+ Parent = null
+ });
+ private static readonly global::System.Type __classType = typeof(global::TUnit.TestProject.TestDiscoveryHookTests);
+ private static readonly global::TUnit.Core.MethodMetadata __mm_0 = global::TUnit.Core.MethodMetadataFactory.Create("CustomNamedDiscoveryHooksWereRegisteredAndRan", __classType, typeof(global::System.Threading.Tasks.Task), __classMetadata);
+ private static global::TUnit.TestProject.TestDiscoveryHookTests __CreateInstance(global::System.Type[] typeArgs, object?[] args)
+ {
+ return new global::TUnit.TestProject.TestDiscoveryHookTests();
+ }
+ private static global::System.Threading.Tasks.ValueTask __Invoke(global::TUnit.TestProject.TestDiscoveryHookTests instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken)
+ {
+ switch (methodIndex)
+ {
+ case 0:
+ {
+ try
+ {
+ return new global::System.Threading.Tasks.ValueTask(instance.CustomNamedDiscoveryHooksWereRegisteredAndRan());
+ }
+ catch (global::System.Exception ex)
+ {
+ return new global::System.Threading.Tasks.ValueTask(global::System.Threading.Tasks.Task.FromException(ex));
+ }
+ }
+ default:
+ throw new global::System.ArgumentOutOfRangeException(nameof(methodIndex));
+ }
+ }
+ private static global::System.Attribute[] __Attributes(int groupIndex)
+ {
+ switch (groupIndex)
+ {
+ case 0:
+ {
+ return
+ [
+ new global::TUnit.Core.TestAttribute(),
+ new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass)
+ ];
+ }
+ default:
+ throw new global::System.ArgumentOutOfRangeException(nameof(groupIndex));
+ }
+ }
+ public static readonly global::TUnit.Core.TestEntry[] Entries = new global::TUnit.Core.TestEntry[]
+ {
+ new global::TUnit.Core.TestEntry
+ {
+ MethodName = "CustomNamedDiscoveryHooksWereRegisteredAndRan",
+ FullyQualifiedName = "TUnit.TestProject.TestDiscoveryHookTests.CustomNamedDiscoveryHooksWereRegisteredAndRan",
+ FilePath = @"",
+ LineNumber = 35,
+ Categories = global::System.Array.Empty(),
+ Properties = global::System.Array.Empty(),
+ HasDataSource = false,
+ RepeatCount = 0,
+ DependsOn = global::System.Array.Empty(),
+ MethodMetadata = __mm_0,
+ CreateInstance = __CreateInstance,
+ InvokeBody = __Invoke,
+ MethodIndex = 0,
+ CreateAttributes = __Attributes,
+ AttributeGroupIndex = 0,
+ },
+ };
+}
+internal static partial class TUnit_TestRegistration
+{
+ static readonly int _r_TUnit_TestProject_TestDiscoveryHookTests__TestSource = global::TUnit.Core.SourceRegistrar.RegisterEntries(static () => TUnit_TestProject_TestDiscoveryHookTests__TestSource.Entries);
+}
diff --git a/TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs b/TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs
index dd58f13d42..26ffd36243 100644
--- a/TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs
+++ b/TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs
@@ -114,7 +114,7 @@ private static void GenerateHookFile(SourceProductionContext context, (HookModel
var hookAttribute = context.Attributes[0];
var hookType = GetHookType(hookAttribute);
- if (!IsValidHookMethod(methodSymbol, hookType))
+ if (!IsValidHookMethod(methodSymbol, hookType, hookKind))
{
return null;
}
@@ -244,7 +244,7 @@ private static void AppendInlineMultilineText(ICodeWriter writer, string text)
}
}
- private static bool IsValidHookMethod(IMethodSymbol method, string hookType)
+ private static bool IsValidHookMethod(IMethodSymbol method, string hookType, string hookKind)
{
var returnType = method.ReturnType;
if (returnType.SpecialType != SpecialType.System_Void &&
@@ -271,21 +271,17 @@ private static bool IsValidHookMethod(IMethodSymbol method, string hookType)
return method.Parameters.Length == 1;
}
+ var isBefore = hookKind is "Before" or "BeforeEvery";
var expectedContextType = hookType switch
{
"Test" => "TestContext",
"Class" => "ClassHookContext",
"Assembly" => "AssemblyHookContext",
"TestSession" => "TestSessionContext",
- "TestDiscovery" => "TestDiscoveryContext",
+ "TestDiscovery" => isBefore ? "BeforeTestDiscoveryContext" : "TestDiscoveryContext",
_ => null
};
- if (hookType == "TestDiscovery" && method.Name.Contains("Before"))
- {
- expectedContextType = "BeforeTestDiscoveryContext";
- }
-
if (expectedContextType == null || firstParamNamespace != "TUnit.Core" || firstParamTypeName != expectedContextType)
{
return false;
diff --git a/TUnit.TestProject/TestDiscoveryHookTests.cs b/TUnit.TestProject/TestDiscoveryHookTests.cs
index 899a68a61c..2775c3a0c6 100644
--- a/TUnit.TestProject/TestDiscoveryHookTests.cs
+++ b/TUnit.TestProject/TestDiscoveryHookTests.cs
@@ -1,7 +1,13 @@
-namespace TUnit.TestProject;
+using TUnit.TestProject.Attributes;
+namespace TUnit.TestProject;
+
+[EngineTest(ExpectedResult.Pass)]
public class TestDiscoveryHookTests
{
+ public static int CustomNamedBeforeDiscoveryCalls;
+ public static int CustomNamedAfterDiscoveryCalls;
+
[BeforeEvery(TestDiscovery, Order = 5)]
public static void BeforeDiscovery()
{
@@ -11,4 +17,24 @@ public static void BeforeDiscovery()
public static void AfterDiscovery()
{
}
+
+ // Regression: method name without "Before"/"After" must still resolve correct
+ // context type from the [Before]/[After] attribute kind, not from the method name.
+ [Before(TestDiscovery)]
+ public static void CustomNamedDiscoveryHook(BeforeTestDiscoveryContext context)
+ {
+ System.Threading.Interlocked.Increment(ref CustomNamedBeforeDiscoveryCalls);
+ }
+
+ [After(TestDiscovery)]
+ public static void AnotherCustomNamedDiscoveryHook(TestDiscoveryContext context)
+ {
+ System.Threading.Interlocked.Increment(ref CustomNamedAfterDiscoveryCalls);
+ }
+
+ [Test]
+ public async Task CustomNamedDiscoveryHooksWereRegisteredAndRan()
+ {
+ await Assert.That(CustomNamedBeforeDiscoveryCalls).IsGreaterThan(0);
+ }
}