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(); }