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
23 changes: 0 additions & 23 deletions TUnit.Core/Attributes/TestData/ClassDataSources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,29 +135,6 @@ private static object Create([DynamicallyAccessedMembers(DynamicallyAccessedMemb
ObjectTracker.TrackObject(trackerEvents2, instance);
}

// Initialize any data source properties on the created instance
if (dataGeneratorMetadata.TestInformation != null)
{
var initTask = Helpers.DataSourceHelpers.InitializeDataSourcePropertiesAsync(
instance,
dataGeneratorMetadata.TestInformation,
dataGeneratorMetadata.TestSessionId);

// We need to block here since this method isn't async
initTask.GetAwaiter().GetResult();

// Also try PropertyInjectionService for properties that have data source attributes
// This handles cases where the type doesn't have a generated initializer
var objectBag = dataGeneratorMetadata.TestBuilderContext?.Current?.ObjectBag ?? new Dictionary<string, object?>();
var events = dataGeneratorMetadata.TestBuilderContext?.Current?.Events;
var injectionTask = PropertyInjectionService.InjectPropertiesIntoObjectAsync(
instance,
objectBag,
dataGeneratorMetadata.TestInformation,
events);
injectionTask.GetAwaiter().GetResult();
}

return instance;
}
catch (TargetInvocationException targetInvocationException)
Expand Down
5 changes: 5 additions & 0 deletions TUnit.Core/ExecutableTestCreationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ public sealed class ExecutableTestCreationContext
public required object?[] ClassArguments { get; init; }
public required TestContext Context { get; init; }

/// <summary>
/// Factory function to create the test class instance lazily during execution.
/// </summary>
public Func<Task<object>>? TestClassInstanceFactory { get; init; }

/// <summary>
/// Resolved generic type arguments for the test method.
/// Will be Type.EmptyTypes if the method is not generic.
Expand Down
6 changes: 6 additions & 0 deletions TUnit.Core/Hooks/InstanceHookMethod.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ public ValueTask ExecuteAsync(TestContext context, CancellationToken cancellatio
return new ValueTask();
}

// If the instance is still a placeholder, we can't execute instance hooks
if (context.TestDetails.ClassInstance is PlaceholderInstance)
{
throw new InvalidOperationException($"Cannot execute instance hook {Name} because the test instance has not been created yet. This is likely a framework bug.");
}

return HookExecutor.ExecuteBeforeTestHook(MethodInfo, context,
() => Body!.Invoke(context.TestDetails.ClassInstance, context, cancellationToken)
);
Expand Down
15 changes: 15 additions & 0 deletions TUnit.Core/PlaceholderInstance.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace TUnit.Core;

/// <summary>
/// A placeholder instance used during test discovery for tests that will have their instances created lazily during execution.
/// This is different from SkippedTestInstance which is for tests that are actually skipped.
/// </summary>
internal sealed class PlaceholderInstance
{
public static readonly PlaceholderInstance Instance = new();

private PlaceholderInstance()
{
// Private constructor to ensure singleton pattern
}
}
11 changes: 8 additions & 3 deletions TUnit.Core/TestMetadata`1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ public override Func<ExecutableTestCreationContext, TestMetadata, AbstractExecut
// Create instance delegate that uses context
Func<TestContext, Task<object>> createInstance = async testContext =>
{
// If we have a factory from discovery, use it (for lazy instance creation)
if (context.TestClassInstanceFactory != null)
{
return await context.TestClassInstanceFactory();
}

// Otherwise fall back to creating instance normally
// Try to create instance with ClassConstructor attribute
var attributes = metadata.AttributeFactory();
var instance = await ClassConstructorHelper.TryCreateInstanceWithClassConstructor(
Expand All @@ -88,9 +95,7 @@ public override Func<ExecutableTestCreationContext, TestMetadata, AbstractExecut
return instance;
}

// Fall back to default instance factory
var typeArgs = testContext.TestDetails.MethodMetadata.Class.Parameters.Select(x => x.Type).ToArray();
return typedMetadata.InstanceFactory!(typeArgs, context.ClassArguments);
return typedMetadata.InstanceFactory!(context.ResolvedClassGenericArguments, context.ClassArguments);
};

// Convert InvokeTypedTest to the expected signature
Expand Down
30 changes: 16 additions & 14 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
{
var tempTestData = new TestData
{
TestClassInstance = null!,
TestClassInstanceFactory = () => Task.FromResult<object>(null!),
ClassDataSourceAttributeIndex = classDataAttributeIndex,
ClassDataLoopIndex = classDataLoopIndex,
ClassData = classData,
Expand Down Expand Up @@ -218,7 +218,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy

var tempTestData = new TestData
{
TestClassInstance = null!, // Temporary placeholder
TestClassInstanceFactory = () => Task.FromResult<object>(null!), // Temporary placeholder
ClassDataSourceAttributeIndex = classDataAttributeIndex,
ClassDataLoopIndex = classDataLoopIndex,
ClassData = classData,
Expand Down Expand Up @@ -271,24 +271,25 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
throw new InvalidOperationException($"Cannot create instance of generic type {metadata.TestClassType.Name} with empty type arguments");
}

// Check for basic skip attributes that can be evaluated at discovery time
var basicSkipReason = GetBasicSkipReason(metadata);
object instance;

if (!string.IsNullOrEmpty(basicSkipReason))

Func<Task<object>> instanceFactory;
if (basicSkipReason != null && basicSkipReason.Length > 0)
{
// Use placeholder instance for basic skip attributes to avoid calling constructor
instance = SkippedTestInstance.Instance;
instanceFactory = () => Task.FromResult<object>(SkippedTestInstance.Instance);
}
else
{
// No skip attributes or custom skip attributes - create instance normally
instance = await CreateInstance(metadata, resolvedClassGenericArgs, classData, contextAccessor.Current);
var capturedMetadata = metadata;
var capturedClassGenericArgs = resolvedClassGenericArgs;
var capturedClassData = classData;
var capturedContext = contextAccessor.Current;
instanceFactory = () => CreateInstance(capturedMetadata, capturedClassGenericArgs, capturedClassData, capturedContext);
}

var testData = new TestData
{
TestClassInstance = instance,
TestClassInstanceFactory = instanceFactory,
ClassDataSourceAttributeIndex = classDataAttributeIndex,
ClassDataLoopIndex = classDataLoopIndex,
ClassData = classData,
Expand Down Expand Up @@ -553,7 +554,7 @@ public async Task<AbstractExecutableTest> BuildTestAsync(TestMetadata metadata,

var context = await CreateTestContextAsync(testId, metadata, testData, testBuilderContext);

context.TestDetails.ClassInstance = testData.TestClassInstance;
context.TestDetails.ClassInstance = PlaceholderInstance.Instance;

TrackDataSourceObjects(context, testData.ClassData, testData.MethodData);

Expand All @@ -566,6 +567,7 @@ public async Task<AbstractExecutableTest> BuildTestAsync(TestMetadata metadata,
Arguments = testData.MethodData,
ClassArguments = testData.ClassData,
Context = context,
TestClassInstanceFactory = testData.TestClassInstanceFactory,
ResolvedMethodGenericArguments = testData.ResolvedMethodGenericArguments,
ResolvedClassGenericArguments = testData.ResolvedClassGenericArguments
};
Expand Down Expand Up @@ -615,7 +617,7 @@ private ValueTask<TestContext> CreateTestContextAsync(string testId, TestMetadat
TestName = metadata.TestName,
ClassType = metadata.TestClassType,
MethodName = metadata.TestMethodName,
ClassInstance = testData.TestClassInstance,
ClassInstance = PlaceholderInstance.Instance,
TestMethodArguments = testData.MethodData,
TestClassArguments = testData.ClassData,
TestFilePath = metadata.FilePath ?? "Unknown",
Expand Down Expand Up @@ -934,7 +936,7 @@ private static bool IsTypeCompatible(Type actualType, Type expectedType)

internal class TestData
{
public required object TestClassInstance { get; init; }
public required Func<Task<object>> TestClassInstanceFactory { get; init; }

public required int ClassDataSourceAttributeIndex { get; init; }
public required int ClassDataLoopIndex { get; init; }
Expand Down
7 changes: 4 additions & 3 deletions TUnit.Engine/Building/TestBuilderPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
// Create a simple TestData for ID generation
var testData = new TestBuilder.TestData
{
TestClassInstance = null!,
TestClassInstanceFactory = () => Task.FromResult(metadata.InstanceFactory(Type.EmptyTypes, [])),
ClassDataSourceAttributeIndex = 0,
ClassDataLoopIndex = 0,
ClassData = [],
Expand All @@ -98,7 +98,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
TestName = metadata.TestName,
ClassType = metadata.TestClassType,
MethodName = metadata.TestMethodName,
ClassInstance = null!,
ClassInstance = PlaceholderInstance.Instance,
TestMethodArguments = [],
TestClassArguments = [],
TestFilePath = metadata.FilePath ?? "Unknown",
Expand Down Expand Up @@ -128,7 +128,8 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
DisplayName = displayName,
Arguments = [],
ClassArguments = [],
Context = context
Context = context,
TestClassInstanceFactory = testData.TestClassInstanceFactory
};

var executableTest = metadata.CreateExecutableTestFactory(executableTestContext, metadata);
Expand Down
42 changes: 8 additions & 34 deletions TUnit.Engine/Discovery/ReflectionTestMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ private AbstractExecutableTest CreateExecutableTest(ExecutableTestCreationContex
// Create instance factory that uses reflection
async Task<object> CreateInstance(TestContext testContext)
{
// If we have a factory from discovery, use it (for lazy instance creation)
if (context.TestClassInstanceFactory != null)
{
return await context.TestClassInstanceFactory();
}

// Otherwise fall back to creating instance normally
// Try to create instance with ClassConstructor attribute
var attributes = testContext.TestDetails.Attributes;
var classConstructorInstance = await ClassConstructorHelper.TryCreateInstanceWithClassConstructor(
Expand All @@ -62,40 +69,7 @@ async Task<object> CreateInstance(TestContext testContext)
throw new InvalidOperationException($"No instance factory for {_testClass.Name}");
}

// Get type arguments for generic types
// For generic types, we need to infer the type arguments from the actual argument values
Type[] typeArgs;
if (_testClass.IsGenericTypeDefinition && context.ClassArguments is { Length: > 0 })
{
// Infer type arguments from the constructor argument values
var genericParams = _testClass.GetGenericArguments();
typeArgs = new Type[genericParams.Length];

// For single generic parameter, use the first argument's type
if (genericParams.Length == 1 && context.ClassArguments.Length >= 1)
{
typeArgs[0] = context.ClassArguments[0]?.GetType() ?? typeof(object);
}
else
{
// For multiple generic parameters, try to match one-to-one
for (var i = 0; i < genericParams.Length; i++)
{
if (i < context.ClassArguments.Length && context.ClassArguments[i] != null)
{
typeArgs[i] = context.ClassArguments[i]!.GetType();
}
else
{
typeArgs[i] = typeof(object);
}
}
}
}
else
{
typeArgs = testContext.TestDetails.TestClassArguments?.OfType<Type>().ToArray() ?? Type.EmptyTypes;
}
Type[] typeArgs = context.ResolvedClassGenericArguments;

var instance = InstanceFactory(typeArgs, context.ClassArguments ??
[
Expand Down
7 changes: 7 additions & 0 deletions TUnit.Engine/Scheduling/TestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,15 @@ public async Task ExecuteTestAsync(AbstractExecutableTest test, CancellationToke

// Report test started
await _tunitMessageBus.InProgress(test.Context);

try
{
if (test.Context.TestDetails.ClassInstance is PlaceholderInstance)
{
var instance = await test.CreateInstanceAsync();
test.Context.TestDetails.ClassInstance = instance;
}

// Execute class/assembly hooks on first test
var executionContext = await _hookOrchestrator.OnTestStartingAsync(test, cancellationToken);

Expand Down
27 changes: 23 additions & 4 deletions TUnit.Engine/Services/SingleTestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,27 @@ private async Task<TestResult> ExecuteTestInternalAsync(
return await HandleSkippedTestInternalAsync(test, cancellationToken);
}

var instance = await test.CreateInstanceAsync();
test.Context.TestDetails.ClassInstance = instance;
if (test.Context.TestDetails.ClassInstance is PlaceholderInstance)
{
var createdInstance = await test.CreateInstanceAsync();
if (createdInstance == null)
{
throw new InvalidOperationException($"CreateInstanceAsync returned null for test {test.Context.GetDisplayName()}. This is likely a framework bug.");
}
test.Context.TestDetails.ClassInstance = createdInstance;
}

var instance = test.Context.TestDetails.ClassInstance;

if (instance == null)
{
throw new InvalidOperationException($"Test instance is null for test {test.Context.GetDisplayName()} after instance creation. ClassInstance type: {test.Context.TestDetails.ClassInstance?.GetType()?.Name ?? "null"}");
}

if (instance is PlaceholderInstance)
{
throw new InvalidOperationException($"Test instance is still PlaceholderInstance for test {test.Context.GetDisplayName()}. This should have been replaced.");
}

await PropertyInjectionService.InjectPropertiesIntoArgumentsAsync(test.ClassArguments, test.Context.ObjectBag, test.Context.TestDetails.MethodMetadata, test.Context.Events);
await PropertyInjectionService.InjectPropertiesIntoArgumentsAsync(test.Arguments, test.Context.ObjectBag, test.Context.TestDetails.MethodMetadata, test.Context.Events);
Expand Down Expand Up @@ -288,7 +307,7 @@ private async Task ExecuteBeforeTestHooksAsync(IReadOnlyList<Func<TestContext, C
try
{
await hook(context, cancellationToken);

// RestoreExecutionContext after each hook to ensure AsyncLocal values flow correctly
// when AddAsyncLocalValues() is called in hooks
context.RestoreExecutionContext();
Expand All @@ -304,7 +323,7 @@ private async Task ExecuteBeforeTestHooksAsync(IReadOnlyList<Func<TestContext, C
private async Task ExecuteAfterTestHooksAsync(IReadOnlyList<Func<TestContext, CancellationToken, Task>> hooks, TestContext context, CancellationToken cancellationToken)
{
var exceptions = new List<Exception>();

// Restore contexts once at the beginning
RestoreHookContexts(context);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ namespace
public required string DisplayName { get; init; }
public [] ResolvedClassGenericArguments { get; init; }
public [] ResolvedMethodGenericArguments { get; init; }
public <.<object>>? TestClassInstanceFactory { get; init; }
public required string TestId { get; init; }
}
[(.Assembly | .Class | .Method)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ namespace
public required string DisplayName { get; init; }
public [] ResolvedClassGenericArguments { get; init; }
public [] ResolvedMethodGenericArguments { get; init; }
public <.<object>>? TestClassInstanceFactory { get; init; }
public required string TestId { get; init; }
}
[(.Assembly | .Class | .Method)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,7 @@ namespace
public required string DisplayName { get; init; }
public [] ResolvedClassGenericArguments { get; init; }
public [] ResolvedMethodGenericArguments { get; init; }
public <.<object>>? TestClassInstanceFactory { get; init; }
public required string TestId { get; init; }
}
[(.Assembly | .Class | .Method)]
Expand Down
Loading