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
175 changes: 93 additions & 82 deletions TUnit.Engine/TestDiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,61 +60,7 @@ public async Task<TestDiscoveryResult> DiscoverTests(string testSessionId, ITest
var contextProvider = _testExecutor.GetContextProvider();
contextProvider.BeforeTestDiscoveryContext.RestoreExecutionContext();

var allTests = new List<AbstractExecutableTest>();

#pragma warning disable TPEXP
var isNopFilter = filter is NopFilter;
#pragma warning restore TPEXP
if (filter == null || !isForExecution || isNopFilter)
{
var buildingContext = new Building.TestBuildingContext(isForExecution, Filter: null);
await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false))
{
allTests.Add(test);
}
}
else
{
// Use filter-aware collection to pre-filter by type before materializing all metadata
var allMetadata = await _testBuilderPipeline.CollectTestMetadataAsync(testSessionId, filter).ConfigureAwait(false);
var allMetadataList = allMetadata.ToList();

var metadataToInclude = _dependencyExpander.ExpandToIncludeDependencies(allMetadataList, filter);

// Build tests directly from the pre-collected metadata (avoid re-collecting)
// Apply 5-minute discovery timeout matching the streaming path (#4715)
using var filterCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
filterCts.CancelAfter(EngineDefaults.DiscoveryTimeout);

var buildingContext = new Building.TestBuildingContext(isForExecution, Filter: null);
var tests = await _testBuilderPipeline.BuildTestsFromMetadataAsync(
metadataToInclude,
buildingContext,
filterCts.Token).ConfigureAwait(false);

var testsList = tests.ToList();

// Cache tests so ITestFinder can locate them
foreach (var test in testsList)
{
_cachedTests.Add(test);
}

allTests.AddRange(testsList);
}

foreach (var test in allTests)
{
_dependencyResolver.RegisterTest(test);
}

// Resolve dependencies in parallel — registration is complete so lookup dictionaries
// are effectively read-only, and each test's Dependencies are written independently.
_dependencyResolver.BatchResolveDependencies(allTests);

// 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);
var allTests = await DiscoverAndResolveTestsAsync(testSessionId, filter, cancellationToken, isForExecution).ConfigureAwait(false);

// Add tests to context and run After(TestDiscovery) hooks before event receivers
// This marks the end of the discovery phase, before registration begins
Expand Down Expand Up @@ -155,6 +101,95 @@ public async Task<TestDiscoveryResult> DiscoverTests(string testSessionId, ITest
return new TestDiscoveryResult(filteredTests, finalContext);
}

private async Task<List<AbstractExecutableTest>> DiscoverAndResolveTestsAsync(
string testSessionId,
ITestExecutionFilter? filter,
CancellationToken cancellationToken,
bool isForExecution)
{
#if NET
Activity? discoveryActivity = null;
if (TUnitActivitySource.Source.HasListeners())
{
var sessionActivity = _testExecutor.GetContextProvider().TestSessionContext.Activity;
discoveryActivity = TUnitActivitySource.StartActivity(
"test discovery",
ActivityKind.Internal,
sessionActivity?.Context ?? default);
}
#endif

var allTests = new List<AbstractExecutableTest>();

try
{
#pragma warning disable TPEXP
var isNopFilter = filter is NopFilter;
#pragma warning restore TPEXP
if (filter == null || !isForExecution || isNopFilter)
{
var buildingContext = new Building.TestBuildingContext(isForExecution, Filter: null);
await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false))
{
allTests.Add(test);
}
}
else
{
// Use filter-aware collection to pre-filter by type before materializing all metadata
var allMetadata = await _testBuilderPipeline.CollectTestMetadataAsync(testSessionId, filter).ConfigureAwait(false);
var allMetadataList = allMetadata.ToList();

var metadataToInclude = _dependencyExpander.ExpandToIncludeDependencies(allMetadataList, filter);

// Apply 5-minute discovery timeout matching the streaming path (#4715)
using var filterCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
filterCts.CancelAfter(EngineDefaults.DiscoveryTimeout);

var buildingContext = new Building.TestBuildingContext(isForExecution, Filter: null);
var tests = await _testBuilderPipeline.BuildTestsFromMetadataAsync(
metadataToInclude,
buildingContext,
filterCts.Token).ConfigureAwait(false);

var testsList = tests.ToList();

foreach (var test in testsList)
{
_cachedTests.Add(test);
}

allTests.AddRange(testsList);
}

foreach (var test in allTests)
{
_dependencyResolver.RegisterTest(test);
}

_dependencyResolver.BatchResolveDependencies(allTests);
_testBuilderPipeline.PopulateAllDependencies(allTests);

return allTests;
}
catch (Exception ex)
{
#if NET
TUnitActivitySource.RecordException(discoveryActivity, ex);
#else
_ = ex;
#endif
throw;
}
finally
{
#if NET
discoveryActivity?.SetTag("tunit.test.count", allTests.Count);
TUnitActivitySource.StopActivity(discoveryActivity);
#endif
}
}

private async IAsyncEnumerable<AbstractExecutableTest> DiscoverTestsStreamAsync(
string testSessionId,
Building.TestBuildingContext buildingContext,
Expand Down Expand Up @@ -183,25 +218,13 @@ public async IAsyncEnumerable<AbstractExecutableTest> DiscoverTestsFullyStreamin
{
await _testExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false);

var buildingContext = new Building.TestBuildingContext(IsForExecution: false, Filter: null);

var allTests = new List<AbstractExecutableTest>();
await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false))
{
allTests.Add(test);
}

// Resolve dependencies in parallel — registration is complete so lookup dictionaries
// are effectively read-only, and each test's Dependencies are written independently.
_dependencyResolver.BatchResolveDependencies(allTests);
var contextProvider = _testExecutor.GetContextProvider();

// 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);
var allTests = await DiscoverAndResolveTestsAsync(
testSessionId, filter: null, cancellationToken, isForExecution: false).ConfigureAwait(false);

// 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();
contextProvider.TestDiscoveryContext.AddTests(allTests.Select(static t => t.Context));
await _testExecutor.ExecuteAfterTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false);
contextProvider.TestDiscoveryContext.RestoreExecutionContext();
Expand Down Expand Up @@ -274,18 +297,6 @@ public async IAsyncEnumerable<AbstractExecutableTest> DiscoverTestsFullyStreamin
}
}

private bool AreAllDependenciesSatisfied(AbstractExecutableTest test, ConcurrentDictionary<string, bool> completedTests)
{
foreach (var dependency in test.Dependencies)
{
if (!completedTests.ContainsKey(dependency.Test.TestId))
{
return false;
}
}
return true;
}



private async Task InvokePostResolutionEventsInParallelAsync(List<AbstractExecutableTest> allTests)
Expand Down
5 changes: 4 additions & 1 deletion docs/docs/examples/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,14 @@ TUnit creates a nested span tree that mirrors the test lifecycle:

```
test session
├── test discovery
└── test assembly
└── test suite (one per test class)
└── test case (one per test method invocation)
```

The **test discovery** span captures the time spent finding, building, and resolving dependencies for all tests. It appears as a sibling of the assembly spans, giving you a clear view of discovery vs execution time.

## Attributes

Each span carries tags that follow [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/) where applicable.
Expand Down Expand Up @@ -86,7 +89,7 @@ Each span carries tags that follow [OpenTelemetry semantic conventions](https://
| `tunit.test.method` | test case | Method name |
| `tunit.test.id` | test case | Unique test instance ID |
| `tunit.test.categories` | test case | Test categories (string array) |
| `tunit.test.count` | session/assembly/suite | Total test count |
| `tunit.test.count` | session/assembly/suite/discovery | Total test count |
| `tunit.test.retry_attempt` | test case | Current retry attempt (when retrying) |
| `tunit.test.skip_reason` | test case | Reason the test was skipped |

Expand Down
Loading