Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 7 additions & 1 deletion TUnit.Core/ContextProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,17 @@ public TestContext CreateTestContext(
[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(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
5 changes: 4 additions & 1 deletion TUnit.Core/Services/IContextProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,15 @@ 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);
}
6 changes: 2 additions & 4 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1081,10 +1081,9 @@ private async ValueTask<TestContext> CreateTestContextAsync(string testId, TestM
metadata.TestName,
metadata.TestClassType,
testBuilderContext,
testDetails,
CancellationToken.None);

context.Metadata.TestDetails = testDetails;

return context;
}

Expand Down Expand Up @@ -1180,10 +1179,9 @@ private TestContext CreateFailedTestContext(TestMetadata metadata, TestDetails t
{
TestMetadata = metadata.MethodMetadata
},
testDetails,
CancellationToken.None);

context.Metadata.TestDetails = testDetails;

return context;
}

Expand Down
14 changes: 4 additions & 10 deletions TUnit.Engine/Building/TestBuilderPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,9 @@ private async Task<AbstractExecutableTest[]> GenerateDynamicTests(TestMetadata m
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 @@ -395,11 +393,9 @@ private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetad
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 @@ -476,10 +472,9 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada
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 @@ -531,10 +526,9 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet
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 @@ -430,7 +430,7 @@ namespace
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 .TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .TestDetails testDetails, .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) { }
Expand Down Expand Up @@ -2903,7 +2903,7 @@ namespace .Services
.BeforeTestDiscoveryContext BeforeTestDiscoveryContext { get; }
.TestDiscoveryContext TestDiscoveryContext { get; }
.TestSessionContext TestSessionContext { get; }
.TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken);
.TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .TestDetails testDetails, .CancellationToken cancellationToken);
.AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly);
.ClassHookContext GetOrCreateClassContext([.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ namespace
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 .TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .TestDetails testDetails, .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) { }
Expand Down Expand Up @@ -2903,7 +2903,7 @@ namespace .Services
.BeforeTestDiscoveryContext BeforeTestDiscoveryContext { get; }
.TestDiscoveryContext TestDiscoveryContext { get; }
.TestSessionContext TestSessionContext { get; }
.TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken);
.TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .TestDetails testDetails, .CancellationToken cancellationToken);
.AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly);
.ClassHookContext GetOrCreateClassContext([.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ namespace
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 .TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .TestDetails testDetails, .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) { }
Expand Down Expand Up @@ -2903,7 +2903,7 @@ namespace .Services
.BeforeTestDiscoveryContext BeforeTestDiscoveryContext { get; }
.TestDiscoveryContext TestDiscoveryContext { get; }
.TestSessionContext TestSessionContext { get; }
.TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken);
.TestContext CreateTestContext(string testName, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType, .TestBuilderContext testBuilderContext, .TestDetails testDetails, .CancellationToken cancellationToken);
.AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly);
.ClassHookContext GetOrCreateClassContext([.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..PublicMethods | ..PublicProperties)] classType);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ namespace
public .GlobalContext GlobalContext { get; }
public .TestDiscoveryContext TestDiscoveryContext { get; }
public .TestSessionContext TestSessionContext { get; }
public .TestContext CreateTestContext(string testName, classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { }
public .TestContext CreateTestContext(string testName, classType, .TestBuilderContext testBuilderContext, .TestDetails testDetails, .CancellationToken cancellationToken) { }
public .AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly) { }
public .ClassHookContext GetOrCreateClassContext( classType) { }
}
Expand Down Expand Up @@ -2824,7 +2824,7 @@ namespace .Services
.BeforeTestDiscoveryContext BeforeTestDiscoveryContext { get; }
.TestDiscoveryContext TestDiscoveryContext { get; }
.TestSessionContext TestSessionContext { get; }
.TestContext CreateTestContext(string testName, classType, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken);
.TestContext CreateTestContext(string testName, classType, .TestBuilderContext testBuilderContext, .TestDetails testDetails, .CancellationToken cancellationToken);
.AssemblyHookContext GetOrCreateAssemblyContext(.Assembly assembly);
.ClassHookContext GetOrCreateClassContext( classType);
}
Expand Down
77 changes: 77 additions & 0 deletions TUnit.UnitTests/ContextProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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(
nameof(DummyTestClass.SomeTest),
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;
}
}
1 change: 1 addition & 0 deletions TUnit.UnitTests/SessionActivityLifecycleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ public TestContext CreateTestContext(
DynamicallyAccessedMemberTypes.PublicMethods)]
Type classType,
TestBuilderContext testBuilderContext,
TestDetails testDetails,
CancellationToken cancellationToken) =>
throw new NotSupportedException();
}
Expand Down
Loading