diff --git a/TUnit.Core.SourceGenerator.Tests/NuGetDownloader.cs b/TUnit.Core.SourceGenerator.Tests/NuGetDownloader.cs index 14a37dfa50..33551dee84 100644 --- a/TUnit.Core.SourceGenerator.Tests/NuGetDownloader.cs +++ b/TUnit.Core.SourceGenerator.Tests/NuGetDownloader.cs @@ -20,7 +20,7 @@ public static async Task> DownloadPackageAsync(st if (!Directory.Exists(extractedPath)) { - var settings = Settings.LoadDefaultSettings(null); + var settings = NuGet.Configuration.Settings.LoadDefaultSettings(null); var sourceRepositoryProvider = new SourceRepositoryProvider(new PackageSourceProvider(settings), Repository.Provider.GetCoreV3()); var repository = sourceRepositoryProvider.CreateRepository(new PackageSource("https://api.nuget.org/v3/index.json")); diff --git a/TUnit.Core/Defaults.cs b/TUnit.Core/Defaults.cs index 893376f766..b6b8c525ad 100644 --- a/TUnit.Core/Defaults.cs +++ b/TUnit.Core/Defaults.cs @@ -1,32 +1,39 @@ +using TUnit.Core.Settings; + namespace TUnit.Core; /// /// Default values shared across TUnit.Core and TUnit.Engine. /// Centralizes magic numbers so they can be tuned in a single place. /// +[Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)} instead.")] public static class Defaults { /// /// Default timeout applied to individual tests when no [Timeout] attribute is specified. /// Can be overridden per-test via . /// - public static readonly TimeSpan TestTimeout = TimeSpan.FromMinutes(30); + [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultTestTimeout)} instead.")] + public static TimeSpan TestTimeout => TUnitSettings.Default.Timeouts.DefaultTestTimeout; /// /// Default timeout applied to hook methods (Before/After at every level) /// when no explicit timeout is configured. /// - public static readonly TimeSpan HookTimeout = TimeSpan.FromMinutes(5); + [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultHookTimeout)} instead.")] + public static TimeSpan HookTimeout => TUnitSettings.Default.Timeouts.DefaultHookTimeout; /// /// Time allowed for a graceful shutdown after a cancellation request (Ctrl+C / SIGTERM) /// before the process is forcefully terminated. /// - public static readonly TimeSpan ForcefulExitTimeout = TimeSpan.FromSeconds(30); + [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ForcefulExitTimeout)} instead.")] + public static TimeSpan ForcefulExitTimeout => TUnitSettings.Default.Timeouts.ForcefulExitTimeout; /// /// Brief delay during process exit to allow After hooks registered via /// to execute before the process terminates. /// - public static readonly TimeSpan ProcessExitHookDelay = TimeSpan.FromMilliseconds(500); + [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ProcessExitHookDelay)} instead.")] + public static TimeSpan ProcessExitHookDelay => TUnitSettings.Default.Timeouts.ProcessExitHookDelay; } diff --git a/TUnit.Core/EngineCancellationToken.cs b/TUnit.Core/EngineCancellationToken.cs index 4236d62080..9e9d5e37a7 100644 --- a/TUnit.Core/EngineCancellationToken.cs +++ b/TUnit.Core/EngineCancellationToken.cs @@ -1,4 +1,6 @@ -namespace TUnit.Core; +using TUnit.Core.Settings; + +namespace TUnit.Core; /// /// Represents a cancellation token for the engine. @@ -62,7 +64,7 @@ private void Cancel() _forcefulExitStarted = true; // Start a new forceful exit timer - _ = Task.Delay(Defaults.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t => + _ = Task.Delay(TUnitSettings.Default.Timeouts.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t => { if (!t.IsCanceled) { @@ -86,7 +88,7 @@ private void OnProcessExit(object? sender, EventArgs e) // ProcessExit has limited time (~3s on Windows), so we can only wait briefly. // Thread.Sleep is appropriate here: we're on a synchronous event handler thread // and just need a simple delay — no need to involve the task scheduler. - Thread.Sleep(Defaults.ProcessExitHookDelay); + Thread.Sleep(TUnitSettings.Default.Timeouts.ProcessExitHookDelay); } } diff --git a/TUnit.Core/Executors/DedicatedThreadExecutor.cs b/TUnit.Core/Executors/DedicatedThreadExecutor.cs index d12c6f80c5..e7a35ad015 100644 --- a/TUnit.Core/Executors/DedicatedThreadExecutor.cs +++ b/TUnit.Core/Executors/DedicatedThreadExecutor.cs @@ -1,5 +1,6 @@ using TUnit.Core.Helpers; using TUnit.Core.Interfaces; +using TUnit.Core.Settings; namespace TUnit.Core; @@ -346,15 +347,16 @@ public override void Send(SendOrPostCallback d, object? state) // Use a more robust synchronous wait pattern to avoid deadlocks // We use Task.Run to ensure we don't capture the current SynchronizationContext // which is a common cause of deadlocks + var timeout = TUnitSettings.Default.Timeouts.DefaultTestTimeout; var waitTask = Task.Run(async () => { // For .NET Standard 2.0 compatibility, use Task.Delay for timeout - var timeoutTask = Task.Delay(Defaults.TestTimeout); + var timeoutTask = Task.Delay(timeout); var completedTask = await Task.WhenAny(tcs.Task, timeoutTask).ConfigureAwait(false); if (completedTask == timeoutTask) { - throw new TimeoutException($"Synchronous operation on dedicated thread timed out after {Defaults.TestTimeout.TotalMinutes} minutes"); + throw new TimeoutException($"Synchronous operation on dedicated thread timed out after {timeout.TotalMinutes} minutes"); } // Await the actual task to get its result or exception diff --git a/TUnit.Core/Hooks/HookMethod.cs b/TUnit.Core/Hooks/HookMethod.cs index 045720c3c8..4e3737fd0c 100644 --- a/TUnit.Core/Hooks/HookMethod.cs +++ b/TUnit.Core/Hooks/HookMethod.cs @@ -2,6 +2,7 @@ using System.Reflection; using TUnit.Core.Extensions; using TUnit.Core.Interfaces; +using TUnit.Core.Settings; namespace TUnit.Core.Hooks; @@ -25,10 +26,12 @@ public abstract record HookMethod public TAttribute? GetAttribute() where TAttribute : Attribute => Attributes.OfType().FirstOrDefault(); /// - /// Gets the timeout for this hook method. This will be set during hook registration - /// by the event receiver infrastructure, falling back to the default 5-minute timeout. + /// Gets the timeout for this hook method. When null, the engine falls back to + /// . + /// at execution time, so discovery-hook configuration is respected. + /// Set explicitly by the [Timeout] attribute or event receiver infrastructure. /// - public TimeSpan? Timeout { get; internal set; } = Defaults.HookTimeout; + public TimeSpan? Timeout { get; internal set; } private IHookExecutor _hookExecutor = DefaultExecutor.Instance; private bool _hookExecutorIsExplicit; diff --git a/TUnit.Core/Models/BeforeTestDiscoveryContext.cs b/TUnit.Core/Models/BeforeTestDiscoveryContext.cs index 5a1044c19a..0e2ef607d0 100644 --- a/TUnit.Core/Models/BeforeTestDiscoveryContext.cs +++ b/TUnit.Core/Models/BeforeTestDiscoveryContext.cs @@ -1,3 +1,5 @@ +using TUnit.Core.Settings; + namespace TUnit.Core; /// @@ -29,6 +31,12 @@ internal BeforeTestDiscoveryContext() : base(GlobalContext.Current) /// public required string? TestFilter { get; init; } + /// + /// Programmatic settings for TUnit. Configure these here to establish project-level defaults + /// before any tests are discovered or executed. + /// + public TUnitSettings Settings => TUnitSettings.Default; + internal override void SetAsyncLocalContext() { Current = this; diff --git a/TUnit.Core/Settings/DisplaySettings.cs b/TUnit.Core/Settings/DisplaySettings.cs new file mode 100644 index 0000000000..6b74af96f1 --- /dev/null +++ b/TUnit.Core/Settings/DisplaySettings.cs @@ -0,0 +1,15 @@ +namespace TUnit.Core.Settings; + +/// +/// Controls visual output settings. +/// +public sealed class DisplaySettings +{ + internal DisplaySettings() { } + + /// + /// Whether to show full stack traces including TUnit internals. Default: false. + /// Precedence: --detailed-stacktrace → TUnitSettings → built-in default. + /// + public bool DetailedStackTrace { get; set; } +} diff --git a/TUnit.Core/Settings/ExecutionSettings.cs b/TUnit.Core/Settings/ExecutionSettings.cs new file mode 100644 index 0000000000..faf3fb1cd0 --- /dev/null +++ b/TUnit.Core/Settings/ExecutionSettings.cs @@ -0,0 +1,15 @@ +namespace TUnit.Core.Settings; + +/// +/// Controls test run behavior. +/// +public sealed class ExecutionSettings +{ + internal ExecutionSettings() { } + + /// + /// Whether to cancel the test run after the first test failure. Default: false. + /// Precedence: --fail-fast → TUnitSettings → built-in default. + /// + public bool FailFast { get; set; } +} diff --git a/TUnit.Core/Settings/ParallelismSettings.cs b/TUnit.Core/Settings/ParallelismSettings.cs new file mode 100644 index 0000000000..c3e4834cd5 --- /dev/null +++ b/TUnit.Core/Settings/ParallelismSettings.cs @@ -0,0 +1,30 @@ +namespace TUnit.Core.Settings; + +/// +/// Controls concurrent test execution. +/// +public sealed class ParallelismSettings +{ + internal ParallelismSettings() { } + + /// + /// Maximum number of tests to run in parallel. Default: null (= 4× CPU cores). + /// Precedence: --maximum-parallel-testsTUNIT_MAX_PARALLEL_TESTS → TUnitSettings → built-in default. + /// + public int? MaximumParallelTests + { + get => _maximumParallelTests; + set + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), value, + "MaximumParallelTests must be null, 0 (unlimited), or a positive number."); + } + + _maximumParallelTests = value; + } + } + + private int? _maximumParallelTests; +} diff --git a/TUnit.Core/Settings/TUnitSettings.cs b/TUnit.Core/Settings/TUnitSettings.cs new file mode 100644 index 0000000000..cc15574a89 --- /dev/null +++ b/TUnit.Core/Settings/TUnitSettings.cs @@ -0,0 +1,41 @@ +namespace TUnit.Core.Settings; + +/// +/// Programmatic configuration for TUnit. Access via context.Settings in a +/// [Before(HookType.TestDiscovery)] hook to establish project-level defaults. +/// +/// Precedence: CLI flag → environment variable → → built-in default. +/// +/// +/// Threading: All settings should be configured before test execution begins +/// (typically in a [Before(HookType.TestDiscovery)] hook). The framework ensures +/// hook completion happens-before test threads start, so no additional synchronization +/// is required. Modifying settings during parallel test execution is not supported. +/// +/// +public sealed class TUnitSettings +{ + internal static TUnitSettings Default { get; } = new(); + + internal TUnitSettings() { } + + /// + /// Default timeouts for tests and hooks. + /// + public TimeoutSettings Timeouts { get; } = new(); + + /// + /// Controls concurrent test execution. + /// + public ParallelismSettings Parallelism { get; } = new(); + + /// + /// Controls visual output. + /// + public DisplaySettings Display { get; } = new(); + + /// + /// Controls test run behavior. + /// + public ExecutionSettings Execution { get; } = new(); +} diff --git a/TUnit.Core/Settings/TimeoutSettings.cs b/TUnit.Core/Settings/TimeoutSettings.cs new file mode 100644 index 0000000000..74d006dff6 --- /dev/null +++ b/TUnit.Core/Settings/TimeoutSettings.cs @@ -0,0 +1,87 @@ +namespace TUnit.Core.Settings; + +/// +/// Default timeouts applied when no [Timeout] attribute is specified. +/// These are project-level defaults — CLI flags and environment variables take precedence. +/// +public sealed class TimeoutSettings +{ + internal TimeoutSettings() { } + + /// + /// Default timeout for individual tests. Default: 30 minutes. + /// Overridden per-test by . + /// Precedence: CLI/env var (N/A for test timeout) → TUnitSettings → built-in default. + /// + public TimeSpan DefaultTestTimeout + { + get => _defaultTestTimeout; + set + { + ValidatePositive(value); + _defaultTestTimeout = value; + } + } + + /// + /// Default timeout for hook methods (Before/After at every level). Default: 5 minutes. + /// Overridden per-hook by . + /// + public TimeSpan DefaultHookTimeout + { + get => _defaultHookTimeout; + set + { + ValidatePositive(value); + _defaultHookTimeout = value; + } + } + + /// + /// Time allowed for graceful shutdown after cancellation (Ctrl+C / SIGTERM) + /// before the process is forcefully terminated. Default: 30 seconds. + /// + public TimeSpan ForcefulExitTimeout + { + get => _forcefulExitTimeout; + set + { + ValidatePositive(value); + _forcefulExitTimeout = value; + } + } + + /// + /// Brief delay during process exit to allow After hooks registered via + /// to execute. Default: 500ms. + /// Set to to disable the delay. + /// + public TimeSpan ProcessExitHookDelay + { + get => _processExitHookDelay; + set + { + if (value < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(value), value, + "ProcessExitHookDelay cannot be negative."); + } + + _processExitHookDelay = value; + } + } + + private TimeSpan _defaultTestTimeout = TimeSpan.FromMinutes(30); + private TimeSpan _defaultHookTimeout = TimeSpan.FromMinutes(5); + private TimeSpan _forcefulExitTimeout = TimeSpan.FromSeconds(30); + private TimeSpan _processExitHookDelay = TimeSpan.FromMilliseconds(500); + + private static void ValidatePositive(TimeSpan value) + { + if (value <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(value), value, + "Timeout must be a positive duration."); + } + } +} diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index b1f447c867..2b972170db 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -1078,8 +1078,7 @@ private async ValueTask CreateTestContextAsync(string testId, TestM AttributesByType = attributes.ToAttributeDictionary(), MethodGenericArguments = testData.ResolvedMethodGenericArguments, ClassGenericArguments = testData.ResolvedClassGenericArguments, - Timeout = Core.Defaults.TestTimeout // Default timeout (can be overridden by TimeoutAttribute) - // Don't set RetryLimit here - let discovery event receivers set it + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout }; var context = _contextProvider.CreateTestContext( @@ -1172,7 +1171,7 @@ private TestDetails CreateFailedTestDetails(TestMetadata metadata, string testId ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = AttributeDictionaryHelper.Empty, - Timeout = Core.Defaults.TestTimeout + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout }; } diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 2a1e2db8e3..4b44a3c206 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -254,8 +254,7 @@ private async Task GenerateDynamicTests(TestMetadata m ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = attributes.ToAttributeDictionary(), - Timeout = Core.Defaults.TestTimeout // Default timeout (can be overridden by TimeoutAttribute) - // Don't set RetryLimit here - let discovery event receivers set it + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout }; var testBuilderContext = CreateTestBuilderContext(metadata); @@ -382,8 +381,7 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad ReturnType = typeof(Task), MethodMetadata = resolvedMetadata.MethodMetadata, AttributesByType = attributes.ToAttributeDictionary(), - Timeout = Core.Defaults.TestTimeout // Default timeout (can be overridden by TimeoutAttribute) - // Don't set Timeout and RetryLimit here - let discovery event receivers set them + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout }; var context = _contextProvider.CreateTestContext( @@ -462,7 +460,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = AttributeDictionaryHelper.Empty, - Timeout = Core.Defaults.TestTimeout // Default timeout + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout }; var context = _contextProvider.CreateTestContext( @@ -515,7 +513,7 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = AttributeDictionaryHelper.Empty, - Timeout = Core.Defaults.TestTimeout // Default timeout + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout }; var context = _contextProvider.CreateTestContext( diff --git a/TUnit.Engine/Helpers/HookTimeoutHelper.cs b/TUnit.Engine/Helpers/HookTimeoutHelper.cs index 8f821771ad..45677966cd 100644 --- a/TUnit.Engine/Helpers/HookTimeoutHelper.cs +++ b/TUnit.Engine/Helpers/HookTimeoutHelper.cs @@ -1,9 +1,12 @@ using TUnit.Core.Hooks; +using TUnit.Core.Settings; namespace TUnit.Engine.Helpers; /// -/// Helper class for executing hooks with timeout enforcement +/// Helper class for executing hooks with timeout enforcement. +/// When no explicit timeout is set on a hook, falls back to +/// ... /// internal static class HookTimeoutHelper { @@ -15,14 +18,9 @@ public static Task CreateTimeoutHookAction( T context, CancellationToken cancellationToken) { - var timeout = hook.Timeout; + var timeout = hook.Timeout ?? TUnitSettings.Default.Timeouts.DefaultHookTimeout; - if (timeout == null) - { - return hook.ExecuteAsync(context, cancellationToken).AsTask(); - } - - var timeoutMs = (int)timeout.Value.TotalMilliseconds; + var timeoutMs = (int)timeout.TotalMilliseconds; return CreateTimeoutHookActionAsync(hook, context, timeoutMs, cancellationToken); @@ -57,13 +55,8 @@ public static Func CreateTimeoutHookAction( string hookName, CancellationToken cancellationToken) { - if (timeout == null) - { - // No timeout specified, execute normally - return async () => await hookDelegate(context, cancellationToken); - } - - var timeoutMs = (int)timeout.Value.TotalMilliseconds; + var effectiveTimeout = timeout ?? TUnitSettings.Default.Timeouts.DefaultHookTimeout; + var timeoutMs = (int)effectiveTimeout.TotalMilliseconds; return async () => { @@ -83,9 +76,9 @@ public static Func CreateTimeoutHookAction( } /// - /// Creates a timeout-aware action wrapper for a hook delegate that returns ValueTask - /// This overload is used for instance hooks (InstanceHookMethod) - /// Custom executor handling for instance hooks is done in HookDelegateBuilder.CreateInstanceHookDelegateAsync + /// Creates a timeout-aware action wrapper for a hook delegate that returns ValueTask. + /// This overload is used for instance hooks (InstanceHookMethod). + /// Custom executor handling for instance hooks is done in HookDelegateBuilder.CreateInstanceHookDelegateAsync. /// public static Func CreateTimeoutHookAction( Func hookDelegate, @@ -94,13 +87,8 @@ public static Func CreateTimeoutHookAction( string hookName, CancellationToken cancellationToken) { - if (timeout == null) - { - // No timeout specified, execute normally - return async () => await hookDelegate(context, cancellationToken); - } - - var timeoutMs = (int)timeout.Value.TotalMilliseconds; + var effectiveTimeout = timeout ?? TUnitSettings.Default.Timeouts.DefaultHookTimeout; + var timeoutMs = (int)effectiveTimeout.TotalMilliseconds; return async () => { diff --git a/TUnit.Engine/Scheduling/TestRunner.cs b/TUnit.Engine/Scheduling/TestRunner.cs index 9191170a41..264199c436 100644 --- a/TUnit.Engine/Scheduling/TestRunner.cs +++ b/TUnit.Engine/Scheduling/TestRunner.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using TUnit.Core; +using TUnit.Core.Settings; using TUnit.Engine.Interfaces; using TUnit.Engine.Logging; using TUnit.Engine.Services.TestExecution; @@ -92,7 +93,7 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca // TestCoordinator handles sending InProgress message await _testCoordinator.ExecuteTestAsync(test, cancellationToken).ConfigureAwait(false); - if (_isFailFastEnabled && test.Result?.State == TestState.Failed) + if ((_isFailFastEnabled || TUnitSettings.Default.Execution.FailFast) && test.Result?.State == TestState.Failed) { // Capture the first failure exception before triggering cancellation if (test.Result.Exception != null) @@ -109,7 +110,7 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca // We only need to handle fail-fast logic here await _logger.LogErrorAsync($"Unhandled exception in test {test.TestId}: {ex}").ConfigureAwait(false); - if (_isFailFastEnabled) + if (_isFailFastEnabled || TUnitSettings.Default.Execution.FailFast) { // Capture the first failure exception before triggering cancellation Interlocked.CompareExchange(ref _firstFailFastException, ex, null); diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 03fd014e20..6a25deefc2 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -9,6 +9,7 @@ using TUnit.Engine.Logging; using TUnit.Engine.Models; using TUnit.Engine.Services; +using TUnit.Core.Settings; using TUnit.Engine.Services.TestExecution; namespace TUnit.Engine.Scheduling; @@ -27,8 +28,8 @@ internal sealed class TestScheduler : ITestScheduler private readonly AfterHookPairTracker _afterHookPairTracker; private readonly StaticPropertyHandler _staticPropertyHandler; private readonly IDynamicTestQueue _dynamicTestQueue; - private readonly int _maxParallelism; - private readonly SemaphoreSlim? _maxParallelismSemaphore; + private readonly Lazy _maxParallelism; + private readonly Lazy _maxParallelismSemaphore; public TestScheduler( TUnitFrameworkLogger logger, @@ -58,11 +59,12 @@ public TestScheduler( _staticPropertyHandler = staticPropertyHandler; _dynamicTestQueue = dynamicTestQueue; - _maxParallelism = GetMaxParallelism(logger, commandLineOptions); + _maxParallelism = new Lazy(() => GetMaxParallelism(logger, commandLineOptions)); - _maxParallelismSemaphore = _maxParallelism == int.MaxValue - ? null - : new SemaphoreSlim(_maxParallelism, _maxParallelism); + _maxParallelismSemaphore = new Lazy(() => + _maxParallelism.Value == int.MaxValue + ? null + : new SemaphoreSlim(_maxParallelism.Value, _maxParallelism.Value)); } #if NET8_0_OR_GREATER @@ -308,9 +310,10 @@ private async Task ExecuteTestsAsync( AbstractExecutableTest[] tests, CancellationToken cancellationToken) { - if (_maxParallelismSemaphore != null) + var semaphore = _maxParallelismSemaphore.Value; + if (semaphore != null) { - await ExecuteWithGlobalLimitAsync(tests, cancellationToken).ConfigureAwait(false); + await ExecuteWithGlobalLimitAsync(tests, semaphore, cancellationToken).ConfigureAwait(false); } else { @@ -381,8 +384,11 @@ private async Task ExecuteSequentiallyAsync( private async Task ExecuteWithGlobalLimitAsync( AbstractExecutableTest[] tests, + SemaphoreSlim globalSemaphore, CancellationToken cancellationToken) { + var maxParallelism = _maxParallelism.Value; + #if NET8_0_OR_GREATER // PERFORMANCE OPTIMIZATION: Partition tests by whether they have parallel limiters // Tests without limiters can run with unlimited parallelism (avoiding global semaphore overhead) @@ -403,11 +409,11 @@ private async Task ExecuteWithGlobalLimitAsync( // Execute both groups concurrently var limitedTask = testsWithLimiters.Count > 0 - ? ExecuteWithLimitAsync(testsWithLimiters, cancellationToken) + ? ExecuteWithLimitAsync(testsWithLimiters, maxParallelism, cancellationToken) : Task.CompletedTask; var unlimitedTask = testsWithoutLimiters.Count > 0 - ? ExecuteUnlimitedAsync(testsWithoutLimiters, cancellationToken) + ? ExecuteUnlimitedAsync(testsWithoutLimiters, maxParallelism, cancellationToken) : Task.CompletedTask; await Task.WhenAll(limitedTask, unlimitedTask).ConfigureAwait(false); @@ -421,7 +427,7 @@ private async Task ExecuteWithGlobalLimitAsync( { SemaphoreSlim? parallelLimiterSemaphore = null; - await _maxParallelismSemaphore!.WaitAsync(cancellationToken).ConfigureAwait(false); + await globalSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (test.Context.ParallelLimiter != null) @@ -442,7 +448,7 @@ private async Task ExecuteWithGlobalLimitAsync( } finally { - _maxParallelismSemaphore.Release(); + globalSemaphore.Release(); } }, CancellationToken.None); } @@ -453,6 +459,7 @@ private async Task ExecuteWithGlobalLimitAsync( #if NET8_0_OR_GREATER private async Task ExecuteWithLimitAsync( List tests, + int maxParallelism, CancellationToken cancellationToken) { // Execute tests with parallel limiters using the global limit @@ -460,7 +467,7 @@ await Parallel.ForEachAsync( tests, new ParallelOptions { - MaxDegreeOfParallelism = _maxParallelism, + MaxDegreeOfParallelism = maxParallelism, CancellationToken = cancellationToken }, async (test, ct) => @@ -483,6 +490,7 @@ await Parallel.ForEachAsync( private async Task ExecuteUnlimitedAsync( List tests, + int maxParallelism, CancellationToken cancellationToken) { // Execute tests without per-test limiters, but still apply global parallelism limit @@ -490,7 +498,7 @@ await Parallel.ForEachAsync( tests, new ParallelOptions { - MaxDegreeOfParallelism = _maxParallelism, + MaxDegreeOfParallelism = maxParallelism, CancellationToken = cancellationToken }, async (test, ct) => @@ -562,6 +570,19 @@ private static int GetMaxParallelism(ILogger logger, ICommandLineOptions command } } + // Check TUnitSettings (third priority — code-level project defaults) + if (TUnitSettings.Default.Parallelism.MaximumParallelTests is { } codeLimit) + { + if (codeLimit == 0) + { + logger.LogDebug("Maximum parallel tests: unlimited (from TUnitSettings)"); + return int.MaxValue; + } + + logger.LogDebug($"Maximum parallel tests limit set to {codeLimit} (from TUnitSettings)"); + return codeLimit; + } + // Default: 4x CPU cores (empirically optimized for async/IO-bound workloads) // Users can override via --maximum-parallel-tests or TUNIT_MAX_PARALLEL_TESTS var defaultLimit = Environment.ProcessorCount * 4; diff --git a/TUnit.Engine/TUnitMessageBus.cs b/TUnit.Engine/TUnitMessageBus.cs index 24b9bb2fa4..c229367024 100644 --- a/TUnit.Engine/TUnitMessageBus.cs +++ b/TUnit.Engine/TUnitMessageBus.cs @@ -5,6 +5,7 @@ using Microsoft.Testing.Platform.Services; using Microsoft.Testing.Platform.TestHost; using TUnit.Core; +using TUnit.Core.Settings; using TUnit.Engine.CommandLineProviders; using TUnit.Engine.Enums; using TUnit.Engine.Exceptions; @@ -85,6 +86,7 @@ private Exception SimplifyStacktrace(Exception exception) { // Check both the legacy --detailed-stacktrace flag and the new verbosity system if (commandLineOptions.IsOptionSet(DetailedStacktraceCommandProvider.DetailedStackTrace) || + TUnitSettings.Default.Display.DetailedStackTrace || verbosityService?.ShowDetailedStackTrace == true) { return exception; diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 8b52caf557..609fdc029d 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -213,6 +213,7 @@ namespace public class BeforeTestDiscoveryContext : .Context { public .GlobalContext GlobalContext { get; } + public . Settings { get; } public required string? TestFilter { get; init; } public new static .BeforeTestDiscoveryContext? Current { get; } } @@ -528,12 +529,17 @@ namespace public static readonly .DefaultExecutor Instance; protected override . ExecuteAsync(<.> action) { } } + [("Use instead.")] public static class Defaults { - public static readonly ForcefulExitTimeout; - public static readonly HookTimeout; - public static readonly ProcessExitHookDelay; - public static readonly TestTimeout; + [("Use .ForcefulExitTimeout instead.")] + public static ForcefulExitTimeout { get; } + [("Use .DefaultHookTimeout instead.")] + public static HookTimeout { get; } + [("Use .ProcessExitHookDelay instead.")] + public static ProcessExitHookDelay { get; } + [("Use .DefaultTestTimeout instead.")] + public static TestTimeout { get; } } public abstract class DependencyInjectionDataSourceAttribute : .UntypedDataSourceGeneratorAttribute { @@ -2914,6 +2920,35 @@ namespace .Services public object? GetService( serviceType) { } } } +namespace .Settings +{ + public sealed class DisplaySettings + { + public bool DetailedStackTrace { get; set; } + } + public sealed class ExecutionSettings + { + public bool FailFast { get; set; } + } + public sealed class ParallelismSettings + { + public int? MaximumParallelTests { get; set; } + } + public sealed class TUnitSettings + { + public . Display { get; } + public . Execution { get; } + public . Parallelism { get; } + public . Timeouts { get; } + } + public sealed class TimeoutSettings + { + public DefaultHookTimeout { get; set; } + public DefaultTestTimeout { get; set; } + public ForcefulExitTimeout { get; set; } + public ProcessExitHookDelay { get; set; } + } +} namespace .StaticProperties { public sealed class StaticPropertyMetadata diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 0622541817..f6b9f26f4e 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -213,6 +213,7 @@ namespace public class BeforeTestDiscoveryContext : .Context { public .GlobalContext GlobalContext { get; } + public . Settings { get; } public required string? TestFilter { get; init; } public new static .BeforeTestDiscoveryContext? Current { get; } } @@ -528,12 +529,17 @@ namespace public static readonly .DefaultExecutor Instance; protected override . ExecuteAsync(<.> action) { } } + [("Use instead.")] public static class Defaults { - public static readonly ForcefulExitTimeout; - public static readonly HookTimeout; - public static readonly ProcessExitHookDelay; - public static readonly TestTimeout; + [("Use .ForcefulExitTimeout instead.")] + public static ForcefulExitTimeout { get; } + [("Use .DefaultHookTimeout instead.")] + public static HookTimeout { get; } + [("Use .ProcessExitHookDelay instead.")] + public static ProcessExitHookDelay { get; } + [("Use .DefaultTestTimeout instead.")] + public static TestTimeout { get; } } public abstract class DependencyInjectionDataSourceAttribute : .UntypedDataSourceGeneratorAttribute { @@ -2914,6 +2920,35 @@ namespace .Services public object? GetService( serviceType) { } } } +namespace .Settings +{ + public sealed class DisplaySettings + { + public bool DetailedStackTrace { get; set; } + } + public sealed class ExecutionSettings + { + public bool FailFast { get; set; } + } + public sealed class ParallelismSettings + { + public int? MaximumParallelTests { get; set; } + } + public sealed class TUnitSettings + { + public . Display { get; } + public . Execution { get; } + public . Parallelism { get; } + public . Timeouts { get; } + } + public sealed class TimeoutSettings + { + public DefaultHookTimeout { get; set; } + public DefaultTestTimeout { get; set; } + public ForcefulExitTimeout { get; set; } + public ProcessExitHookDelay { get; set; } + } +} namespace .StaticProperties { public sealed class StaticPropertyMetadata diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 0a1d3fcbe5..7eb932e7c6 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -213,6 +213,7 @@ namespace public class BeforeTestDiscoveryContext : .Context { public .GlobalContext GlobalContext { get; } + public . Settings { get; } public required string? TestFilter { get; init; } public new static .BeforeTestDiscoveryContext? Current { get; } } @@ -528,12 +529,17 @@ namespace public static readonly .DefaultExecutor Instance; protected override . ExecuteAsync(<.> action) { } } + [("Use instead.")] public static class Defaults { - public static readonly ForcefulExitTimeout; - public static readonly HookTimeout; - public static readonly ProcessExitHookDelay; - public static readonly TestTimeout; + [("Use .ForcefulExitTimeout instead.")] + public static ForcefulExitTimeout { get; } + [("Use .DefaultHookTimeout instead.")] + public static HookTimeout { get; } + [("Use .ProcessExitHookDelay instead.")] + public static ProcessExitHookDelay { get; } + [("Use .DefaultTestTimeout instead.")] + public static TestTimeout { get; } } public abstract class DependencyInjectionDataSourceAttribute : .UntypedDataSourceGeneratorAttribute { @@ -2914,6 +2920,35 @@ namespace .Services public object? GetService( serviceType) { } } } +namespace .Settings +{ + public sealed class DisplaySettings + { + public bool DetailedStackTrace { get; set; } + } + public sealed class ExecutionSettings + { + public bool FailFast { get; set; } + } + public sealed class ParallelismSettings + { + public int? MaximumParallelTests { get; set; } + } + public sealed class TUnitSettings + { + public . Display { get; } + public . Execution { get; } + public . Parallelism { get; } + public . Timeouts { get; } + } + public sealed class TimeoutSettings + { + public DefaultHookTimeout { get; set; } + public DefaultTestTimeout { get; set; } + public ForcefulExitTimeout { get; set; } + public ProcessExitHookDelay { get; set; } + } +} namespace .StaticProperties { public sealed class StaticPropertyMetadata diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 0266604c25..3a655edd46 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -210,6 +210,7 @@ namespace public class BeforeTestDiscoveryContext : .Context { public .GlobalContext GlobalContext { get; } + public . Settings { get; } public required string? TestFilter { get; init; } public new static .BeforeTestDiscoveryContext? Current { get; } } @@ -507,12 +508,17 @@ namespace public static readonly .DefaultExecutor Instance; protected override . ExecuteAsync(<.> action) { } } + [("Use instead.")] public static class Defaults { - public static readonly ForcefulExitTimeout; - public static readonly HookTimeout; - public static readonly ProcessExitHookDelay; - public static readonly TestTimeout; + [("Use .ForcefulExitTimeout instead.")] + public static ForcefulExitTimeout { get; } + [("Use .DefaultHookTimeout instead.")] + public static HookTimeout { get; } + [("Use .ProcessExitHookDelay instead.")] + public static ProcessExitHookDelay { get; } + [("Use .DefaultTestTimeout instead.")] + public static TestTimeout { get; } } public abstract class DependencyInjectionDataSourceAttribute : .UntypedDataSourceGeneratorAttribute { @@ -2839,6 +2845,35 @@ namespace .Services public object? GetService( serviceType) { } } } +namespace .Settings +{ + public sealed class DisplaySettings + { + public bool DetailedStackTrace { get; set; } + } + public sealed class ExecutionSettings + { + public bool FailFast { get; set; } + } + public sealed class ParallelismSettings + { + public int? MaximumParallelTests { get; set; } + } + public sealed class TUnitSettings + { + public . Display { get; } + public . Execution { get; } + public . Parallelism { get; } + public . Timeouts { get; } + } + public sealed class TimeoutSettings + { + public DefaultHookTimeout { get; set; } + public DefaultTestTimeout { get; set; } + public ForcefulExitTimeout { get; set; } + public ProcessExitHookDelay { get; set; } + } +} namespace .StaticProperties { public sealed class StaticPropertyMetadata diff --git a/TUnit.UnitTests/TUnitSettingsTests.cs b/TUnit.UnitTests/TUnitSettingsTests.cs new file mode 100644 index 0000000000..a300d453de --- /dev/null +++ b/TUnit.UnitTests/TUnitSettingsTests.cs @@ -0,0 +1,60 @@ +using TUnit.Core.Settings; + +namespace TUnit.UnitTests; + +// [NotInParallel] because tests mutate static TUnitSettings state; +// Before/After hooks snapshot and restore values so test order doesn't matter. +[NotInParallel] +public class TUnitSettingsTests +{ + private TimeSpan _savedTestTimeout; + private TimeSpan _savedHookTimeout; + private TimeSpan _savedForcefulExitTimeout; + private TimeSpan _savedProcessExitHookDelay; + private int? _savedMaximumParallelTests; + private bool _savedDetailedStackTrace; + private bool _savedFailFast; + + [Before(HookType.Test)] + public void SnapshotSettings() + { + _savedTestTimeout = TUnitSettings.Default.Timeouts.DefaultTestTimeout; + _savedHookTimeout = TUnitSettings.Default.Timeouts.DefaultHookTimeout; + _savedForcefulExitTimeout = TUnitSettings.Default.Timeouts.ForcefulExitTimeout; + _savedProcessExitHookDelay = TUnitSettings.Default.Timeouts.ProcessExitHookDelay; + _savedMaximumParallelTests = TUnitSettings.Default.Parallelism.MaximumParallelTests; + _savedDetailedStackTrace = TUnitSettings.Default.Display.DetailedStackTrace; + _savedFailFast = TUnitSettings.Default.Execution.FailFast; + } + + [After(HookType.Test)] + public void RestoreSettings() + { + TUnitSettings.Default.Timeouts.DefaultTestTimeout = _savedTestTimeout; + TUnitSettings.Default.Timeouts.DefaultHookTimeout = _savedHookTimeout; + TUnitSettings.Default.Timeouts.ForcefulExitTimeout = _savedForcefulExitTimeout; + TUnitSettings.Default.Timeouts.ProcessExitHookDelay = _savedProcessExitHookDelay; + TUnitSettings.Default.Parallelism.MaximumParallelTests = _savedMaximumParallelTests; + TUnitSettings.Default.Display.DetailedStackTrace = _savedDetailedStackTrace; + TUnitSettings.Default.Execution.FailFast = _savedFailFast; + } + + [Test] + public async Task Defaults_Are_Correct() + { + await Assert.That(TUnitSettings.Default.Timeouts.DefaultTestTimeout).IsEqualTo(TimeSpan.FromMinutes(30)); + await Assert.That(TUnitSettings.Default.Timeouts.DefaultHookTimeout).IsEqualTo(TimeSpan.FromMinutes(5)); + await Assert.That(TUnitSettings.Default.Timeouts.ForcefulExitTimeout).IsEqualTo(TimeSpan.FromSeconds(30)); + await Assert.That(TUnitSettings.Default.Timeouts.ProcessExitHookDelay).IsEqualTo(TimeSpan.FromMilliseconds(500)); + await Assert.That(TUnitSettings.Default.Parallelism.MaximumParallelTests).IsNull(); + await Assert.That(TUnitSettings.Default.Display.DetailedStackTrace).IsFalse(); + await Assert.That(TUnitSettings.Default.Execution.FailFast).IsFalse(); + } + + [Test] + public async Task Settings_Can_Be_Modified() + { + TUnitSettings.Default.Timeouts.DefaultTestTimeout = TimeSpan.FromMinutes(10); + await Assert.That(TUnitSettings.Default.Timeouts.DefaultTestTimeout).IsEqualTo(TimeSpan.FromMinutes(10)); + } +} diff --git a/docs/docs/execution/parallelism.md b/docs/docs/execution/parallelism.md index 42efb21b0b..8ca7dc464b 100644 --- a/docs/docs/execution/parallelism.md +++ b/docs/docs/execution/parallelism.md @@ -167,6 +167,20 @@ With a limit of `2`, at most two of these 20 test invocations execute at the sam More specific attributes override less specific ones. Precedence: Method > Class > Assembly. +## Setting Maximum Parallel Tests + +You can cap the total number of concurrent tests globally using the command line or an environment variable: + +```bash +# Command-line flag +dotnet run --project MyTests -- --maximum-parallel-tests 4 + +# Environment variable +TUNIT_MAX_PARALLEL_TESTS=4 dotnet run --project MyTests +``` + +You can also set this programmatically via `context.Settings.Parallelism.MaximumParallelTests` in a `[Before(HookType.TestDiscovery)]` hook. See the [Programmatic Configuration](/docs/reference/programmatic-configuration) reference for details. + ## When to Use Which | Scenario | Attribute | diff --git a/docs/docs/reference/command-line-flags.md b/docs/docs/reference/command-line-flags.md index 13aac8fc16..2e86de5f3e 100644 --- a/docs/docs/reference/command-line-flags.md +++ b/docs/docs/reference/command-line-flags.md @@ -80,9 +80,11 @@ Please note that for the coverage and trx report, you need to install [additiona --fail-fast Cancel the test run after the first test failure + Programmatic equivalent: context.Settings.Execution.FailFast --maximum-parallel-tests Maximum Parallel Tests + Programmatic equivalent: context.Settings.Parallelism.MaximumParallelTests --no-ansi Disable outputting ANSI escape characters to screen. @@ -122,6 +124,7 @@ Please note that for the coverage and trx report, you need to install [additiona --detailed-stacktrace Display TUnit internals within stack traces. By default, TUnit frames are hidden to keep failure output focused on user code. + Programmatic equivalent: context.Settings.Display.DetailedStackTrace --output-json Write a JSON report of the test run. diff --git a/docs/docs/reference/environment-variables.md b/docs/docs/reference/environment-variables.md index b2e27c4d53..30ce7ab154 100644 --- a/docs/docs/reference/environment-variables.md +++ b/docs/docs/reference/environment-variables.md @@ -95,6 +95,8 @@ export TUNIT_MAX_PARALLEL_TESTS=0 # Unlimited parallelism **Equivalent to:** `--maximum-parallel-tests` +**Programmatic equivalent:** `context.Settings.Parallelism.MaximumParallelTests` (see [Programmatic Configuration](./programmatic-configuration.md)) + **Note:** Command-line arguments take precedence over environment variables. ### TUNIT_EXECUTION_MODE @@ -253,7 +255,8 @@ When the same setting is configured in multiple places, TUnit follows this prior 1. **Command-line arguments** - Always take precedence 2. **Environment variables** - Applied when command-line argument is not provided -3. **Configuration files** - Applied as defaults +3. **`context.Settings` (code)** - Values set in `[Before(HookType.TestDiscovery)]` hooks (see [Programmatic Configuration](./programmatic-configuration.md)) +4. **Built-in defaults** ## Summary Table diff --git a/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md new file mode 100644 index 0000000000..266cc7f75b --- /dev/null +++ b/docs/docs/reference/programmatic-configuration.md @@ -0,0 +1,99 @@ +--- +sidebar_position: 4 +--- + +# Programmatic Configuration + +## Overview + +The `context.Settings` API lets you configure TUnit settings directly in code. This is useful when you want discoverable, version-controlled defaults for your test suite without relying on command-line flags or environment variables. + +Settings are organized into logical groups: + +- `Timeouts` — test and hook timeout durations +- `Parallelism` — concurrent test execution limits +- `Execution` — runtime behavior such as fail-fast +- `Display` — output and display options + +## Usage + +Set values inside a `[Before(HookType.TestDiscovery)]` hook so they are applied before any tests are discovered or executed. The `context.Settings` property provides direct access: + +```csharp +using TUnit.Core; + +public class TestSetup +{ + [Before(HookType.TestDiscovery)] + public static Task Configure(BeforeTestDiscoveryContext context) + { + context.Settings.Timeouts.DefaultTestTimeout = TimeSpan.FromMinutes(5); + context.Settings.Timeouts.DefaultHookTimeout = TimeSpan.FromMinutes(2); + context.Settings.Execution.FailFast = true; + + return Task.CompletedTask; + } +} +``` + +Place this class anywhere in your test project. TUnit will discover and run the hook automatically. + +Settings are accessed exclusively through `context.Settings` in the discovery hook, which ensures they are configured at the correct point in the TUnit lifecycle. + +## Settings Reference + +### `context.Settings.Timeouts` + +| Property | Type | Default | Description | +|---|---|---|---| +| `DefaultTestTimeout` | `TimeSpan` | 30 minutes | Maximum duration for a single test before it is cancelled. | +| `DefaultHookTimeout` | `TimeSpan` | 5 minutes | Maximum duration for a single hook (`[Before]`/`[After]`) before it is cancelled. | +| `ForcefulExitTimeout` | `TimeSpan` | 30 seconds | Grace period before the process is forcefully terminated after a cancellation. | +| `ProcessExitHookDelay` | `TimeSpan` | 500 ms | Delay before process-exit hooks run, allowing pending I/O to flush. | + +### `context.Settings.Parallelism` + +| Property | Type | Default | Description | +|---|---|---|---| +| `MaximumParallelTests` | `int?` | `null` (4 x CPU cores) | Maximum number of tests that can execute concurrently. Set to `null` to use the default heuristic. | + +### `context.Settings.Display` + +| Property | Type | Default | Description | +|---|---|---|---| +| `DetailedStackTrace` | `bool` | `false` | Includes TUnit internal frames in stack traces. By default, internal frames are hidden to keep failure output focused on user code. | + +### `context.Settings.Execution` + +| Property | Type | Default | Description | +|---|---|---|---| +| `FailFast` | `bool` | `false` | Cancels the remaining test run after the first test failure. | + +## Precedence + +When the same setting is configured in multiple places, the following priority order applies (highest wins): + +1. **Command-line flag** (e.g., `--maximum-parallel-tests 8`) +2. **Environment variable** (e.g., `TUNIT_MAX_PARALLEL_TESTS=8`) +3. **`context.Settings` (code)** — values set in a `[Before(HookType.TestDiscovery)]` hook +4. **Built-in default** + +### Example + +Your test project sets a conservative parallelism limit in code: + +```csharp +context.Settings.Parallelism.MaximumParallelTests = 1; +``` + +A developer on a powerful machine can override this for a local run without changing code: + +```bash +dotnet run --project MyTests -- --maximum-parallel-tests 8 +``` + +The command-line flag takes precedence, so 8 parallel tests will be used. + +## When to Set + +Set most values via `context.Settings` inside a `[Before(HookType.TestDiscovery)]` hook. This is the earliest point in the TUnit lifecycle where user code runs and ensures your values are in place before test discovery begins. Setting values later (for example in a `[Before(HookType.TestSession)]` hook) may have no effect for settings that are read during discovery.