diff --git a/TUnit.Engine/TestDiscoveryService.cs b/TUnit.Engine/TestDiscoveryService.cs index 0d7121abe4..180fe27e81 100644 --- a/TUnit.Engine/TestDiscoveryService.cs +++ b/TUnit.Engine/TestDiscoveryService.cs @@ -60,61 +60,7 @@ public async Task DiscoverTests(string testSessionId, ITest var contextProvider = _testExecutor.GetContextProvider(); contextProvider.BeforeTestDiscoveryContext.RestoreExecutionContext(); - var allTests = new List(); - -#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 @@ -155,6 +101,94 @@ public async Task DiscoverTests(string testSessionId, ITest return new TestDiscoveryResult(filteredTests, finalContext); } + private async Task> 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(); + + 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 DiscoverTestsStreamAsync( string testSessionId, Building.TestBuildingContext buildingContext, @@ -183,25 +217,13 @@ public async IAsyncEnumerable DiscoverTestsFullyStreamin { await _testExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false); - var buildingContext = new Building.TestBuildingContext(IsForExecution: false, Filter: null); - - var allTests = new List(); - 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(); @@ -274,18 +296,6 @@ public async IAsyncEnumerable DiscoverTestsFullyStreamin } } - private bool AreAllDependenciesSatisfied(AbstractExecutableTest test, ConcurrentDictionary completedTests) - { - foreach (var dependency in test.Dependencies) - { - if (!completedTests.ContainsKey(dependency.Test.TestId)) - { - return false; - } - } - return true; - } - private async Task InvokePostResolutionEventsInParallelAsync(List allTests) diff --git a/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md index 852e649acc..03a7462e8f 100644 --- a/docs/docs/examples/opentelemetry.md +++ b/docs/docs/examples/opentelemetry.md @@ -8,6 +8,8 @@ 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 @@ -15,7 +17,7 @@ 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; @@ -27,7 +29,7 @@ public class TraceSetup { private static TracerProvider? _tracerProvider; - [Before(TestSession)] + [Before(TestDiscovery)] public static void SetupTracing() { _tracerProvider = Sdk.CreateTracerProviderBuilder() @@ -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 _) => 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. @@ -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 | @@ -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")) ```