diff --git a/TUnit.Core/ContextProvider.cs b/TUnit.Core/ContextProvider.cs
index b81cd12a38..a34aff4e85 100644
--- a/TUnit.Core/ContextProvider.cs
+++ b/TUnit.Core/ContextProvider.cs
@@ -8,7 +8,7 @@ namespace TUnit.Core;
///
/// Builder for creating and managing the context hierarchy with proper parent-child relationships and singleton behavior
///
-public class ContextProvider(IServiceProvider serviceProvider, string testSessionId, string? testFilter) : IContextProvider
+internal class ContextProvider(IServiceProvider serviceProvider, string testSessionId, string? testFilter) : IContextProvider
{
private readonly ConcurrentDictionary _assemblyContexts = new();
private readonly ConcurrentDictionary _classContexts = new();
@@ -81,15 +81,20 @@ public ClassHookContext GetOrCreateClassContext(
/// Creates a test context with proper parent hierarchy
///
public TestContext CreateTestContext(
- string testName,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
Type classType,
TestBuilderContext testBuilderContext,
+ TestDetails testDetails,
CancellationToken cancellationToken)
{
var classContext = GetOrCreateClassContext(classType);
- var testContext = new TestContext(testName, serviceProvider, classContext, testBuilderContext, cancellationToken);
+ var testContext = new TestContext(testDetails.TestName, serviceProvider, classContext, testBuilderContext, cancellationToken)
+ {
+ // Must be assigned before AddTest publishes the context via ClassHookContext.Tests —
+ // AfterEvery(Class) hooks can iterate Tests while sibling dynamic tests are still being built.
+ TestDetails = testDetails,
+ };
classContext.AddTest(testContext);
diff --git a/TUnit.Core/Services/IContextProvider.cs b/TUnit.Core/Services/IContextProvider.cs
index 085182b417..512d7bc7c0 100644
--- a/TUnit.Core/Services/IContextProvider.cs
+++ b/TUnit.Core/Services/IContextProvider.cs
@@ -6,7 +6,7 @@ namespace TUnit.Core.Services;
///
/// Interface for building and managing context hierarchies
///
-public interface IContextProvider
+internal interface IContextProvider
{
///
/// Gets or creates the discovery context
@@ -36,12 +36,14 @@ ClassHookContext GetOrCreateClassContext(
Type classType);
///
- /// Creates a test context
+ /// Creates a test context. is assigned before the context
+ /// becomes observable via so hooks never see a
+ /// partially-built context.
///
TestContext CreateTestContext(
- string testName,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
Type classType,
TestBuilderContext testBuilderContext,
+ TestDetails testDetails,
CancellationToken cancellationToken);
}
diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs
index 42cf5a438b..8a9847b0a8 100644
--- a/TUnit.Engine/Building/TestBuilder.cs
+++ b/TUnit.Engine/Building/TestBuilder.cs
@@ -1078,13 +1078,11 @@ private async ValueTask CreateTestContextAsync(string testId, TestM
};
var context = _contextProvider.CreateTestContext(
- metadata.TestName,
metadata.TestClassType,
testBuilderContext,
+ testDetails,
CancellationToken.None);
- context.Metadata.TestDetails = testDetails;
-
return context;
}
@@ -1174,16 +1172,14 @@ private async Task InitializeAttributesAsync(Attribute[] attributes
private TestContext CreateFailedTestContext(TestMetadata metadata, TestDetails testDetails)
{
var context = _contextProvider.CreateTestContext(
- metadata.TestName,
metadata.TestClassType,
new TestBuilderContext
{
TestMetadata = metadata.MethodMetadata
},
+ testDetails,
CancellationToken.None);
- context.Metadata.TestDetails = testDetails;
-
return context;
}
diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs
index 493aec53b5..1879ed045e 100644
--- a/TUnit.Engine/Building/TestBuilderPipeline.cs
+++ b/TUnit.Engine/Building/TestBuilderPipeline.cs
@@ -265,14 +265,11 @@ private async Task GenerateDynamicTests(TestMetadata m
var testBuilderContext = CreateTestBuilderContext(metadata);
var context = _contextProvider.CreateTestContext(
- metadata.TestName,
metadata.TestClassType,
testBuilderContext,
+ testDetails,
CancellationToken.None);
- // Set the TestDetails on the context
- context.Metadata.TestDetails = testDetails;
-
// Set custom display name for dynamic tests if specified
if (dynamicTestMetadata?.DisplayName != null)
{
@@ -392,14 +389,11 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad
};
var context = _contextProvider.CreateTestContext(
- resolvedMetadata.TestName,
resolvedMetadata.TestClassType,
CreateTestBuilderContext(resolvedMetadata),
+ testDetails,
CancellationToken.None);
- // Set the TestDetails on the context
- context.Metadata.TestDetails = testDetails;
-
// Set custom display name for dynamic tests if specified
if (dynamicMetadata.DisplayName != null)
{
@@ -473,13 +467,11 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada
};
var context = _contextProvider.CreateTestContext(
- metadata.TestName,
metadata.TestClassType,
CreateTestBuilderContext(metadata),
+ testDetails,
CancellationToken.None);
- context.Metadata.TestDetails = testDetails;
-
var now = DateTimeOffset.UtcNow;
return new FailedExecutableTest(exception)
@@ -528,13 +520,11 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet
};
var context = _contextProvider.CreateTestContext(
- metadata.TestName,
metadata.TestClassType,
CreateTestBuilderContext(metadata),
+ testDetails,
CancellationToken.None);
- context.Metadata.TestDetails = testDetails;
-
var now = DateTimeOffset.UtcNow;
return new FailedExecutableTest(exception)
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 0c948f5566..486d9db7cb 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
@@ -423,18 +423,6 @@ namespace
public virtual string GetStandardOutput() { }
public void RestoreExecutionContext() { }
}
- public class ContextProvider : .
- {
- public ContextProvider( serviceProvider, string testSessionId, string? testFilter) { }
- public .BeforeTestDiscoveryContext BeforeTestDiscoveryContext { get; }
- public .GlobalContext GlobalContext { get; }
- public .TestDiscoveryContext TestDiscoveryContext { get; }
- public .TestSessionContext TestSessionContext { get; }
- public .TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { }
- public .AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly) { }
- [.("Trimming", "IL2111", Justification="Type parameter is annotated at the method boundary.")]
- public .ClassHookContext GetOrCreateClassContext([.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType) { }
- }
public class CultureExecutor : .DedicatedThreadExecutor
{
public CultureExecutor(.CultureInfo cultureInfo) { }
@@ -2898,15 +2886,6 @@ namespace .Services
public [] ResolveGenericClassArguments([.(..PublicConstructors)] genericTypeDefinition, object?[] constructorArguments) { }
public [] ResolveGenericMethodArguments(.MethodInfo genericMethodDefinition, object?[] runtimeArguments) { }
}
- public interface IContextProvider
- {
- .BeforeTestDiscoveryContext BeforeTestDiscoveryContext { get; }
- .TestDiscoveryContext TestDiscoveryContext { get; }
- .TestSessionContext TestSessionContext { get; }
- .TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken);
- .AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly);
- .ClassHookContext GetOrCreateClassContext([.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType);
- }
public static class ModeDetector
{
public static bool IsSourceGenerationAvailable { get; }
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 c63d5e723c..7ac15f9cba 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
@@ -423,18 +423,6 @@ namespace
public virtual string GetStandardOutput() { }
public void RestoreExecutionContext() { }
}
- public class ContextProvider : .
- {
- public ContextProvider( serviceProvider, string testSessionId, string? testFilter) { }
- public .BeforeTestDiscoveryContext BeforeTestDiscoveryContext { get; }
- public .GlobalContext GlobalContext { get; }
- public .TestDiscoveryContext TestDiscoveryContext { get; }
- public .TestSessionContext TestSessionContext { get; }
- public .TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { }
- public .AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly) { }
- [.("Trimming", "IL2111", Justification="Type parameter is annotated at the method boundary.")]
- public .ClassHookContext GetOrCreateClassContext([.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType) { }
- }
public class CultureExecutor : .DedicatedThreadExecutor
{
public CultureExecutor(.CultureInfo cultureInfo) { }
@@ -2898,15 +2886,6 @@ namespace .Services
public [] ResolveGenericClassArguments([.(..PublicConstructors)] genericTypeDefinition, object?[] constructorArguments) { }
public [] ResolveGenericMethodArguments(.MethodInfo genericMethodDefinition, object?[] runtimeArguments) { }
}
- public interface IContextProvider
- {
- .BeforeTestDiscoveryContext BeforeTestDiscoveryContext { get; }
- .TestDiscoveryContext TestDiscoveryContext { get; }
- .TestSessionContext TestSessionContext { get; }
- .TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken);
- .AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly);
- .ClassHookContext GetOrCreateClassContext([.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType);
- }
public static class ModeDetector
{
public static bool IsSourceGenerationAvailable { get; }
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 61ab10d179..7257c82ca6 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
@@ -423,18 +423,6 @@ namespace
public virtual string GetStandardOutput() { }
public void RestoreExecutionContext() { }
}
- public class ContextProvider : .
- {
- public ContextProvider( serviceProvider, string testSessionId, string? testFilter) { }
- public .BeforeTestDiscoveryContext BeforeTestDiscoveryContext { get; }
- public .GlobalContext GlobalContext { get; }
- public .TestDiscoveryContext TestDiscoveryContext { get; }
- public .TestSessionContext TestSessionContext { get; }
- public .TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { }
- public .AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly) { }
- [.("Trimming", "IL2111", Justification="Type parameter is annotated at the method boundary.")]
- public .ClassHookContext GetOrCreateClassContext([.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType) { }
- }
public class CultureExecutor : .DedicatedThreadExecutor
{
public CultureExecutor(.CultureInfo cultureInfo) { }
@@ -2898,15 +2886,6 @@ namespace .Services
public [] ResolveGenericClassArguments([.(..PublicConstructors)] genericTypeDefinition, object?[] constructorArguments) { }
public [] ResolveGenericMethodArguments(.MethodInfo genericMethodDefinition, object?[] runtimeArguments) { }
}
- public interface IContextProvider
- {
- .BeforeTestDiscoveryContext BeforeTestDiscoveryContext { get; }
- .TestDiscoveryContext TestDiscoveryContext { get; }
- .TestSessionContext TestSessionContext { get; }
- .TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken);
- .AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly);
- .ClassHookContext GetOrCreateClassContext([.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType);
- }
public static class ModeDetector
{
public static bool IsSourceGenerationAvailable { get; }
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 0da677c22b..6a7b189619 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
@@ -403,17 +403,6 @@ namespace
public virtual string GetStandardOutput() { }
public void RestoreExecutionContext() { }
}
- public class ContextProvider : .
- {
- public ContextProvider( serviceProvider, string testSessionId, string? testFilter) { }
- public .BeforeTestDiscoveryContext BeforeTestDiscoveryContext { get; }
- public .GlobalContext GlobalContext { get; }
- public .TestDiscoveryContext TestDiscoveryContext { get; }
- public .TestSessionContext TestSessionContext { get; }
- public .TestContext CreateTestContext(string testName, classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { }
- public .AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly) { }
- public .ClassHookContext GetOrCreateClassContext( classType) { }
- }
public class CultureExecutor : .DedicatedThreadExecutor
{
public CultureExecutor(.CultureInfo cultureInfo) { }
@@ -2819,15 +2808,6 @@ namespace .Services
public [] ResolveGenericClassArguments( genericTypeDefinition, object?[] constructorArguments) { }
public [] ResolveGenericMethodArguments(.MethodInfo genericMethodDefinition, object?[] runtimeArguments) { }
}
- public interface IContextProvider
- {
- .BeforeTestDiscoveryContext BeforeTestDiscoveryContext { get; }
- .TestDiscoveryContext TestDiscoveryContext { get; }
- .TestSessionContext TestSessionContext { get; }
- .TestContext CreateTestContext(string testName, classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken);
- .AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly);
- .ClassHookContext GetOrCreateClassContext( classType);
- }
public static class ModeDetector
{
public static bool IsSourceGenerationAvailable { get; }
diff --git a/TUnit.UnitTests/ContextProviderTests.cs b/TUnit.UnitTests/ContextProviderTests.cs
new file mode 100644
index 0000000000..28d4558f93
--- /dev/null
+++ b/TUnit.UnitTests/ContextProviderTests.cs
@@ -0,0 +1,76 @@
+namespace TUnit.UnitTests;
+
+///
+/// Regression tests for https://github.com/thomhurst/TUnit/issues/6180 —
+/// a partially-built (with TestDetails == null) must never
+/// be observable via , otherwise AfterEvery(Class) hooks
+/// running concurrently with dynamic test registration NRE on test.Metadata.TestDetails.
+///
+public class ContextProviderTests
+{
+ [Test]
+ public async Task CreateTestContext_PublishesContextWithTestDetailsAlreadyAssigned()
+ {
+ var provider = new ContextProvider(new EmptyServiceProvider(), Guid.NewGuid().ToString(), testFilter: null);
+
+ var classMetadata = new ClassMetadata
+ {
+ Type = typeof(DummyTestClass),
+ TypeInfo = new ConcreteType(typeof(DummyTestClass)),
+ Name = nameof(DummyTestClass),
+ Namespace = typeof(DummyTestClass).Namespace ?? string.Empty,
+ Assembly = new AssemblyMetadata
+ {
+ Name = typeof(DummyTestClass).Assembly.GetName().Name ?? string.Empty
+ },
+ Parent = null,
+ Parameters = [],
+ Properties = []
+ };
+
+ var methodMetadata = MethodMetadataFactory.Create(
+ nameof(DummyTestClass.SomeTest),
+ typeof(DummyTestClass),
+ typeof(Task),
+ classMetadata);
+
+ var testDetails = new TestDetails([])
+ {
+ TestId = "Test:0",
+ TestName = nameof(DummyTestClass.SomeTest),
+ ClassType = typeof(DummyTestClass),
+ MethodName = nameof(DummyTestClass.SomeTest),
+ ClassInstance = PlaceholderInstance.Instance,
+ TestMethodArguments = [],
+ TestClassArguments = [],
+ MethodMetadata = methodMetadata,
+ ReturnType = typeof(Task),
+ AttributesByType = new Dictionary>()
+ };
+
+ var context = provider.CreateTestContext(
+ typeof(DummyTestClass),
+ new TestBuilderContext { TestMetadata = methodMetadata },
+ testDetails,
+ CancellationToken.None);
+
+ var classContext = provider.GetOrCreateClassContext(typeof(DummyTestClass));
+ var publishedContext = classContext.Tests.Single();
+
+ // The contract callers (and AfterEvery(Class) hooks) rely on: by the time a context is
+ // visible in ClassHookContext.Tests, its TestDetails is set — no post-hoc assignment.
+ await Assert.That(publishedContext).IsSameReferenceAs(context);
+ await Assert.That(publishedContext.TestDetails).IsNotNull();
+ await Assert.That(publishedContext.Metadata.TestDetails).IsSameReferenceAs(testDetails);
+ }
+
+ private sealed class DummyTestClass
+ {
+ public Task SomeTest() => Task.CompletedTask;
+ }
+
+ private sealed class EmptyServiceProvider : IServiceProvider
+ {
+ public object? GetService(Type serviceType) => null;
+ }
+}
diff --git a/TUnit.UnitTests/SessionActivityLifecycleTests.cs b/TUnit.UnitTests/SessionActivityLifecycleTests.cs
index 517a41b18f..6de4255305 100644
--- a/TUnit.UnitTests/SessionActivityLifecycleTests.cs
+++ b/TUnit.UnitTests/SessionActivityLifecycleTests.cs
@@ -309,12 +309,12 @@ public ClassHookContext GetOrCreateClassContext(
throw new NotSupportedException();
public TestContext CreateTestContext(
- string testName,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors |
DynamicallyAccessedMemberTypes.PublicProperties |
DynamicallyAccessedMemberTypes.PublicMethods)]
Type classType,
TestBuilderContext testBuilderContext,
+ TestDetails testDetails,
CancellationToken cancellationToken) =>
throw new NotSupportedException();
}