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
174 changes: 92 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, isForExecution, cancellationToken).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,94 @@ public async Task<TestDiscoveryResult> DiscoverTests(string testSessionId, ITest
return new TestDiscoveryResult(filteredTests, finalContext);
}

private async Task<List<AbstractExecutableTest>> DiscoverAndResolveTestsAsync(
string testSessionId,
ITestExecutionFilter? filter,
bool isForExecution,
CancellationToken cancellationToken)
{
#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);
_dependencyResolver.RegisterTest(test);
}

allTests.AddRange(testsList);
}

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

return allTests;
}
#if NET
catch (Exception ex)
{
TUnitActivitySource.RecordException(discoveryActivity, ex);
throw;
}
#else
catch
{
throw;
}
#endif
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 +217,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, isForExecution: false, cancellationToken).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 +296,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
71 changes: 64 additions & 7 deletions docs/docs/examples/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ Activity tracing requires .NET 8 or later. It is not available on .NET Framework

## Setup

### Option A: OpenTelemetry SDK (recommended)

Add the OpenTelemetry packages to your test project:

```bash
dotnet add package OpenTelemetry
dotnet add package OpenTelemetry.Exporter.Console
```

Then subscribe to the `"TUnit"` ActivitySource in your test setup:
Then subscribe to the `"TUnit"` ActivitySource in a `[Before(TestDiscovery)]` hook:

```csharp
using System.Diagnostics;
Expand All @@ -27,7 +29,7 @@ public class TraceSetup
{
private static TracerProvider? _tracerProvider;

[Before(TestSession)]
[Before(TestDiscovery)]
public static void SetupTracing()
{
_tracerProvider = Sdk.CreateTracerProviderBuilder()
Expand All @@ -47,17 +49,64 @@ public class TraceSetup

Replace `AddConsoleExporter()` with your preferred exporter (Jaeger, Zipkin, OTLP, etc.).

### Option B: Raw `ActivityListener` (no SDK dependency)

If you don't want the OpenTelemetry SDK, you can subscribe directly with a `System.Diagnostics.ActivityListener`:

```csharp
using System.Diagnostics;

public class TraceSetup
{
private static ActivityListener? _listener;

[Before(TestDiscovery)]
public static void SetupTracing()
{
_listener = new ActivityListener
{
ShouldListenTo = source => source.Name == "TUnit",
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStarted = activity => Console.WriteLine($"▶ {activity.OperationName}"),
ActivityStopped = activity => Console.WriteLine($"■ {activity.OperationName} ({activity.Duration.TotalMilliseconds:F1}ms)")
};
ActivitySource.AddActivityListener(_listener);
}

[After(TestSession)]
public static void TeardownTracing()
{
_listener?.Dispose();
}
}
```

### Why `[Before(TestDiscovery)]`?

The listener **must** be registered in a `[Before(TestDiscovery)]` hook (or earlier, e.g. `[Before(TestSession)]`) so it is active before the discovery span begins. TUnit's hook execution order is:

1. `[Before(TestSession)]` — session-level setup
2. `[Before(TestDiscovery)]` — register your listener here
3. **Test discovery** — the `"test discovery"` span is emitted here
4. Test execution — assembly, suite, and test case spans are emitted
5. `[After(TestSession)]` — dispose your listener here

If you register the listener later (e.g., in `[Before(Assembly)]`), the discovery span will not be captured.

## Span Hierarchy

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 +135,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 All @@ -104,17 +153,25 @@ When a test is configured with `[Retry]`, each failed attempt produces its own s

## Using with Jaeger, Zipkin, or OTLP

Swap the exporter in the setup:
Swap the exporter in the setup code above. Each exporter needs its own NuGet package.

```csharp
// OTLP (works with Jaeger, Tempo, Honeycomb, etc.)
### OTLP (works with Jaeger, Tempo, Honeycomb, etc.)

```bash
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
```

```csharp
.AddOtlpExporter(opts => opts.Endpoint = new Uri("http://localhost:4317"))
```

// Zipkin
### Zipkin

```bash
dotnet add package OpenTelemetry.Exporter.Zipkin
```

```csharp
.AddZipkinExporter(opts => opts.Endpoint = new Uri("http://localhost:9411/api/v2/spans"))
```

Expand Down
Loading