Skip to content
Closed
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
41 changes: 41 additions & 0 deletions TUnit.Engine/CommandLineProviders/MetricsCommandProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Microsoft.Testing.Platform.CommandLine;
using Microsoft.Testing.Platform.Extensions;
using Microsoft.Testing.Platform.Extensions.CommandLine;

namespace TUnit.Engine.CommandLineProviders;

internal class MetricsCommandProvider(IExtension extension) : ICommandLineOptionsProvider
{
public const string Metrics = "metrics";

public Task<bool> IsEnabledAsync()
{
return extension.IsEnabledAsync();
}

public string Uid => extension.Uid;

public string Version => extension.Version;

public string DisplayName => extension.DisplayName;

public string Description => extension.Description;

public IReadOnlyCollection<CommandLineOption> GetCommandLineOptions()
{
return
[
new CommandLineOption(Metrics, "Enable test execution metrics summary at the end of the run", ArgumentArity.Zero, false)
];
}

public Task<ValidationResult> ValidateOptionArgumentsAsync(CommandLineOption commandOption, string[] arguments)
{
return ValidationResult.ValidTask;
}

public Task<ValidationResult> ValidateCommandLineOptionsAsync(ICommandLineOptions commandLineOptions)
{
return ValidationResult.ValidTask;
}
}
3 changes: 3 additions & 0 deletions TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder)
testApplicationBuilder.CommandLine.AddProvider(() => new ParallelismStrategyCommandProvider(extension));
testApplicationBuilder.CommandLine.AddProvider(() => new AdaptiveMetricsCommandProvider(extension));

// Metrics command provider
testApplicationBuilder.CommandLine.AddProvider(() => new MetricsCommandProvider(extension));

// Keep detailed stacktrace option for backward compatibility
testApplicationBuilder.CommandLine.AddProvider(() => new DetailedStacktraceCommandProvider(extension));

Expand Down
10 changes: 9 additions & 1 deletion TUnit.Engine/Framework/TUnitServiceProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public ITestExecutionFilter? Filter
public CancellationTokenSource FailFastCancellationSource { get; }
public ParallelLimitLockProvider ParallelLimitLockProvider { get; }
public ObjectLifecycleService ObjectLifecycleService { get; }
public TestMetricsCollector? MetricsCollector { get; }
public bool AfterSessionHooksFailed { get; set; }

[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT/trimmed scenarios")]
Expand Down Expand Up @@ -107,6 +108,12 @@ public TUnitServiceProvider(IExtension extension,
loggerFactory.CreateLogger<TUnitFrameworkLogger>(),
logLevelProvider));

// Create metrics collector if --metrics flag is set
if (CommandLineOptions.IsOptionSet(CommandLineProviders.MetricsCommandProvider.Metrics))
{
MetricsCollector = Register(new TestMetricsCollector(Logger));
}

// Create initialization services using Lazy<T> to break circular dependencies
// No more two-phase initialization with Initialize() calls
var objectGraphDiscoveryService = Register(new ObjectGraphDiscoveryService());
Expand Down Expand Up @@ -246,7 +253,8 @@ public TUnitServiceProvider(IExtension extension,
isFailFastEnabled,
FailFastCancellationSource,
Logger,
testStateManager));
testStateManager,
MetricsCollector));

// Create scheduler configuration from command line options
var testGroupingService = Register<ITestGroupingService>(new TestGroupingService(Logger));
Expand Down
25 changes: 22 additions & 3 deletions TUnit.Engine/Scheduling/TestRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using TUnit.Core;
using TUnit.Engine.Interfaces;
using TUnit.Engine.Logging;
using TUnit.Engine.Services;
using TUnit.Engine.Services.TestExecution;

namespace TUnit.Engine.Scheduling;
Expand All @@ -18,21 +19,24 @@ public sealed class TestRunner
private readonly CancellationTokenSource _failFastCancellationSource;
private readonly TUnitFrameworkLogger _logger;
private readonly TestStateManager _testStateManager;
private readonly TestMetricsCollector? _metricsCollector;

internal TestRunner(
ITestCoordinator testCoordinator,
ITUnitMessageBus tunitMessageBus,
bool isFailFastEnabled,
CancellationTokenSource failFastCancellationSource,
TUnitFrameworkLogger logger,
TestStateManager testStateManager)
TestStateManager testStateManager,
TestMetricsCollector? metricsCollector = null)
{
_testCoordinator = testCoordinator;
_tunitMessageBus = tunitMessageBus;
_isFailFastEnabled = isFailFastEnabled;
_failFastCancellationSource = failFastCancellationSource;
_logger = logger;
_testStateManager = testStateManager;
_metricsCollector = metricsCollector;
}

private readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> _executingTests = new();
Expand Down Expand Up @@ -81,6 +85,7 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca
if (dependency.Test.State != TestState.Passed && !dependency.ProceedOnFailure)
{
_testStateManager.MarkSkipped(test, "Skipped due to failed dependencies");
_metricsCollector?.OnTestSkipped();
await _tunitMessageBus.Skipped(test.Context, "Skipped due to failed dependencies").ConfigureAwait(false);
return;
}
Expand All @@ -89,8 +94,22 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca
test.State = TestState.Running;
test.StartTime = DateTimeOffset.UtcNow;

// TestCoordinator handles sending InProgress message
await _testCoordinator.ExecuteTestAsync(test, cancellationToken).ConfigureAwait(false);
_metricsCollector?.OnTestStarted();

try
{
// TestCoordinator handles sending InProgress message
await _testCoordinator.ExecuteTestAsync(test, cancellationToken).ConfigureAwait(false);
}
finally
{
var duration = test.EndTime.HasValue && test.StartTime.HasValue
? test.EndTime.Value - test.StartTime.Value
: (TimeSpan?)null;
var passed = test.Result?.State == TestState.Passed;
var skipped = test.Result?.State == TestState.Skipped;
_metricsCollector?.OnTestCompleted(passed, skipped, duration);
}

if (_isFailFastEnabled && test.Result?.State == TestState.Failed)
{
Expand Down
164 changes: 164 additions & 0 deletions TUnit.Engine/Services/TestMetricsCollector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
using System.Diagnostics;
using TUnit.Core.Logging;
using TUnit.Engine.Logging;

namespace TUnit.Engine.Services;

/// <summary>
/// Collects and reports test execution metrics when the --metrics flag is enabled.
/// Tracks test counts, durations, concurrency, and memory usage.
/// </summary>
internal sealed class TestMetricsCollector
{
private readonly TUnitFrameworkLogger _logger;

private int _totalTests;
private int _passedTests;
private int _failedTests;
private int _skippedTests;

private int _currentConcurrentTests;
private int _peakConcurrentTests;

private long _totalTestDurationTicks;
private int _completedTestsWithDuration;

private long _memoryAtStart;
private long _memoryAtEnd;

private readonly Stopwatch _wallClockStopwatch = new();

public TestMetricsCollector(TUnitFrameworkLogger logger)
{
_logger = logger;
}

/// <summary>
/// Called once before test execution begins to capture initial state.
/// </summary>
public void OnExecutionStarted(int totalTestCount)
{
_totalTests = totalTestCount;
_memoryAtStart = GC.GetTotalMemory(forceFullCollection: false);
_wallClockStopwatch.Start();
}

/// <summary>
/// Called when an individual test begins executing.
/// </summary>
public void OnTestStarted()
{
var current = Interlocked.Increment(ref _currentConcurrentTests);

// Update peak using a lock-free CAS loop
int peak;
do
{
peak = Volatile.Read(ref _peakConcurrentTests);
if (current <= peak)
{
break;
}
} while (Interlocked.CompareExchange(ref _peakConcurrentTests, current, peak) != peak);
}

/// <summary>
/// Called when a test is skipped without ever being started (e.g. due to a failed dependency).
/// Only increments the skip counter without touching the concurrency counter.
/// </summary>
public void OnTestSkipped()
{
Interlocked.Increment(ref _skippedTests);
}

/// <summary>
/// Called when an individual test finishes executing.
/// Must only be called for tests that had a prior <see cref="OnTestStarted"/> call.
/// </summary>
public void OnTestCompleted(bool passed, bool skipped, TimeSpan? duration)
{
Interlocked.Decrement(ref _currentConcurrentTests);

if (skipped)
{
Interlocked.Increment(ref _skippedTests);
}
else if (passed)
{
Interlocked.Increment(ref _passedTests);
}
else
{
Interlocked.Increment(ref _failedTests);
}

if (duration.HasValue)
{
Interlocked.Add(ref _totalTestDurationTicks, duration.Value.Ticks);
Interlocked.Increment(ref _completedTestsWithDuration);
}
}

/// <summary>
/// Called after all tests have finished. Captures final state and logs the summary.
/// </summary>
public async ValueTask OnExecutionFinishedAsync()
{
_wallClockStopwatch.Stop();
_memoryAtEnd = GC.GetTotalMemory(forceFullCollection: false);

var summary = BuildSummary();
await _logger.LogAsync(LogLevel.Information, summary, null, static (s, _) => s);
}

private string BuildSummary()
{
var wallClock = _wallClockStopwatch.Elapsed;
var completedWithDuration = Volatile.Read(ref _completedTestsWithDuration);
var avgDuration = completedWithDuration > 0
? TimeSpan.FromTicks(Volatile.Read(ref _totalTestDurationTicks) / completedWithDuration)
: TimeSpan.Zero;

var memStartMb = _memoryAtStart / (1024.0 * 1024.0);
var memEndMb = _memoryAtEnd / (1024.0 * 1024.0);
var memDeltaMb = memEndMb - memStartMb;
var sign = memDeltaMb >= 0 ? "+" : "";

return $"""

--- Test Execution Metrics ---
Total tests: {Volatile.Read(ref _totalTests)}
Passed: {Volatile.Read(ref _passedTests)}
Failed: {Volatile.Read(ref _failedTests)}
Skipped: {Volatile.Read(ref _skippedTests)}
Average test duration: {FormatDuration(avgDuration)}
Peak concurrent tests: {Volatile.Read(ref _peakConcurrentTests)}
Wall-clock time: {FormatDuration(wallClock)}
Memory at start: {memStartMb:F1} MB
Memory at end: {memEndMb:F1} MB
Memory delta: {sign}{memDeltaMb:F1} MB
------------------------------
""";
}

private static string FormatDuration(TimeSpan ts)
{
if (ts.TotalMilliseconds < 1)
{
var microseconds = ts.Ticks / 10.0;
return $"{microseconds:F0} us";
}

if (ts.TotalSeconds < 1)
{
return $"{ts.TotalMilliseconds:F1} ms";
}

if (ts.TotalMinutes < 1)
{
return $"{ts.TotalSeconds:F2} s";
}

return $"{ts.TotalMinutes:F1} min";
}
}
7 changes: 7 additions & 0 deletions TUnit.Engine/TestSessionCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,20 @@ public async Task ExecuteTests(

InitializeEventReceivers(testList, cancellationToken);

_serviceProvider.MetricsCollector?.OnExecutionStarted(testList.Count);

try
{
await PrepareTestOrchestrator(testList, cancellationToken);
await ExecuteTestsCore(testList, cancellationToken);
}
finally
{
if (_serviceProvider.MetricsCollector != null)
{
await _serviceProvider.MetricsCollector.OnExecutionFinishedAsync();
}

foreach (var artifact in _contextProvider.TestSessionContext.Artifacts)
{
await _messageBus.SessionArtifact(artifact);
Expand Down
Loading