Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions TUnit.Core/ContextProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace TUnit.Core;
/// <summary>
/// Builder for creating and managing the context hierarchy with proper parent-child relationships and singleton behavior
/// </summary>
public class ContextProvider(IServiceProvider serviceProvider, string testSessionId, string? testFilter) : IContextProvider
internal class ContextProvider(IServiceProvider serviceProvider, string testSessionId, string? testFilter) : IContextProvider
{
private readonly ConcurrentDictionary<Assembly, AssemblyHookContext> _assemblyContexts = new();
private readonly ConcurrentDictionary<Type, ClassHookContext> _classContexts = new();
Expand Down Expand Up @@ -81,15 +81,20 @@ public ClassHookContext GetOrCreateClassContext(
/// Creates a test context with proper parent hierarchy
/// </summary>
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);

Expand Down
8 changes: 5 additions & 3 deletions TUnit.Core/Services/IContextProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace TUnit.Core.Services;
/// <summary>
/// Interface for building and managing context hierarchies
/// </summary>
public interface IContextProvider
internal interface IContextProvider
{
/// <summary>
/// Gets or creates the discovery context
Expand Down Expand Up @@ -36,12 +36,14 @@ ClassHookContext GetOrCreateClassContext(
Type classType);

/// <summary>
/// Creates a test context
/// Creates a test context. <paramref name="testDetails"/> is assigned before the context
/// becomes observable via <see cref="ClassHookContext.Tests"/> so hooks never see a
/// partially-built context.
/// </summary>
TestContext CreateTestContext(
string testName,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
Type classType,
TestBuilderContext testBuilderContext,
TestDetails testDetails,
CancellationToken cancellationToken);
}
8 changes: 2 additions & 6 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1078,13 +1078,11 @@ private async ValueTask<TestContext> CreateTestContextAsync(string testId, TestM
};

var context = _contextProvider.CreateTestContext(
metadata.TestName,
metadata.TestClassType,
testBuilderContext,
testDetails,
CancellationToken.None);

context.Metadata.TestDetails = testDetails;

return context;
}

Expand Down Expand Up @@ -1174,16 +1172,14 @@ private async Task<Attribute[]> 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;
}

Expand Down
18 changes: 4 additions & 14 deletions TUnit.Engine/Building/TestBuilderPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,14 +265,11 @@ private async Task<AbstractExecutableTest[]> 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)
{
Expand Down Expand Up @@ -392,14 +389,11 @@ private async IAsyncEnumerable<AbstractExecutableTest> 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)
{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) { }
Expand Down Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) { }
Expand Down Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) { }
Expand Down Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) { }
Expand Down Expand Up @@ -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; }
Expand Down
76 changes: 76 additions & 0 deletions TUnit.UnitTests/ContextProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
namespace TUnit.UnitTests;

/// <summary>
/// Regression tests for https://github.com/thomhurst/TUnit/issues/6180 —
/// a partially-built <see cref="TestContext"/> (with <c>TestDetails == null</c>) must never
/// be observable via <see cref="ClassHookContext.Tests"/>, otherwise AfterEvery(Class) hooks
/// running concurrently with dynamic test registration NRE on <c>test.Metadata.TestDetails</c>.
/// </summary>
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<Type, IReadOnlyList<Attribute>>()
};

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;
}
}
2 changes: 1 addition & 1 deletion TUnit.UnitTests/SessionActivityLifecycleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading