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
1 change: 1 addition & 0 deletions TUnit.Core/TestContext.Dependencies.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace TUnit.Core;
public partial class TestContext
{
internal readonly List<TestDetails> _dependencies = [];
internal bool _dependenciesPopulated;
internal string? ParentTestId { get; set; }
internal TestRelationship Relationship { get; set; } = TestRelationship.None;

Expand Down
8 changes: 8 additions & 0 deletions TUnit.Engine/Building/Interfaces/ITestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,12 @@ IAsyncEnumerable<AbstractExecutableTest> BuildTestsStreamingAsync(
[RequiresUnreferencedCode("Type comes from runtime objects that cannot be annotated")]
#endif
ValueTask InvokePostResolutionEventsAsync(AbstractExecutableTest test);

/// <summary>
/// Populates TestContext._dependencies from resolved test.Dependencies without invoking event receivers.
/// This should be called for all tests before After(TestDiscovery) hooks run, so that hooks
/// can access dependency information on any TestContext.
/// </summary>
/// <param name="test">The test with resolved dependencies</param>
void PopulateDependenciesOnly(AbstractExecutableTest test);
}
54 changes: 17 additions & 37 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -957,27 +957,29 @@ public async ValueTask InvokePostResolutionEventsAsync(AbstractExecutableTest te
TestContext.Current = context;

// Populate TestContext._dependencies from resolved test.Dependencies
// This makes dependencies available to event receivers
PopulateDependencies(test, context._dependencies);
// This makes dependencies available to event receivers and hooks
PopulateDependenciesOnly(test);

// Invoke discovery event receivers first (discovery phase)
// Invoke discovery event receivers (for all tests during discovery phase)
// Note: ITestRegisteredEventReceiver.OnTestRegistered is NOT called here.
// Registration events fire later in TestFilterService.RegisterTest for filtered tests only.
// This ensures OnTestRegistered is called exactly once per test that will actually run.
await InvokeDiscoveryEventReceiversAsync(context);
}

// Invoke test registered event receivers (registration phase)
try
{
await InvokeTestRegisteredReceiversAsync(context);
}
catch (Exception ex)
/// <inheritdoc />
public void PopulateDependenciesOnly(AbstractExecutableTest test)
{
var context = test.Context;

// Skip if already populated to make this idempotent
if (context._dependenciesPopulated)
{
// Registration logic failed - mark the test as failed
test.SetResult(TestState.Failed, ex);
return;
}

// Clear the cached display name after registration events
// This ensures that ArgumentDisplayFormatterAttribute and similar attributes
// have a chance to register their formatters before the display name is finalized
context.InvalidateDisplayNameCache();
PopulateDependencies(test, context._dependencies);
context._dependenciesPopulated = true;
}

private static void PopulateDependencies(AbstractExecutableTest test, List<TestDetails> dependencies)
Expand Down Expand Up @@ -1100,28 +1102,6 @@ private async Task RegisterTestArgumentsAsync(TestContext context)
await _testArgumentRegistrationService.RegisterTestArgumentsAsync(context);
}

/// <summary>
/// Invokes ITestRegisteredEventReceiver receivers.
/// Called after dependencies are resolved so receivers can access dependency information.
/// </summary>
#if NET6_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Type comes from runtime objects that cannot be annotated")]
#endif
private async Task InvokeTestRegisteredReceiversAsync(TestContext context)
{
var registeredContext = new TestRegisteredContext(context)
{
// InternalDiscoveredTest is set in RegisterTestArgumentsAsync during building
DiscoveredTest = context.InternalDiscoveredTest!
};

// Use pre-computed receivers (already filtered, sorted, and scoped-attribute filtered)
foreach (var receiver in context.GetTestRegisteredReceivers())
{
await receiver.OnTestRegistered(registeredContext);
}
}

#if NET6_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Scoped attribute filtering uses Type.GetInterfaces and reflection")]
#endif
Expand Down
14 changes: 14 additions & 0 deletions TUnit.Engine/Building/TestBuilderPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -572,4 +572,18 @@ private async Task InvokeDiscoveryEventReceiversAsync(TestContext context)
public ValueTask InvokePostResolutionEventsAsync(AbstractExecutableTest test)
=> _testBuilder.InvokePostResolutionEventsAsync(test);

/// <summary>
/// Populates dependencies for all tests without invoking event receivers.
/// This should be called before After(TestDiscovery) hooks run, so that hooks
/// can access dependency information on any TestContext.
/// </summary>
/// <param name="tests">All tests that have had their dependencies resolved</param>
public void PopulateAllDependencies(IEnumerable<AbstractExecutableTest> tests)
{
foreach (var test in tests)
{
_testBuilder.PopulateDependenciesOnly(test);
}
}

}
4 changes: 4 additions & 0 deletions TUnit.Engine/Services/TestFilterService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ private async Task RegisterTest(AbstractExecutableTest test)
}
}

// Clear the cached display name after registration events
// This ensures that ArgumentDisplayFormatterAttribute and similar attributes
// have a chance to register their formatters before the display name is finalized
test.Context.InvalidateDisplayNameCache();
}

public async Task RegisterTestsAsync(IEnumerable<AbstractExecutableTest> tests)
Expand Down
8 changes: 8 additions & 0 deletions TUnit.Engine/TestDiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ public async Task<TestDiscoveryResult> DiscoverTests(string testSessionId, ITest
_dependencyResolver.TryResolveDependencies(test);
}

// Populate TestContext._dependencies for ALL tests before After(TestDiscovery) hooks run.
// This ensures hooks can access dependency information on any TestContext (including focused tests).
_testBuilderPipeline.PopulateAllDependencies(allTests);

// Add tests to context and run After(TestDiscovery) hooks before event receivers
// This marks the end of the discovery phase, before registration begins
contextProvider.TestDiscoveryContext.AddTests(allTests.Select(static t => t.Context));
Expand Down Expand Up @@ -191,6 +195,10 @@ public async IAsyncEnumerable<AbstractExecutableTest> DiscoverTestsFullyStreamin
_dependencyResolver.TryResolveDependencies(test);
}

// Populate TestContext._dependencies for ALL tests before After(TestDiscovery) hooks run.
// This ensures hooks can access dependency information on any TestContext (including focused tests).
_testBuilderPipeline.PopulateAllDependencies(allTests);

// Add tests to context and run After(TestDiscovery) hooks before event receivers
// This marks the end of the discovery phase, before registration begins
var contextProvider = _testExecutor.GetContextProvider();
Expand Down
Loading
Loading