From a5d57a479cef19eb23b3b3fb2ef03e28d9a566d8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:07:50 +0100 Subject: [PATCH 01/21] feat: add TUnitSettings static API classes (#5521) --- TUnit.Core/Settings/DisplaySettings.cs | 19 ++++++++++++ TUnit.Core/Settings/ExecutionSettings.cs | 13 +++++++++ TUnit.Core/Settings/ParallelismSettings.cs | 13 +++++++++ TUnit.Core/Settings/TUnitSettings.cs | 31 ++++++++++++++++++++ TUnit.Core/Settings/TimeoutSettings.cs | 34 ++++++++++++++++++++++ 5 files changed, 110 insertions(+) create mode 100644 TUnit.Core/Settings/DisplaySettings.cs create mode 100644 TUnit.Core/Settings/ExecutionSettings.cs create mode 100644 TUnit.Core/Settings/ParallelismSettings.cs create mode 100644 TUnit.Core/Settings/TUnitSettings.cs create mode 100644 TUnit.Core/Settings/TimeoutSettings.cs diff --git a/TUnit.Core/Settings/DisplaySettings.cs b/TUnit.Core/Settings/DisplaySettings.cs new file mode 100644 index 0000000000..023f803c5d --- /dev/null +++ b/TUnit.Core/Settings/DisplaySettings.cs @@ -0,0 +1,19 @@ +namespace TUnit.Core.Settings; + +/// +/// Controls visual output settings. +/// +public sealed class DisplaySettings +{ + /// + /// Whether to suppress the TUnit banner logo. Default: false. + /// Precedence: --disable-logoTUNIT_DISABLE_LOGO → TUnitSettings → built-in default. + /// + public bool DisableLogo { get; set; } + + /// + /// 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..5608eda326 --- /dev/null +++ b/TUnit.Core/Settings/ExecutionSettings.cs @@ -0,0 +1,13 @@ +namespace TUnit.Core.Settings; + +/// +/// Controls test run behavior. +/// +public sealed class 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..9d81d7d6b0 --- /dev/null +++ b/TUnit.Core/Settings/ParallelismSettings.cs @@ -0,0 +1,13 @@ +namespace TUnit.Core.Settings; + +/// +/// Controls concurrent test execution. +/// +public sealed class 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; set; } +} diff --git a/TUnit.Core/Settings/TUnitSettings.cs b/TUnit.Core/Settings/TUnitSettings.cs new file mode 100644 index 0000000000..6837d5f85d --- /dev/null +++ b/TUnit.Core/Settings/TUnitSettings.cs @@ -0,0 +1,31 @@ +namespace TUnit.Core.Settings; + +/// +/// Programmatic configuration for TUnit. Set these in a +/// [Before(HookType.TestDiscovery)] hook to establish project-level defaults. +/// +/// Precedence: CLI flag → environment variable → → built-in default. +/// +/// +public static class TUnitSettings +{ + /// + /// Default timeouts for tests and hooks. + /// + public static TimeoutSettings Timeouts { get; } = new(); + + /// + /// Controls concurrent test execution. + /// + public static ParallelismSettings Parallelism { get; } = new(); + + /// + /// Controls visual output. + /// + public static DisplaySettings Display { get; } = new(); + + /// + /// Controls test run behavior. + /// + public static ExecutionSettings Execution { get; } = new(); +} diff --git a/TUnit.Core/Settings/TimeoutSettings.cs b/TUnit.Core/Settings/TimeoutSettings.cs new file mode 100644 index 0000000000..fc35a0828d --- /dev/null +++ b/TUnit.Core/Settings/TimeoutSettings.cs @@ -0,0 +1,34 @@ +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 +{ + /// + /// 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; set; } = TimeSpan.FromMinutes(30); + + /// + /// Default timeout for hook methods (Before/After at every level). Default: 5 minutes. + /// Overridden per-hook by . + /// Precedence: CLI/env var (N/A for hook timeout) → TUnitSettings → built-in default. + /// + public TimeSpan DefaultHookTimeout { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Time allowed for graceful shutdown after cancellation (Ctrl+C / SIGTERM) + /// before the process is forcefully terminated. Default: 30 seconds. + /// + public TimeSpan ForcefulExitTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Brief delay during process exit to allow After hooks registered via + /// to execute. Default: 500ms. + /// + public TimeSpan ProcessExitHookDelay { get; set; } = TimeSpan.FromMilliseconds(500); +} From eb9c582eeca8e2cfc473ed62801f17dd44496401 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:12:39 +0100 Subject: [PATCH 02/21] refactor: deprecate Defaults class and switch internal reads to TUnitSettings (#5521) Mark the Defaults class and all its fields as [Obsolete], pointing users to TUnitSettings.Timeouts.* equivalents. Migrate all internal references from Defaults.* to Settings.TUnitSettings.Timeouts.* to eliminate CS0618 warnings under TreatWarningsAsErrors. Also fix a namespace collision in NuGetDownloader.cs caused by the new TUnit.Core.Settings namespace. --- TUnit.Core.SourceGenerator.Tests/NuGetDownloader.cs | 2 +- TUnit.Core/Defaults.cs | 7 +++++++ TUnit.Core/EngineCancellationToken.cs | 4 ++-- TUnit.Core/Executors/DedicatedThreadExecutor.cs | 4 ++-- TUnit.Core/Hooks/HookMethod.cs | 2 +- TUnit.Engine/Building/TestBuilder.cs | 4 ++-- TUnit.Engine/Building/TestBuilderPipeline.cs | 8 ++++---- 7 files changed, 19 insertions(+), 12 deletions(-) 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..449788fc9a 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 . /// + [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultTestTimeout)} instead.")] public static readonly TimeSpan TestTimeout = TimeSpan.FromMinutes(30); /// /// Default timeout applied to hook methods (Before/After at every level) /// when no explicit timeout is configured. /// + [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultHookTimeout)} instead.")] public static readonly TimeSpan HookTimeout = TimeSpan.FromMinutes(5); /// /// Time allowed for a graceful shutdown after a cancellation request (Ctrl+C / SIGTERM) /// before the process is forcefully terminated. /// + [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ForcefulExitTimeout)} instead.")] public static readonly TimeSpan ForcefulExitTimeout = TimeSpan.FromSeconds(30); /// /// Brief delay during process exit to allow After hooks registered via /// to execute before the process terminates. /// + [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ProcessExitHookDelay)} instead.")] public static readonly TimeSpan ProcessExitHookDelay = TimeSpan.FromMilliseconds(500); } diff --git a/TUnit.Core/EngineCancellationToken.cs b/TUnit.Core/EngineCancellationToken.cs index 4236d62080..419ea64853 100644 --- a/TUnit.Core/EngineCancellationToken.cs +++ b/TUnit.Core/EngineCancellationToken.cs @@ -62,7 +62,7 @@ private void Cancel() _forcefulExitStarted = true; // Start a new forceful exit timer - _ = Task.Delay(Defaults.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t => + _ = Task.Delay(Settings.TUnitSettings.Timeouts.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t => { if (!t.IsCanceled) { @@ -86,7 +86,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(Settings.TUnitSettings.Timeouts.ProcessExitHookDelay); } } diff --git a/TUnit.Core/Executors/DedicatedThreadExecutor.cs b/TUnit.Core/Executors/DedicatedThreadExecutor.cs index d12c6f80c5..e0f82ed8a8 100644 --- a/TUnit.Core/Executors/DedicatedThreadExecutor.cs +++ b/TUnit.Core/Executors/DedicatedThreadExecutor.cs @@ -349,12 +349,12 @@ public override void Send(SendOrPostCallback d, object? state) 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(Settings.TUnitSettings.Timeouts.DefaultTestTimeout); 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 {Settings.TUnitSettings.Timeouts.DefaultTestTimeout.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..a05dda5a9f 100644 --- a/TUnit.Core/Hooks/HookMethod.cs +++ b/TUnit.Core/Hooks/HookMethod.cs @@ -28,7 +28,7 @@ public abstract record HookMethod /// 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. /// - public TimeSpan? Timeout { get; internal set; } = Defaults.HookTimeout; + public TimeSpan? Timeout { get; internal set; } = Settings.TUnitSettings.Timeouts.DefaultHookTimeout; private IHookExecutor _hookExecutor = DefaultExecutor.Instance; private bool _hookExecutorIsExplicit; diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index b1f447c867..91cdfa28f8 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -1078,7 +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) + Timeout = Core.Settings.TUnitSettings.Timeouts.DefaultTestTimeout // Default timeout (can be overridden by TimeoutAttribute) // Don't set RetryLimit here - let discovery event receivers set it }; @@ -1172,7 +1172,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.Timeouts.DefaultTestTimeout }; } diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 2a1e2db8e3..7fa3b5de97 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -254,7 +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) + Timeout = Core.Settings.TUnitSettings.Timeouts.DefaultTestTimeout // Default timeout (can be overridden by TimeoutAttribute) // Don't set RetryLimit here - let discovery event receivers set it }; @@ -382,7 +382,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) + Timeout = Core.Settings.TUnitSettings.Timeouts.DefaultTestTimeout // Default timeout (can be overridden by TimeoutAttribute) // Don't set Timeout and RetryLimit here - let discovery event receivers set them }; @@ -462,7 +462,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = AttributeDictionaryHelper.Empty, - Timeout = Core.Defaults.TestTimeout // Default timeout + Timeout = Core.Settings.TUnitSettings.Timeouts.DefaultTestTimeout // Default timeout }; var context = _contextProvider.CreateTestContext( @@ -515,7 +515,7 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = AttributeDictionaryHelper.Empty, - Timeout = Core.Defaults.TestTimeout // Default timeout + Timeout = Core.Settings.TUnitSettings.Timeouts.DefaultTestTimeout // Default timeout }; var context = _contextProvider.CreateTestContext( From e5624484fe3b3b577d70f53c54f60e9f8b4c83a9 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:16:18 +0100 Subject: [PATCH 03/21] feat: wire TUnitSettings into engine for parallelism, fail-fast, and display (#5521) --- TUnit.Engine/Capabilities/BannerCapability.cs | 2 ++ TUnit.Engine/Framework/TUnitServiceProvider.cs | 4 +++- TUnit.Engine/Scheduling/TestScheduler.cs | 17 +++++++++++++++++ TUnit.Engine/TUnitMessageBus.cs | 2 ++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/TUnit.Engine/Capabilities/BannerCapability.cs b/TUnit.Engine/Capabilities/BannerCapability.cs index c6d25b747b..427cea8e26 100644 --- a/TUnit.Engine/Capabilities/BannerCapability.cs +++ b/TUnit.Engine/Capabilities/BannerCapability.cs @@ -5,6 +5,7 @@ using Microsoft.Testing.Platform.CommandLine; using Microsoft.Testing.Platform.Logging; using Microsoft.Testing.Platform.Services; +using TUnit.Core.Settings; using TUnit.Engine.CommandLineProviders; using TUnit.Engine.Configuration; using TUnit.Engine.Enums; @@ -25,6 +26,7 @@ internal class BannerCapability(IPlatformInformation platformInformation, IComma { if (commandLineOptions.IsOptionSet(DisableLogoCommandProvider.DisableLogo) || Environment.GetEnvironmentVariable(EnvironmentConstants.DisableLogo) is not null + || TUnitSettings.Display.DisableLogo || loggerFactory.CreateLogger(nameof(BannerCapability)).IsEnabled(LogLevel.Information)) { return Task.FromResult(GetRuntimeDetails()); diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index 92e6a5ac82..d18ff7e130 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -17,6 +17,7 @@ using TUnit.Engine.Building.Collectors; using TUnit.Engine.Configuration; using TUnit.Engine.Building.Interfaces; +using TUnit.Core.Settings; using TUnit.Engine.CommandLineProviders; using TUnit.Engine.Discovery; using TUnit.Engine.Extensions; @@ -236,7 +237,8 @@ public TUnitServiceProvider(IExtension extension, // Create the HookOrchestratingTestExecutorAdapter // Note: We'll need to update this to handle dynamic dependencies properly var sessionUid = context.Request.Session.SessionUid; - var isFailFastEnabled = CommandLineOptions.TryGetOptionArgumentList(FailFastCommandProvider.FailFast, out _); + var isFailFastEnabled = CommandLineOptions.TryGetOptionArgumentList(FailFastCommandProvider.FailFast, out _) + || TUnitSettings.Execution.FailFast; FailFastCancellationSource = Register(new CancellationTokenSource()); var testRunner = Register( diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 03fd014e20..aba36a31b4 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; @@ -562,6 +563,22 @@ private static int GetMaxParallelism(ILogger logger, ICommandLineOptions command } } + // Check TUnitSettings (third priority — code-level project defaults) + if (TUnitSettings.Parallelism.MaximumParallelTests is { } codeLimit) + { + if (codeLimit == 0) + { + logger.LogDebug("Maximum parallel tests: unlimited (from TUnitSettings)"); + return int.MaxValue; + } + + if (codeLimit > 0) + { + 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..c4c4beb946 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.Display.DetailedStackTrace || verbosityService?.ShowDetailedStackTrace == true) { return exception; From e5aebc4aac4482ace6b96c2c477bb5d1323d37dd Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:21:05 +0100 Subject: [PATCH 04/21] test: update public API snapshots for TUnitSettings (#5521) --- ...Has_No_API_Changes.DotNet10_0.verified.txt | 39 +++++++++++++++++++ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 39 +++++++++++++++++++ ..._Has_No_API_Changes.DotNet9_0.verified.txt | 39 +++++++++++++++++++ ...ary_Has_No_API_Changes.Net4_7.verified.txt | 39 +++++++++++++++++++ 4 files changed, 156 insertions(+) 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..ccef21e893 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 @@ -528,11 +528,16 @@ namespace public static readonly .DefaultExecutor Instance; protected override . ExecuteAsync(<.> action) { } } + [("Use instead.")] public static class Defaults { + [("Use .ForcefulExitTimeout instead.")] public static readonly ForcefulExitTimeout; + [("Use .DefaultHookTimeout instead.")] public static readonly HookTimeout; + [("Use .ProcessExitHookDelay instead.")] public static readonly ProcessExitHookDelay; + [("Use .DefaultTestTimeout instead.")] public static readonly TestTimeout; } public abstract class DependencyInjectionDataSourceAttribute : .UntypedDataSourceGeneratorAttribute @@ -2914,6 +2919,40 @@ namespace .Services public object? GetService( serviceType) { } } } +namespace .Settings +{ + public sealed class DisplaySettings + { + public DisplaySettings() { } + public bool DetailedStackTrace { get; set; } + public bool DisableLogo { get; set; } + } + public sealed class ExecutionSettings + { + public ExecutionSettings() { } + public bool FailFast { get; set; } + } + public sealed class ParallelismSettings + { + public ParallelismSettings() { } + public int? MaximumParallelTests { get; set; } + } + public static class TUnitSettings + { + public static . Display { get; } + public static . Execution { get; } + public static . Parallelism { get; } + public static . Timeouts { get; } + } + public sealed class TimeoutSettings + { + public 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..d4ccc0cb72 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 @@ -528,11 +528,16 @@ namespace public static readonly .DefaultExecutor Instance; protected override . ExecuteAsync(<.> action) { } } + [("Use instead.")] public static class Defaults { + [("Use .ForcefulExitTimeout instead.")] public static readonly ForcefulExitTimeout; + [("Use .DefaultHookTimeout instead.")] public static readonly HookTimeout; + [("Use .ProcessExitHookDelay instead.")] public static readonly ProcessExitHookDelay; + [("Use .DefaultTestTimeout instead.")] public static readonly TestTimeout; } public abstract class DependencyInjectionDataSourceAttribute : .UntypedDataSourceGeneratorAttribute @@ -2914,6 +2919,40 @@ namespace .Services public object? GetService( serviceType) { } } } +namespace .Settings +{ + public sealed class DisplaySettings + { + public DisplaySettings() { } + public bool DetailedStackTrace { get; set; } + public bool DisableLogo { get; set; } + } + public sealed class ExecutionSettings + { + public ExecutionSettings() { } + public bool FailFast { get; set; } + } + public sealed class ParallelismSettings + { + public ParallelismSettings() { } + public int? MaximumParallelTests { get; set; } + } + public static class TUnitSettings + { + public static . Display { get; } + public static . Execution { get; } + public static . Parallelism { get; } + public static . Timeouts { get; } + } + public sealed class TimeoutSettings + { + public 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..dd80f490a1 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 @@ -528,11 +528,16 @@ namespace public static readonly .DefaultExecutor Instance; protected override . ExecuteAsync(<.> action) { } } + [("Use instead.")] public static class Defaults { + [("Use .ForcefulExitTimeout instead.")] public static readonly ForcefulExitTimeout; + [("Use .DefaultHookTimeout instead.")] public static readonly HookTimeout; + [("Use .ProcessExitHookDelay instead.")] public static readonly ProcessExitHookDelay; + [("Use .DefaultTestTimeout instead.")] public static readonly TestTimeout; } public abstract class DependencyInjectionDataSourceAttribute : .UntypedDataSourceGeneratorAttribute @@ -2914,6 +2919,40 @@ namespace .Services public object? GetService( serviceType) { } } } +namespace .Settings +{ + public sealed class DisplaySettings + { + public DisplaySettings() { } + public bool DetailedStackTrace { get; set; } + public bool DisableLogo { get; set; } + } + public sealed class ExecutionSettings + { + public ExecutionSettings() { } + public bool FailFast { get; set; } + } + public sealed class ParallelismSettings + { + public ParallelismSettings() { } + public int? MaximumParallelTests { get; set; } + } + public static class TUnitSettings + { + public static . Display { get; } + public static . Execution { get; } + public static . Parallelism { get; } + public static . Timeouts { get; } + } + public sealed class TimeoutSettings + { + public 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..57cab48467 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 @@ -507,11 +507,16 @@ namespace public static readonly .DefaultExecutor Instance; protected override . ExecuteAsync(<.> action) { } } + [("Use instead.")] public static class Defaults { + [("Use .ForcefulExitTimeout instead.")] public static readonly ForcefulExitTimeout; + [("Use .DefaultHookTimeout instead.")] public static readonly HookTimeout; + [("Use .ProcessExitHookDelay instead.")] public static readonly ProcessExitHookDelay; + [("Use .DefaultTestTimeout instead.")] public static readonly TestTimeout; } public abstract class DependencyInjectionDataSourceAttribute : .UntypedDataSourceGeneratorAttribute @@ -2839,6 +2844,40 @@ namespace .Services public object? GetService( serviceType) { } } } +namespace .Settings +{ + public sealed class DisplaySettings + { + public DisplaySettings() { } + public bool DetailedStackTrace { get; set; } + public bool DisableLogo { get; set; } + } + public sealed class ExecutionSettings + { + public ExecutionSettings() { } + public bool FailFast { get; set; } + } + public sealed class ParallelismSettings + { + public ParallelismSettings() { } + public int? MaximumParallelTests { get; set; } + } + public static class TUnitSettings + { + public static . Display { get; } + public static . Execution { get; } + public static . Parallelism { get; } + public static . Timeouts { get; } + } + public sealed class TimeoutSettings + { + public TimeoutSettings() { } + public DefaultHookTimeout { get; set; } + public DefaultTestTimeout { get; set; } + public ForcefulExitTimeout { get; set; } + public ProcessExitHookDelay { get; set; } + } +} namespace .StaticProperties { public sealed class StaticPropertyMetadata From 27f31fa66f50010393bb121001543020f1f6170f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:23:02 +0100 Subject: [PATCH 05/21] test: add TUnitSettings unit tests (#5521) --- TUnit.UnitTests/TUnitSettingsTests.cs | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 TUnit.UnitTests/TUnitSettingsTests.cs diff --git a/TUnit.UnitTests/TUnitSettingsTests.cs b/TUnit.UnitTests/TUnitSettingsTests.cs new file mode 100644 index 0000000000..5402a9ce96 --- /dev/null +++ b/TUnit.UnitTests/TUnitSettingsTests.cs @@ -0,0 +1,36 @@ +using TUnit.Core.Settings; + +namespace TUnit.UnitTests; + +[NotInParallel] +public class TUnitSettingsTests +{ + [Test] + public async Task Defaults_Are_Correct() + { + await Assert.That(TUnitSettings.Timeouts.DefaultTestTimeout).IsEqualTo(TimeSpan.FromMinutes(30)); + await Assert.That(TUnitSettings.Timeouts.DefaultHookTimeout).IsEqualTo(TimeSpan.FromMinutes(5)); + await Assert.That(TUnitSettings.Timeouts.ForcefulExitTimeout).IsEqualTo(TimeSpan.FromSeconds(30)); + await Assert.That(TUnitSettings.Timeouts.ProcessExitHookDelay).IsEqualTo(TimeSpan.FromMilliseconds(500)); + await Assert.That(TUnitSettings.Parallelism.MaximumParallelTests).IsNull(); + await Assert.That(TUnitSettings.Display.DisableLogo).IsFalse(); + await Assert.That(TUnitSettings.Display.DetailedStackTrace).IsFalse(); + await Assert.That(TUnitSettings.Execution.FailFast).IsFalse(); + } + + [Test] + public async Task Settings_Can_Be_Modified() + { + var originalTimeout = TUnitSettings.Timeouts.DefaultTestTimeout; + + try + { + TUnitSettings.Timeouts.DefaultTestTimeout = TimeSpan.FromMinutes(10); + await Assert.That(TUnitSettings.Timeouts.DefaultTestTimeout).IsEqualTo(TimeSpan.FromMinutes(10)); + } + finally + { + TUnitSettings.Timeouts.DefaultTestTimeout = originalTimeout; + } + } +} From 970b8b6b57d2e5a95978e688aa8585d8bebd69af Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:25:24 +0100 Subject: [PATCH 06/21] docs: add programmatic configuration documentation (#5521) --- docs/docs/execution/parallelism.md | 21 ++++ docs/docs/reference/command-line-flags.md | 4 + docs/docs/reference/environment-variables.md | 7 +- .../reference/programmatic-configuration.md | 101 ++++++++++++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 docs/docs/reference/programmatic-configuration.md diff --git a/docs/docs/execution/parallelism.md b/docs/docs/execution/parallelism.md index 42efb21b0b..59bbabd724 100644 --- a/docs/docs/execution/parallelism.md +++ b/docs/docs/execution/parallelism.md @@ -167,6 +167,27 @@ 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 in Code + +You can cap the total number of concurrent tests globally using the `TUnitSettings` API. Set the value in a `[Before(HookType.TestDiscovery)]` hook: + +```csharp +using TUnit.Core; +using TUnit.Core.Settings; + +public class TestSetup +{ + [Before(HookType.TestDiscovery)] + public static Task Configure(BeforeTestDiscoveryContext context) + { + TUnitSettings.Parallelism.MaximumParallelTests = 4; + return Task.CompletedTask; + } +} +``` + +This is equivalent to passing `--maximum-parallel-tests 4` on the command line or setting the `TUNIT_MAX_PARALLEL_TESTS=4` environment variable. Command-line flags and environment variables take precedence over code-level settings. See the [Programmatic Configuration](/docs/reference/programmatic-configuration) reference for the full precedence rules. + ## 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..20f0f9754a 100644 --- a/docs/docs/reference/command-line-flags.md +++ b/docs/docs/reference/command-line-flags.md @@ -77,12 +77,15 @@ Please note that for the coverage and trx report, you need to install [additiona --disable-logo Disables the TUnit logo when starting a test session. Can also be set via TUNIT_DISABLE_LOGO environment variable. + Programmatic equivalent: TUnitSettings.Display.DisableLogo --fail-fast Cancel the test run after the first test failure + Programmatic equivalent: TUnitSettings.Execution.FailFast --maximum-parallel-tests Maximum Parallel Tests + Programmatic equivalent: TUnitSettings.Parallelism.MaximumParallelTests --no-ansi Disable outputting ANSI escape characters to screen. @@ -122,6 +125,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: TUnitSettings.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..2b81f18aca 100644 --- a/docs/docs/reference/environment-variables.md +++ b/docs/docs/reference/environment-variables.md @@ -21,6 +21,8 @@ set TUNIT_DISABLE_LOGO=true **Equivalent to:** `--disable-logo` +**Programmatic equivalent:** `TUnitSettings.Display.DisableLogo` (see [Programmatic Configuration](./programmatic-configuration.md)) + **Use case:** Reduces output noise in CI/CD logs or when using AI/LLM coding assistants that parse test output. ### TUNIT_DISABLE_GITHUB_REPORTER @@ -95,6 +97,8 @@ export TUNIT_MAX_PARALLEL_TESTS=0 # Unlimited parallelism **Equivalent to:** `--maximum-parallel-tests` +**Programmatic equivalent:** `TUnitSettings.Parallelism.MaximumParallelTests` (see [Programmatic Configuration](./programmatic-configuration.md)) + **Note:** Command-line arguments take precedence over environment variables. ### TUNIT_EXECUTION_MODE @@ -253,7 +257,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. **`TUnitSettings` (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..85d4c999db --- /dev/null +++ b/docs/docs/reference/programmatic-configuration.md @@ -0,0 +1,101 @@ +--- +sidebar_position: 4 +--- + +# Programmatic Configuration + +## Overview + +The `TUnitSettings` 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: + +- `TUnitSettings.Timeouts` — test and hook timeout durations +- `TUnitSettings.Parallelism` — concurrent test execution limits +- `TUnitSettings.Execution` — runtime behavior such as fail-fast +- `TUnitSettings.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: + +```csharp +using TUnit.Core; +using TUnit.Core.Settings; + +public class TestSetup +{ + [Before(HookType.TestDiscovery)] + public static Task Configure(BeforeTestDiscoveryContext context) + { + TUnitSettings.Timeouts.DefaultTestTimeout = TimeSpan.FromMinutes(5); + TUnitSettings.Timeouts.DefaultHookTimeout = TimeSpan.FromMinutes(2); + TUnitSettings.Parallelism.MaximumParallelTests = 4; + TUnitSettings.Execution.FailFast = true; + TUnitSettings.Display.DisableLogo = true; + + return Task.CompletedTask; + } +} +``` + +Place this class anywhere in your test project. TUnit will discover and run the hook automatically. + +## Settings Reference + +### `TUnitSettings.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. | + +### `TUnitSettings.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. | + +### `TUnitSettings.Display` + +| Property | Type | Default | Description | +|---|---|---|---| +| `DisableLogo` | `bool` | `false` | Suppresses the TUnit ASCII art logo at startup. | +| `DetailedStackTrace` | `bool` | `false` | Includes TUnit internal frames in stack traces. By default, internal frames are hidden to keep failure output focused on user code. | + +### `TUnitSettings.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. **`TUnitSettings` (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 +TUnitSettings.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 + +Always set `TUnitSettings` values inside a `[Before(HookType.TestDiscovery)]` hook. This is the earliest point in the TUnit lifecycle 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. From e745bf398cfe03c4bed506237633dc51f51486a5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:37:59 +0100 Subject: [PATCH 07/21] refactor: eliminate redundant defaults and simplify Settings access paths (#5521) - Defaults.cs fields now delegate to TUnitSettings.Timeouts (single source of truth) - Add `using TUnit.Core.Settings` to TUnit.Core files for consistent shorter access --- TUnit.Core/Defaults.cs | 8 ++++---- TUnit.Core/EngineCancellationToken.cs | 8 +++++--- TUnit.Core/Executors/DedicatedThreadExecutor.cs | 5 +++-- TUnit.Core/Hooks/HookMethod.cs | 3 ++- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/TUnit.Core/Defaults.cs b/TUnit.Core/Defaults.cs index 449788fc9a..a6c65743f8 100644 --- a/TUnit.Core/Defaults.cs +++ b/TUnit.Core/Defaults.cs @@ -14,26 +14,26 @@ public static class Defaults /// Can be overridden per-test via . /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultTestTimeout)} instead.")] - public static readonly TimeSpan TestTimeout = TimeSpan.FromMinutes(30); + public static readonly TimeSpan TestTimeout = TUnitSettings.Timeouts.DefaultTestTimeout; /// /// Default timeout applied to hook methods (Before/After at every level) /// when no explicit timeout is configured. /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultHookTimeout)} instead.")] - public static readonly TimeSpan HookTimeout = TimeSpan.FromMinutes(5); + public static readonly TimeSpan HookTimeout = TUnitSettings.Timeouts.DefaultHookTimeout; /// /// Time allowed for a graceful shutdown after a cancellation request (Ctrl+C / SIGTERM) /// before the process is forcefully terminated. /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ForcefulExitTimeout)} instead.")] - public static readonly TimeSpan ForcefulExitTimeout = TimeSpan.FromSeconds(30); + public static readonly TimeSpan ForcefulExitTimeout = TUnitSettings.Timeouts.ForcefulExitTimeout; /// /// Brief delay during process exit to allow After hooks registered via /// to execute before the process terminates. /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ProcessExitHookDelay)} instead.")] - public static readonly TimeSpan ProcessExitHookDelay = TimeSpan.FromMilliseconds(500); + public static readonly TimeSpan ProcessExitHookDelay = TUnitSettings.Timeouts.ProcessExitHookDelay; } diff --git a/TUnit.Core/EngineCancellationToken.cs b/TUnit.Core/EngineCancellationToken.cs index 419ea64853..c5a76c74cf 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(Settings.TUnitSettings.Timeouts.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t => + _ = Task.Delay(TUnitSettings.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(Settings.TUnitSettings.Timeouts.ProcessExitHookDelay); + Thread.Sleep(TUnitSettings.Timeouts.ProcessExitHookDelay); } } diff --git a/TUnit.Core/Executors/DedicatedThreadExecutor.cs b/TUnit.Core/Executors/DedicatedThreadExecutor.cs index e0f82ed8a8..51df22807b 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; @@ -349,12 +350,12 @@ public override void Send(SendOrPostCallback d, object? state) var waitTask = Task.Run(async () => { // For .NET Standard 2.0 compatibility, use Task.Delay for timeout - var timeoutTask = Task.Delay(Settings.TUnitSettings.Timeouts.DefaultTestTimeout); + var timeoutTask = Task.Delay(TUnitSettings.Timeouts.DefaultTestTimeout); var completedTask = await Task.WhenAny(tcs.Task, timeoutTask).ConfigureAwait(false); if (completedTask == timeoutTask) { - throw new TimeoutException($"Synchronous operation on dedicated thread timed out after {Settings.TUnitSettings.Timeouts.DefaultTestTimeout.TotalMinutes} minutes"); + throw new TimeoutException($"Synchronous operation on dedicated thread timed out after {TUnitSettings.Timeouts.DefaultTestTimeout.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 a05dda5a9f..2439dee576 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; @@ -28,7 +29,7 @@ public abstract record HookMethod /// 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. /// - public TimeSpan? Timeout { get; internal set; } = Settings.TUnitSettings.Timeouts.DefaultHookTimeout; + public TimeSpan? Timeout { get; internal set; } = TUnitSettings.Timeouts.DefaultHookTimeout; private IHookExecutor _hookExecutor = DefaultExecutor.Instance; private bool _hookExecutorIsExplicit; From d90e152c87c297cb58223c1294c3393aa0355e4a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:52:39 +0100 Subject: [PATCH 08/21] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20remove=20DisableLogo,=20lazy=20FailFast,=20validate?= =?UTF-8?q?=20parallelism=20(#5521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove DisplaySettings.DisableLogo (banner fires before discovery hooks, making it useless as a code setting) - Make FailFast check lazy in TestRunner (reads TUnitSettings.Execution.FailFast at failure time, not at construction) - Add setter validation on MaximumParallelTests (reject negative values) - Document that MaximumParallelTests is read before discovery hooks - Update public API snapshots and docs --- TUnit.Core/Settings/DisplaySettings.cs | 6 ----- TUnit.Core/Settings/ParallelismSettings.cs | 23 ++++++++++++++++++- TUnit.Engine/Capabilities/BannerCapability.cs | 2 -- TUnit.Engine/Scheduling/TestRunner.cs | 5 ++-- ...Has_No_API_Changes.DotNet10_0.verified.txt | 1 - ..._Has_No_API_Changes.DotNet8_0.verified.txt | 1 - ..._Has_No_API_Changes.DotNet9_0.verified.txt | 1 - ...ary_Has_No_API_Changes.Net4_7.verified.txt | 1 - TUnit.UnitTests/TUnitSettingsTests.cs | 1 - docs/docs/reference/command-line-flags.md | 1 - docs/docs/reference/environment-variables.md | 2 -- .../reference/programmatic-configuration.md | 2 -- 12 files changed, 25 insertions(+), 21 deletions(-) diff --git a/TUnit.Core/Settings/DisplaySettings.cs b/TUnit.Core/Settings/DisplaySettings.cs index 023f803c5d..e118ed4153 100644 --- a/TUnit.Core/Settings/DisplaySettings.cs +++ b/TUnit.Core/Settings/DisplaySettings.cs @@ -5,12 +5,6 @@ namespace TUnit.Core.Settings; /// public sealed class DisplaySettings { - /// - /// Whether to suppress the TUnit banner logo. Default: false. - /// Precedence: --disable-logoTUNIT_DISABLE_LOGO → TUnitSettings → built-in default. - /// - public bool DisableLogo { get; set; } - /// /// Whether to show full stack traces including TUnit internals. Default: false. /// Precedence: --detailed-stacktrace → TUnitSettings → built-in default. diff --git a/TUnit.Core/Settings/ParallelismSettings.cs b/TUnit.Core/Settings/ParallelismSettings.cs index 9d81d7d6b0..6707225894 100644 --- a/TUnit.Core/Settings/ParallelismSettings.cs +++ b/TUnit.Core/Settings/ParallelismSettings.cs @@ -8,6 +8,27 @@ public sealed class 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. + /// + /// Note: This value is read during scheduler initialization, which occurs before + /// [Before(HookType.TestDiscovery)] hooks run. Set this value in a module initializer + /// or static constructor, or use the --maximum-parallel-tests CLI flag / + /// TUNIT_MAX_PARALLEL_TESTS environment variable instead. + /// /// - public int? MaximumParallelTests { get; set; } + 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.Engine/Capabilities/BannerCapability.cs b/TUnit.Engine/Capabilities/BannerCapability.cs index 427cea8e26..c6d25b747b 100644 --- a/TUnit.Engine/Capabilities/BannerCapability.cs +++ b/TUnit.Engine/Capabilities/BannerCapability.cs @@ -5,7 +5,6 @@ using Microsoft.Testing.Platform.CommandLine; using Microsoft.Testing.Platform.Logging; using Microsoft.Testing.Platform.Services; -using TUnit.Core.Settings; using TUnit.Engine.CommandLineProviders; using TUnit.Engine.Configuration; using TUnit.Engine.Enums; @@ -26,7 +25,6 @@ internal class BannerCapability(IPlatformInformation platformInformation, IComma { if (commandLineOptions.IsOptionSet(DisableLogoCommandProvider.DisableLogo) || Environment.GetEnvironmentVariable(EnvironmentConstants.DisableLogo) is not null - || TUnitSettings.Display.DisableLogo || loggerFactory.CreateLogger(nameof(BannerCapability)).IsEnabled(LogLevel.Information)) { return Task.FromResult(GetRuntimeDetails()); diff --git a/TUnit.Engine/Scheduling/TestRunner.cs b/TUnit.Engine/Scheduling/TestRunner.cs index 9191170a41..2d40608e57 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.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.Execution.FailFast) { // Capture the first failure exception before triggering cancellation Interlocked.CompareExchange(ref _firstFailFastException, ex, null); 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 ccef21e893..6c3a299344 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 @@ -2925,7 +2925,6 @@ namespace .Settings { public DisplaySettings() { } public bool DetailedStackTrace { get; set; } - public bool DisableLogo { get; set; } } public sealed class ExecutionSettings { 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 d4ccc0cb72..00efb70c0e 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 @@ -2925,7 +2925,6 @@ namespace .Settings { public DisplaySettings() { } public bool DetailedStackTrace { get; set; } - public bool DisableLogo { get; set; } } public sealed class ExecutionSettings { 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 dd80f490a1..ad5c9e0020 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 @@ -2925,7 +2925,6 @@ namespace .Settings { public DisplaySettings() { } public bool DetailedStackTrace { get; set; } - public bool DisableLogo { get; set; } } public sealed class ExecutionSettings { 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 57cab48467..e0b1ee71c8 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 @@ -2850,7 +2850,6 @@ namespace .Settings { public DisplaySettings() { } public bool DetailedStackTrace { get; set; } - public bool DisableLogo { get; set; } } public sealed class ExecutionSettings { diff --git a/TUnit.UnitTests/TUnitSettingsTests.cs b/TUnit.UnitTests/TUnitSettingsTests.cs index 5402a9ce96..1655b3f4be 100644 --- a/TUnit.UnitTests/TUnitSettingsTests.cs +++ b/TUnit.UnitTests/TUnitSettingsTests.cs @@ -13,7 +13,6 @@ public async Task Defaults_Are_Correct() await Assert.That(TUnitSettings.Timeouts.ForcefulExitTimeout).IsEqualTo(TimeSpan.FromSeconds(30)); await Assert.That(TUnitSettings.Timeouts.ProcessExitHookDelay).IsEqualTo(TimeSpan.FromMilliseconds(500)); await Assert.That(TUnitSettings.Parallelism.MaximumParallelTests).IsNull(); - await Assert.That(TUnitSettings.Display.DisableLogo).IsFalse(); await Assert.That(TUnitSettings.Display.DetailedStackTrace).IsFalse(); await Assert.That(TUnitSettings.Execution.FailFast).IsFalse(); } diff --git a/docs/docs/reference/command-line-flags.md b/docs/docs/reference/command-line-flags.md index 20f0f9754a..a9803ad2ef 100644 --- a/docs/docs/reference/command-line-flags.md +++ b/docs/docs/reference/command-line-flags.md @@ -77,7 +77,6 @@ Please note that for the coverage and trx report, you need to install [additiona --disable-logo Disables the TUnit logo when starting a test session. Can also be set via TUNIT_DISABLE_LOGO environment variable. - Programmatic equivalent: TUnitSettings.Display.DisableLogo --fail-fast Cancel the test run after the first test failure diff --git a/docs/docs/reference/environment-variables.md b/docs/docs/reference/environment-variables.md index 2b81f18aca..8e5f8be67a 100644 --- a/docs/docs/reference/environment-variables.md +++ b/docs/docs/reference/environment-variables.md @@ -21,8 +21,6 @@ set TUNIT_DISABLE_LOGO=true **Equivalent to:** `--disable-logo` -**Programmatic equivalent:** `TUnitSettings.Display.DisableLogo` (see [Programmatic Configuration](./programmatic-configuration.md)) - **Use case:** Reduces output noise in CI/CD logs or when using AI/LLM coding assistants that parse test output. ### TUNIT_DISABLE_GITHUB_REPORTER diff --git a/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md index 85d4c999db..876afcdb3d 100644 --- a/docs/docs/reference/programmatic-configuration.md +++ b/docs/docs/reference/programmatic-configuration.md @@ -32,7 +32,6 @@ public class TestSetup TUnitSettings.Timeouts.DefaultHookTimeout = TimeSpan.FromMinutes(2); TUnitSettings.Parallelism.MaximumParallelTests = 4; TUnitSettings.Execution.FailFast = true; - TUnitSettings.Display.DisableLogo = true; return Task.CompletedTask; } @@ -62,7 +61,6 @@ Place this class anywhere in your test project. TUnit will discover and run the | Property | Type | Default | Description | |---|---|---|---| -| `DisableLogo` | `bool` | `false` | Suppresses the TUnit ASCII art logo at startup. | | `DetailedStackTrace` | `bool` | `false` | Includes TUnit internal frames in stack traces. By default, internal frames are hidden to keep failure output focused on user code. | ### `TUnitSettings.Execution` From 51cbc33bcb9d4e50fcf1ffc1fd20bdac38952454 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:00:16 +0100 Subject: [PATCH 09/21] =?UTF-8?q?fix:=20address=20second=20review=20?= =?UTF-8?q?=E2=80=94=20clean=20up=20FailFast=20redundancy,=20revert=20Defa?= =?UTF-8?q?ults=20alias,=20fix=20docs=20(#5521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove TUnitSettings.Execution.FailFast from TUnitServiceProvider (eager capture). TestRunner already checks it lazily at failure time — no double-check needed. - Revert Defaults.cs to hardcoded values (static readonly fields can't track mutable TUnitSettings; stale alias is worse than honest deprecation). - Remove MaximumParallelTests from discovery hook example in docs (silently ignored due to scheduler timing) and add a note about the limitation. --- TUnit.Core/Defaults.cs | 8 ++++---- TUnit.Engine/Framework/TUnitServiceProvider.cs | 4 +--- docs/docs/reference/programmatic-configuration.md | 3 ++- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/TUnit.Core/Defaults.cs b/TUnit.Core/Defaults.cs index a6c65743f8..449788fc9a 100644 --- a/TUnit.Core/Defaults.cs +++ b/TUnit.Core/Defaults.cs @@ -14,26 +14,26 @@ public static class Defaults /// Can be overridden per-test via . /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultTestTimeout)} instead.")] - public static readonly TimeSpan TestTimeout = TUnitSettings.Timeouts.DefaultTestTimeout; + public static readonly TimeSpan TestTimeout = TimeSpan.FromMinutes(30); /// /// Default timeout applied to hook methods (Before/After at every level) /// when no explicit timeout is configured. /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultHookTimeout)} instead.")] - public static readonly TimeSpan HookTimeout = TUnitSettings.Timeouts.DefaultHookTimeout; + public static readonly TimeSpan HookTimeout = TimeSpan.FromMinutes(5); /// /// Time allowed for a graceful shutdown after a cancellation request (Ctrl+C / SIGTERM) /// before the process is forcefully terminated. /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ForcefulExitTimeout)} instead.")] - public static readonly TimeSpan ForcefulExitTimeout = TUnitSettings.Timeouts.ForcefulExitTimeout; + public static readonly TimeSpan ForcefulExitTimeout = TimeSpan.FromSeconds(30); /// /// Brief delay during process exit to allow After hooks registered via /// to execute before the process terminates. /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ProcessExitHookDelay)} instead.")] - public static readonly TimeSpan ProcessExitHookDelay = TUnitSettings.Timeouts.ProcessExitHookDelay; + public static readonly TimeSpan ProcessExitHookDelay = TimeSpan.FromMilliseconds(500); } diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index d18ff7e130..92e6a5ac82 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -17,7 +17,6 @@ using TUnit.Engine.Building.Collectors; using TUnit.Engine.Configuration; using TUnit.Engine.Building.Interfaces; -using TUnit.Core.Settings; using TUnit.Engine.CommandLineProviders; using TUnit.Engine.Discovery; using TUnit.Engine.Extensions; @@ -237,8 +236,7 @@ public TUnitServiceProvider(IExtension extension, // Create the HookOrchestratingTestExecutorAdapter // Note: We'll need to update this to handle dynamic dependencies properly var sessionUid = context.Request.Session.SessionUid; - var isFailFastEnabled = CommandLineOptions.TryGetOptionArgumentList(FailFastCommandProvider.FailFast, out _) - || TUnitSettings.Execution.FailFast; + var isFailFastEnabled = CommandLineOptions.TryGetOptionArgumentList(FailFastCommandProvider.FailFast, out _); FailFastCancellationSource = Register(new CancellationTokenSource()); var testRunner = Register( diff --git a/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md index 876afcdb3d..0132794db9 100644 --- a/docs/docs/reference/programmatic-configuration.md +++ b/docs/docs/reference/programmatic-configuration.md @@ -30,7 +30,6 @@ public class TestSetup { TUnitSettings.Timeouts.DefaultTestTimeout = TimeSpan.FromMinutes(5); TUnitSettings.Timeouts.DefaultHookTimeout = TimeSpan.FromMinutes(2); - TUnitSettings.Parallelism.MaximumParallelTests = 4; TUnitSettings.Execution.FailFast = true; return Task.CompletedTask; @@ -57,6 +56,8 @@ Place this class anywhere in your test project. TUnit will discover and run the |---|---|---|---| | `MaximumParallelTests` | `int?` | `null` (4 x CPU cores) | Maximum number of tests that can execute concurrently. Set to `null` to use the default heuristic. | +> **Note:** `MaximumParallelTests` is read during scheduler initialization, which occurs before `[Before(HookType.TestDiscovery)]` hooks run. Use the `--maximum-parallel-tests` CLI flag or the `TUNIT_MAX_PARALLEL_TESTS` environment variable to override this setting. + ### `TUnitSettings.Display` | Property | Type | Default | Description | From c128dd77764f7e3195da69a3eb241bef8d4a8e9a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:07:19 +0100 Subject: [PATCH 10/21] docs: fix misleading parallelism example and document hook timeout ordering (#5521) - Replace discovery-hook example in parallelism.md with CLI/env-var approach (MaximumParallelTests is read before discovery hooks run) - Qualify "When to Set" section to note MaximumParallelTests exception - Document DefaultHookTimeout ordering constraint in XML doc --- TUnit.Core/Settings/TimeoutSettings.cs | 6 ++++- docs/docs/execution/parallelism.md | 23 +++++++------------ .../reference/programmatic-configuration.md | 4 +++- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/TUnit.Core/Settings/TimeoutSettings.cs b/TUnit.Core/Settings/TimeoutSettings.cs index fc35a0828d..804cec570c 100644 --- a/TUnit.Core/Settings/TimeoutSettings.cs +++ b/TUnit.Core/Settings/TimeoutSettings.cs @@ -16,7 +16,11 @@ public sealed class TimeoutSettings /// /// Default timeout for hook methods (Before/After at every level). Default: 5 minutes. /// Overridden per-hook by . - /// Precedence: CLI/env var (N/A for hook timeout) → TUnitSettings → built-in default. + /// + /// Note: Hook methods capture this value at registration time, which occurs before + /// [Before(HookType.TestDiscovery)] hooks run. Use the [Timeout] attribute + /// on individual hook methods for reliable per-hook timeout control. + /// /// public TimeSpan DefaultHookTimeout { get; set; } = TimeSpan.FromMinutes(5); diff --git a/docs/docs/execution/parallelism.md b/docs/docs/execution/parallelism.md index 59bbabd724..9e8ee0ffd0 100644 --- a/docs/docs/execution/parallelism.md +++ b/docs/docs/execution/parallelism.md @@ -167,26 +167,19 @@ 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 in Code +## Setting Maximum Parallel Tests -You can cap the total number of concurrent tests globally using the `TUnitSettings` API. Set the value in a `[Before(HookType.TestDiscovery)]` hook: +You can cap the total number of concurrent tests globally using the command line or an environment variable: -```csharp -using TUnit.Core; -using TUnit.Core.Settings; +```bash +# Command-line flag +dotnet run --project MyTests -- --maximum-parallel-tests 4 -public class TestSetup -{ - [Before(HookType.TestDiscovery)] - public static Task Configure(BeforeTestDiscoveryContext context) - { - TUnitSettings.Parallelism.MaximumParallelTests = 4; - return Task.CompletedTask; - } -} +# Environment variable +TUNIT_MAX_PARALLEL_TESTS=4 dotnet run --project MyTests ``` -This is equivalent to passing `--maximum-parallel-tests 4` on the command line or setting the `TUNIT_MAX_PARALLEL_TESTS=4` environment variable. Command-line flags and environment variables take precedence over code-level settings. See the [Programmatic Configuration](/docs/reference/programmatic-configuration) reference for the full precedence rules. +The `TUnitSettings.Parallelism.MaximumParallelTests` property is also available, but the scheduler reads it before `[Before(HookType.TestDiscovery)]` hooks run, so the CLI flag or environment variable is the recommended approach. See the [Programmatic Configuration](/docs/reference/programmatic-configuration) reference for details. ## When to Use Which diff --git a/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md index 0132794db9..833041bba2 100644 --- a/docs/docs/reference/programmatic-configuration.md +++ b/docs/docs/reference/programmatic-configuration.md @@ -97,4 +97,6 @@ The command-line flag takes precedence, so 8 parallel tests will be used. ## When to Set -Always set `TUnitSettings` values inside a `[Before(HookType.TestDiscovery)]` hook. This is the earliest point in the TUnit lifecycle 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. +Set most `TUnitSettings` values 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. + +The exception is `Parallelism.MaximumParallelTests`, which is read during scheduler initialization before discovery hooks run — use the CLI flag or environment variable for that setting (see the note above). From 521bf56f0b708e85e9d8a64da26424b2e60deff1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:30:36 +0100 Subject: [PATCH 11/21] =?UTF-8?q?fix:=20address=20review=20round=205=20?= =?UTF-8?q?=E2=80=94=20doc=20gap,=20test=20guard,=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document DefaultHookTimeout timing exception in "When to Set" section - Add Before/After hooks to TUnitSettingsTests to snapshot and restore static state, making Defaults_Are_Correct resilient to prior mutations - Remove dead negative-value branch in GetMaxParallelism (setter already validates) --- TUnit.Engine/Scheduling/TestScheduler.cs | 8 ++-- TUnit.UnitTests/TUnitSettingsTests.cs | 45 ++++++++++++++----- .../reference/programmatic-configuration.md | 5 ++- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index aba36a31b4..fb01c455ec 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -572,11 +572,9 @@ private static int GetMaxParallelism(ILogger logger, ICommandLineOptions command return int.MaxValue; } - if (codeLimit > 0) - { - logger.LogDebug($"Maximum parallel tests limit set to {codeLimit} (from TUnitSettings)"); - return codeLimit; - } + // Setter guarantees no negative values + logger.LogDebug($"Maximum parallel tests limit set to {codeLimit} (from TUnitSettings)"); + return codeLimit; } // Default: 4x CPU cores (empirically optimized for async/IO-bound workloads) diff --git a/TUnit.UnitTests/TUnitSettingsTests.cs b/TUnit.UnitTests/TUnitSettingsTests.cs index 1655b3f4be..8f8dab5e4f 100644 --- a/TUnit.UnitTests/TUnitSettingsTests.cs +++ b/TUnit.UnitTests/TUnitSettingsTests.cs @@ -5,6 +5,38 @@ namespace TUnit.UnitTests; [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.Timeouts.DefaultTestTimeout; + _savedHookTimeout = TUnitSettings.Timeouts.DefaultHookTimeout; + _savedForcefulExitTimeout = TUnitSettings.Timeouts.ForcefulExitTimeout; + _savedProcessExitHookDelay = TUnitSettings.Timeouts.ProcessExitHookDelay; + _savedMaximumParallelTests = TUnitSettings.Parallelism.MaximumParallelTests; + _savedDetailedStackTrace = TUnitSettings.Display.DetailedStackTrace; + _savedFailFast = TUnitSettings.Execution.FailFast; + } + + [After(HookType.Test)] + public void RestoreSettings() + { + TUnitSettings.Timeouts.DefaultTestTimeout = _savedTestTimeout; + TUnitSettings.Timeouts.DefaultHookTimeout = _savedHookTimeout; + TUnitSettings.Timeouts.ForcefulExitTimeout = _savedForcefulExitTimeout; + TUnitSettings.Timeouts.ProcessExitHookDelay = _savedProcessExitHookDelay; + TUnitSettings.Parallelism.MaximumParallelTests = _savedMaximumParallelTests; + TUnitSettings.Display.DetailedStackTrace = _savedDetailedStackTrace; + TUnitSettings.Execution.FailFast = _savedFailFast; + } + [Test] public async Task Defaults_Are_Correct() { @@ -20,16 +52,7 @@ public async Task Defaults_Are_Correct() [Test] public async Task Settings_Can_Be_Modified() { - var originalTimeout = TUnitSettings.Timeouts.DefaultTestTimeout; - - try - { - TUnitSettings.Timeouts.DefaultTestTimeout = TimeSpan.FromMinutes(10); - await Assert.That(TUnitSettings.Timeouts.DefaultTestTimeout).IsEqualTo(TimeSpan.FromMinutes(10)); - } - finally - { - TUnitSettings.Timeouts.DefaultTestTimeout = originalTimeout; - } + TUnitSettings.Timeouts.DefaultTestTimeout = TimeSpan.FromMinutes(10); + await Assert.That(TUnitSettings.Timeouts.DefaultTestTimeout).IsEqualTo(TimeSpan.FromMinutes(10)); } } diff --git a/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md index 833041bba2..14feb9cb14 100644 --- a/docs/docs/reference/programmatic-configuration.md +++ b/docs/docs/reference/programmatic-configuration.md @@ -99,4 +99,7 @@ The command-line flag takes precedence, so 8 parallel tests will be used. Set most `TUnitSettings` values 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. -The exception is `Parallelism.MaximumParallelTests`, which is read during scheduler initialization before discovery hooks run — use the CLI flag or environment variable for that setting (see the note above). +Two settings are exceptions: + +- **`Parallelism.MaximumParallelTests`** is read during scheduler initialization before discovery hooks run — use the CLI flag or environment variable for that setting (see the note above). +- **`Timeouts.DefaultHookTimeout`** is captured at hook registration time, which also occurs before discovery hooks run. Use the `[Timeout]` attribute on individual hook methods for reliable per-hook timeout control. From 664da2b6de04a7e11f0600a6b6296b336da2b1e7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:37:36 +0100 Subject: [PATCH 12/21] fix: add thread-safe volatile reads/writes to boolean settings Use Volatile.Read/Write for FailFast and DetailedStackTrace backing fields so cross-thread visibility is guaranteed when settings are configured in a discovery hook and read during parallel test execution. --- TUnit.Core/Settings/DisplaySettings.cs | 8 +++++++- TUnit.Core/Settings/ExecutionSettings.cs | 8 +++++++- TUnit.UnitTests/TUnitSettingsTests.cs | 2 ++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/TUnit.Core/Settings/DisplaySettings.cs b/TUnit.Core/Settings/DisplaySettings.cs index e118ed4153..19aa50e347 100644 --- a/TUnit.Core/Settings/DisplaySettings.cs +++ b/TUnit.Core/Settings/DisplaySettings.cs @@ -9,5 +9,11 @@ public sealed class DisplaySettings /// Whether to show full stack traces including TUnit internals. Default: false. /// Precedence: --detailed-stacktrace → TUnitSettings → built-in default. /// - public bool DetailedStackTrace { get; set; } + public bool DetailedStackTrace + { + get => Volatile.Read(ref _detailedStackTrace); + set => Volatile.Write(ref _detailedStackTrace, value); + } + + private bool _detailedStackTrace; } diff --git a/TUnit.Core/Settings/ExecutionSettings.cs b/TUnit.Core/Settings/ExecutionSettings.cs index 5608eda326..79a352e934 100644 --- a/TUnit.Core/Settings/ExecutionSettings.cs +++ b/TUnit.Core/Settings/ExecutionSettings.cs @@ -9,5 +9,11 @@ public sealed class 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; } + public bool FailFast + { + get => Volatile.Read(ref _failFast); + set => Volatile.Write(ref _failFast, value); + } + + private bool _failFast; } diff --git a/TUnit.UnitTests/TUnitSettingsTests.cs b/TUnit.UnitTests/TUnitSettingsTests.cs index 8f8dab5e4f..2c52003222 100644 --- a/TUnit.UnitTests/TUnitSettingsTests.cs +++ b/TUnit.UnitTests/TUnitSettingsTests.cs @@ -2,6 +2,8 @@ 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 { From af43dd2d72f37635f712140b8672fac470bed708 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:50:26 +0100 Subject: [PATCH 13/21] fix: remove Volatile from boolean settings for consistency Revert to plain auto-properties on all settings classes. The framework's lifecycle guarantees that discovery hooks complete before test threads start, so no per-field synchronization is needed. Volatile cannot be applied uniformly to TimeSpan/int? types, so mixing patterns was worse than relying on the existing happens-before guarantee. Added threading contract doc comment on TUnitSettings. --- TUnit.Core/Settings/DisplaySettings.cs | 8 +------- TUnit.Core/Settings/ExecutionSettings.cs | 8 +------- TUnit.Core/Settings/TUnitSettings.cs | 6 ++++++ 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/TUnit.Core/Settings/DisplaySettings.cs b/TUnit.Core/Settings/DisplaySettings.cs index 19aa50e347..e118ed4153 100644 --- a/TUnit.Core/Settings/DisplaySettings.cs +++ b/TUnit.Core/Settings/DisplaySettings.cs @@ -9,11 +9,5 @@ public sealed class DisplaySettings /// Whether to show full stack traces including TUnit internals. Default: false. /// Precedence: --detailed-stacktrace → TUnitSettings → built-in default. /// - public bool DetailedStackTrace - { - get => Volatile.Read(ref _detailedStackTrace); - set => Volatile.Write(ref _detailedStackTrace, value); - } - - private bool _detailedStackTrace; + public bool DetailedStackTrace { get; set; } } diff --git a/TUnit.Core/Settings/ExecutionSettings.cs b/TUnit.Core/Settings/ExecutionSettings.cs index 79a352e934..5608eda326 100644 --- a/TUnit.Core/Settings/ExecutionSettings.cs +++ b/TUnit.Core/Settings/ExecutionSettings.cs @@ -9,11 +9,5 @@ public sealed class 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 => Volatile.Read(ref _failFast); - set => Volatile.Write(ref _failFast, value); - } - - private bool _failFast; + public bool FailFast { get; set; } } diff --git a/TUnit.Core/Settings/TUnitSettings.cs b/TUnit.Core/Settings/TUnitSettings.cs index 6837d5f85d..39f5fd51c0 100644 --- a/TUnit.Core/Settings/TUnitSettings.cs +++ b/TUnit.Core/Settings/TUnitSettings.cs @@ -6,6 +6,12 @@ namespace TUnit.Core.Settings; /// /// 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 static class TUnitSettings { From 35b898de2142aa1415e8a6d135bdaa49d6177e1b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:58:19 +0100 Subject: [PATCH 14/21] fix: Defaults delegates to TUnitSettings, add TimeoutSettings validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change Defaults fields from static readonly to computed properties that delegate to TUnitSettings, so deprecated consumers see the correct value even after programmatic configuration. - Add setter validation to all TimeoutSettings properties — reject TimeSpan.Zero and negative values with ArgumentOutOfRangeException, consistent with ParallelismSettings. - Update public API snapshots for Defaults field→property change. --- TUnit.Core/Defaults.cs | 8 +-- TUnit.Core/Settings/TimeoutSettings.cs | 54 +++++++++++++++++-- ...Has_No_API_Changes.DotNet10_0.verified.txt | 8 +-- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 8 +-- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 8 +-- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 8 +-- 6 files changed, 70 insertions(+), 24 deletions(-) diff --git a/TUnit.Core/Defaults.cs b/TUnit.Core/Defaults.cs index 449788fc9a..a67df0e0ed 100644 --- a/TUnit.Core/Defaults.cs +++ b/TUnit.Core/Defaults.cs @@ -14,26 +14,26 @@ public static class Defaults /// Can be overridden per-test via . /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultTestTimeout)} instead.")] - public static readonly TimeSpan TestTimeout = TimeSpan.FromMinutes(30); + public static TimeSpan TestTimeout => TUnitSettings.Timeouts.DefaultTestTimeout; /// /// Default timeout applied to hook methods (Before/After at every level) /// when no explicit timeout is configured. /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultHookTimeout)} instead.")] - public static readonly TimeSpan HookTimeout = TimeSpan.FromMinutes(5); + public static TimeSpan HookTimeout => TUnitSettings.Timeouts.DefaultHookTimeout; /// /// Time allowed for a graceful shutdown after a cancellation request (Ctrl+C / SIGTERM) /// before the process is forcefully terminated. /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ForcefulExitTimeout)} instead.")] - public static readonly TimeSpan ForcefulExitTimeout = TimeSpan.FromSeconds(30); + public static TimeSpan ForcefulExitTimeout => TUnitSettings.Timeouts.ForcefulExitTimeout; /// /// Brief delay during process exit to allow After hooks registered via /// to execute before the process terminates. /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ProcessExitHookDelay)} instead.")] - public static readonly TimeSpan ProcessExitHookDelay = TimeSpan.FromMilliseconds(500); + public static TimeSpan ProcessExitHookDelay => TUnitSettings.Timeouts.ProcessExitHookDelay; } diff --git a/TUnit.Core/Settings/TimeoutSettings.cs b/TUnit.Core/Settings/TimeoutSettings.cs index 804cec570c..8ec0a33539 100644 --- a/TUnit.Core/Settings/TimeoutSettings.cs +++ b/TUnit.Core/Settings/TimeoutSettings.cs @@ -11,7 +11,15 @@ public sealed class TimeoutSettings /// Overridden per-test by . /// Precedence: CLI/env var (N/A for test timeout) → TUnitSettings → built-in default. /// - public TimeSpan DefaultTestTimeout { get; set; } = TimeSpan.FromMinutes(30); + public TimeSpan DefaultTestTimeout + { + get => _defaultTestTimeout; + set + { + ValidatePositive(value); + _defaultTestTimeout = value; + } + } /// /// Default timeout for hook methods (Before/After at every level). Default: 5 minutes. @@ -22,17 +30,55 @@ public sealed class TimeoutSettings /// on individual hook methods for reliable per-hook timeout control. /// /// - public TimeSpan DefaultHookTimeout { get; set; } = TimeSpan.FromMinutes(5); + 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; set; } = TimeSpan.FromSeconds(30); + 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. /// - public TimeSpan ProcessExitHookDelay { get; set; } = TimeSpan.FromMilliseconds(500); + public TimeSpan ProcessExitHookDelay + { + get => _processExitHookDelay; + set + { + ValidatePositive(value); + _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.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 6c3a299344..3bfbfed721 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 @@ -532,13 +532,13 @@ namespace public static class Defaults { [("Use .ForcefulExitTimeout instead.")] - public static readonly ForcefulExitTimeout; + public static ForcefulExitTimeout { get; } [("Use .DefaultHookTimeout instead.")] - public static readonly HookTimeout; + public static HookTimeout { get; } [("Use .ProcessExitHookDelay instead.")] - public static readonly ProcessExitHookDelay; + public static ProcessExitHookDelay { get; } [("Use .DefaultTestTimeout instead.")] - public static readonly TestTimeout; + public static TestTimeout { get; } } public abstract class DependencyInjectionDataSourceAttribute : .UntypedDataSourceGeneratorAttribute { 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 00efb70c0e..fa04aa267f 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 @@ -532,13 +532,13 @@ namespace public static class Defaults { [("Use .ForcefulExitTimeout instead.")] - public static readonly ForcefulExitTimeout; + public static ForcefulExitTimeout { get; } [("Use .DefaultHookTimeout instead.")] - public static readonly HookTimeout; + public static HookTimeout { get; } [("Use .ProcessExitHookDelay instead.")] - public static readonly ProcessExitHookDelay; + public static ProcessExitHookDelay { get; } [("Use .DefaultTestTimeout instead.")] - public static readonly TestTimeout; + public static TestTimeout { get; } } public abstract class DependencyInjectionDataSourceAttribute : .UntypedDataSourceGeneratorAttribute { 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 ad5c9e0020..92fc04116c 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 @@ -532,13 +532,13 @@ namespace public static class Defaults { [("Use .ForcefulExitTimeout instead.")] - public static readonly ForcefulExitTimeout; + public static ForcefulExitTimeout { get; } [("Use .DefaultHookTimeout instead.")] - public static readonly HookTimeout; + public static HookTimeout { get; } [("Use .ProcessExitHookDelay instead.")] - public static readonly ProcessExitHookDelay; + public static ProcessExitHookDelay { get; } [("Use .DefaultTestTimeout instead.")] - public static readonly TestTimeout; + public static TestTimeout { get; } } public abstract class DependencyInjectionDataSourceAttribute : .UntypedDataSourceGeneratorAttribute { 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 e0b1ee71c8..518fd7365d 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 @@ -511,13 +511,13 @@ namespace public static class Defaults { [("Use .ForcefulExitTimeout instead.")] - public static readonly ForcefulExitTimeout; + public static ForcefulExitTimeout { get; } [("Use .DefaultHookTimeout instead.")] - public static readonly HookTimeout; + public static HookTimeout { get; } [("Use .ProcessExitHookDelay instead.")] - public static readonly ProcessExitHookDelay; + public static ProcessExitHookDelay { get; } [("Use .DefaultTestTimeout instead.")] - public static readonly TestTimeout; + public static TestTimeout { get; } } public abstract class DependencyInjectionDataSourceAttribute : .UntypedDataSourceGeneratorAttribute { From 0035ffc6d204df61ebe9fd4b424b830b49d3c74a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:15:17 +0100 Subject: [PATCH 15/21] feat: make TUnitSettings non-static, expose via BeforeTestDiscoveryContext TUnitSettings is now a sealed class (not static) with an internal-only Default singleton. Users access settings exclusively through context.Settings in a [Before(HookType.TestDiscovery)] hook, which naturally enforces the correct lifecycle timing. Engine code uses TUnitSettings.Default.* via InternalsVisibleTo. Removed TUnitSettingsAccessor (no longer needed). Updated all docs to reference context.Settings instead of the static API. --- TUnit.Core/Defaults.cs | 8 ++-- TUnit.Core/EngineCancellationToken.cs | 4 +- .../Executors/DedicatedThreadExecutor.cs | 4 +- TUnit.Core/Hooks/HookMethod.cs | 2 +- .../Models/BeforeTestDiscoveryContext.cs | 8 ++++ TUnit.Core/Settings/TUnitSettings.cs | 16 ++++--- TUnit.Engine/Building/TestBuilder.cs | 4 +- TUnit.Engine/Building/TestBuilderPipeline.cs | 8 ++-- TUnit.Engine/Scheduling/TestRunner.cs | 4 +- TUnit.Engine/Scheduling/TestScheduler.cs | 2 +- TUnit.Engine/TUnitMessageBus.cs | 2 +- ...Has_No_API_Changes.DotNet10_0.verified.txt | 11 +++-- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 11 +++-- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 11 +++-- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 11 +++-- TUnit.UnitTests/TUnitSettingsTests.cs | 46 +++++++++---------- docs/docs/execution/parallelism.md | 2 +- docs/docs/reference/command-line-flags.md | 6 +-- docs/docs/reference/environment-variables.md | 4 +- .../reference/programmatic-configuration.md | 35 +++++++------- 20 files changed, 108 insertions(+), 91 deletions(-) diff --git a/TUnit.Core/Defaults.cs b/TUnit.Core/Defaults.cs index a67df0e0ed..b6b8c525ad 100644 --- a/TUnit.Core/Defaults.cs +++ b/TUnit.Core/Defaults.cs @@ -14,26 +14,26 @@ public static class Defaults /// Can be overridden per-test via . /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultTestTimeout)} instead.")] - public static TimeSpan TestTimeout => TUnitSettings.Timeouts.DefaultTestTimeout; + 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. /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultHookTimeout)} instead.")] - public static TimeSpan HookTimeout => TUnitSettings.Timeouts.DefaultHookTimeout; + 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. /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ForcefulExitTimeout)} instead.")] - public static TimeSpan ForcefulExitTimeout => TUnitSettings.Timeouts.ForcefulExitTimeout; + 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. /// [Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ProcessExitHookDelay)} instead.")] - public static TimeSpan ProcessExitHookDelay => TUnitSettings.Timeouts.ProcessExitHookDelay; + public static TimeSpan ProcessExitHookDelay => TUnitSettings.Default.Timeouts.ProcessExitHookDelay; } diff --git a/TUnit.Core/EngineCancellationToken.cs b/TUnit.Core/EngineCancellationToken.cs index c5a76c74cf..9e9d5e37a7 100644 --- a/TUnit.Core/EngineCancellationToken.cs +++ b/TUnit.Core/EngineCancellationToken.cs @@ -64,7 +64,7 @@ private void Cancel() _forcefulExitStarted = true; // Start a new forceful exit timer - _ = Task.Delay(TUnitSettings.Timeouts.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t => + _ = Task.Delay(TUnitSettings.Default.Timeouts.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t => { if (!t.IsCanceled) { @@ -88,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(TUnitSettings.Timeouts.ProcessExitHookDelay); + Thread.Sleep(TUnitSettings.Default.Timeouts.ProcessExitHookDelay); } } diff --git a/TUnit.Core/Executors/DedicatedThreadExecutor.cs b/TUnit.Core/Executors/DedicatedThreadExecutor.cs index 51df22807b..23c36093c6 100644 --- a/TUnit.Core/Executors/DedicatedThreadExecutor.cs +++ b/TUnit.Core/Executors/DedicatedThreadExecutor.cs @@ -350,12 +350,12 @@ public override void Send(SendOrPostCallback d, object? state) var waitTask = Task.Run(async () => { // For .NET Standard 2.0 compatibility, use Task.Delay for timeout - var timeoutTask = Task.Delay(TUnitSettings.Timeouts.DefaultTestTimeout); + var timeoutTask = Task.Delay(TUnitSettings.Default.Timeouts.DefaultTestTimeout); var completedTask = await Task.WhenAny(tcs.Task, timeoutTask).ConfigureAwait(false); if (completedTask == timeoutTask) { - throw new TimeoutException($"Synchronous operation on dedicated thread timed out after {TUnitSettings.Timeouts.DefaultTestTimeout.TotalMinutes} minutes"); + throw new TimeoutException($"Synchronous operation on dedicated thread timed out after {TUnitSettings.Default.Timeouts.DefaultTestTimeout.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 2439dee576..86fb2410d4 100644 --- a/TUnit.Core/Hooks/HookMethod.cs +++ b/TUnit.Core/Hooks/HookMethod.cs @@ -29,7 +29,7 @@ public abstract record HookMethod /// 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. /// - public TimeSpan? Timeout { get; internal set; } = TUnitSettings.Timeouts.DefaultHookTimeout; + public TimeSpan? Timeout { get; internal set; } = TUnitSettings.Default.Timeouts.DefaultHookTimeout; 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/TUnitSettings.cs b/TUnit.Core/Settings/TUnitSettings.cs index 39f5fd51c0..cc15574a89 100644 --- a/TUnit.Core/Settings/TUnitSettings.cs +++ b/TUnit.Core/Settings/TUnitSettings.cs @@ -1,7 +1,7 @@ namespace TUnit.Core.Settings; /// -/// Programmatic configuration for TUnit. Set these in a +/// 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. @@ -13,25 +13,29 @@ namespace TUnit.Core.Settings; /// is required. Modifying settings during parallel test execution is not supported. /// /// -public static class TUnitSettings +public sealed class TUnitSettings { + internal static TUnitSettings Default { get; } = new(); + + internal TUnitSettings() { } + /// /// Default timeouts for tests and hooks. /// - public static TimeoutSettings Timeouts { get; } = new(); + public TimeoutSettings Timeouts { get; } = new(); /// /// Controls concurrent test execution. /// - public static ParallelismSettings Parallelism { get; } = new(); + public ParallelismSettings Parallelism { get; } = new(); /// /// Controls visual output. /// - public static DisplaySettings Display { get; } = new(); + public DisplaySettings Display { get; } = new(); /// /// Controls test run behavior. /// - public static ExecutionSettings Execution { get; } = new(); + public ExecutionSettings Execution { get; } = new(); } diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 91cdfa28f8..871d434f56 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -1078,7 +1078,7 @@ private async ValueTask CreateTestContextAsync(string testId, TestM AttributesByType = attributes.ToAttributeDictionary(), MethodGenericArguments = testData.ResolvedMethodGenericArguments, ClassGenericArguments = testData.ResolvedClassGenericArguments, - Timeout = Core.Settings.TUnitSettings.Timeouts.DefaultTestTimeout // Default timeout (can be overridden by TimeoutAttribute) + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout // Default timeout (can be overridden by TimeoutAttribute) // Don't set RetryLimit here - let discovery event receivers set it }; @@ -1172,7 +1172,7 @@ private TestDetails CreateFailedTestDetails(TestMetadata metadata, string testId ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = AttributeDictionaryHelper.Empty, - Timeout = Core.Settings.TUnitSettings.Timeouts.DefaultTestTimeout + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout }; } diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 7fa3b5de97..58e860e03e 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -254,7 +254,7 @@ private async Task GenerateDynamicTests(TestMetadata m ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = attributes.ToAttributeDictionary(), - Timeout = Core.Settings.TUnitSettings.Timeouts.DefaultTestTimeout // Default timeout (can be overridden by TimeoutAttribute) + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout // Default timeout (can be overridden by TimeoutAttribute) // Don't set RetryLimit here - let discovery event receivers set it }; @@ -382,7 +382,7 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad ReturnType = typeof(Task), MethodMetadata = resolvedMetadata.MethodMetadata, AttributesByType = attributes.ToAttributeDictionary(), - Timeout = Core.Settings.TUnitSettings.Timeouts.DefaultTestTimeout // Default timeout (can be overridden by TimeoutAttribute) + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout // Default timeout (can be overridden by TimeoutAttribute) // Don't set Timeout and RetryLimit here - let discovery event receivers set them }; @@ -462,7 +462,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = AttributeDictionaryHelper.Empty, - Timeout = Core.Settings.TUnitSettings.Timeouts.DefaultTestTimeout // Default timeout + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout // Default timeout }; var context = _contextProvider.CreateTestContext( @@ -515,7 +515,7 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, AttributesByType = AttributeDictionaryHelper.Empty, - Timeout = Core.Settings.TUnitSettings.Timeouts.DefaultTestTimeout // Default timeout + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout // Default timeout }; var context = _contextProvider.CreateTestContext( diff --git a/TUnit.Engine/Scheduling/TestRunner.cs b/TUnit.Engine/Scheduling/TestRunner.cs index 2d40608e57..264199c436 100644 --- a/TUnit.Engine/Scheduling/TestRunner.cs +++ b/TUnit.Engine/Scheduling/TestRunner.cs @@ -93,7 +93,7 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca // TestCoordinator handles sending InProgress message await _testCoordinator.ExecuteTestAsync(test, cancellationToken).ConfigureAwait(false); - if ((_isFailFastEnabled || TUnitSettings.Execution.FailFast) && 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) @@ -110,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 || TUnitSettings.Execution.FailFast) + 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 fb01c455ec..03ef446ff7 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -564,7 +564,7 @@ private static int GetMaxParallelism(ILogger logger, ICommandLineOptions command } // Check TUnitSettings (third priority — code-level project defaults) - if (TUnitSettings.Parallelism.MaximumParallelTests is { } codeLimit) + if (TUnitSettings.Default.Parallelism.MaximumParallelTests is { } codeLimit) { if (codeLimit == 0) { diff --git a/TUnit.Engine/TUnitMessageBus.cs b/TUnit.Engine/TUnitMessageBus.cs index c4c4beb946..c229367024 100644 --- a/TUnit.Engine/TUnitMessageBus.cs +++ b/TUnit.Engine/TUnitMessageBus.cs @@ -86,7 +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.Display.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 3bfbfed721..2097952b16 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; } } @@ -2936,12 +2937,12 @@ namespace .Settings public ParallelismSettings() { } public int? MaximumParallelTests { get; set; } } - public static class TUnitSettings + public sealed class TUnitSettings { - public static . Display { get; } - public static . Execution { get; } - public static . Parallelism { get; } - public static . Timeouts { get; } + public . Display { get; } + public . Execution { get; } + public . Parallelism { get; } + public . Timeouts { get; } } public sealed class TimeoutSettings { 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 fa04aa267f..f7233c6dc7 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; } } @@ -2936,12 +2937,12 @@ namespace .Settings public ParallelismSettings() { } public int? MaximumParallelTests { get; set; } } - public static class TUnitSettings + public sealed class TUnitSettings { - public static . Display { get; } - public static . Execution { get; } - public static . Parallelism { get; } - public static . Timeouts { get; } + public . Display { get; } + public . Execution { get; } + public . Parallelism { get; } + public . Timeouts { get; } } public sealed class TimeoutSettings { 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 92fc04116c..226483142c 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; } } @@ -2936,12 +2937,12 @@ namespace .Settings public ParallelismSettings() { } public int? MaximumParallelTests { get; set; } } - public static class TUnitSettings + public sealed class TUnitSettings { - public static . Display { get; } - public static . Execution { get; } - public static . Parallelism { get; } - public static . Timeouts { get; } + public . Display { get; } + public . Execution { get; } + public . Parallelism { get; } + public . Timeouts { get; } } public sealed class TimeoutSettings { 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 518fd7365d..031e96c271 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; } } @@ -2861,12 +2862,12 @@ namespace .Settings public ParallelismSettings() { } public int? MaximumParallelTests { get; set; } } - public static class TUnitSettings + public sealed class TUnitSettings { - public static . Display { get; } - public static . Execution { get; } - public static . Parallelism { get; } - public static . Timeouts { get; } + public . Display { get; } + public . Execution { get; } + public . Parallelism { get; } + public . Timeouts { get; } } public sealed class TimeoutSettings { diff --git a/TUnit.UnitTests/TUnitSettingsTests.cs b/TUnit.UnitTests/TUnitSettingsTests.cs index 2c52003222..a300d453de 100644 --- a/TUnit.UnitTests/TUnitSettingsTests.cs +++ b/TUnit.UnitTests/TUnitSettingsTests.cs @@ -18,43 +18,43 @@ public class TUnitSettingsTests [Before(HookType.Test)] public void SnapshotSettings() { - _savedTestTimeout = TUnitSettings.Timeouts.DefaultTestTimeout; - _savedHookTimeout = TUnitSettings.Timeouts.DefaultHookTimeout; - _savedForcefulExitTimeout = TUnitSettings.Timeouts.ForcefulExitTimeout; - _savedProcessExitHookDelay = TUnitSettings.Timeouts.ProcessExitHookDelay; - _savedMaximumParallelTests = TUnitSettings.Parallelism.MaximumParallelTests; - _savedDetailedStackTrace = TUnitSettings.Display.DetailedStackTrace; - _savedFailFast = TUnitSettings.Execution.FailFast; + _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.Timeouts.DefaultTestTimeout = _savedTestTimeout; - TUnitSettings.Timeouts.DefaultHookTimeout = _savedHookTimeout; - TUnitSettings.Timeouts.ForcefulExitTimeout = _savedForcefulExitTimeout; - TUnitSettings.Timeouts.ProcessExitHookDelay = _savedProcessExitHookDelay; - TUnitSettings.Parallelism.MaximumParallelTests = _savedMaximumParallelTests; - TUnitSettings.Display.DetailedStackTrace = _savedDetailedStackTrace; - TUnitSettings.Execution.FailFast = _savedFailFast; + 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.Timeouts.DefaultTestTimeout).IsEqualTo(TimeSpan.FromMinutes(30)); - await Assert.That(TUnitSettings.Timeouts.DefaultHookTimeout).IsEqualTo(TimeSpan.FromMinutes(5)); - await Assert.That(TUnitSettings.Timeouts.ForcefulExitTimeout).IsEqualTo(TimeSpan.FromSeconds(30)); - await Assert.That(TUnitSettings.Timeouts.ProcessExitHookDelay).IsEqualTo(TimeSpan.FromMilliseconds(500)); - await Assert.That(TUnitSettings.Parallelism.MaximumParallelTests).IsNull(); - await Assert.That(TUnitSettings.Display.DetailedStackTrace).IsFalse(); - await Assert.That(TUnitSettings.Execution.FailFast).IsFalse(); + 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.Timeouts.DefaultTestTimeout = TimeSpan.FromMinutes(10); - await Assert.That(TUnitSettings.Timeouts.DefaultTestTimeout).IsEqualTo(TimeSpan.FromMinutes(10)); + 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 9e8ee0ffd0..0b63700a57 100644 --- a/docs/docs/execution/parallelism.md +++ b/docs/docs/execution/parallelism.md @@ -179,7 +179,7 @@ dotnet run --project MyTests -- --maximum-parallel-tests 4 TUNIT_MAX_PARALLEL_TESTS=4 dotnet run --project MyTests ``` -The `TUnitSettings.Parallelism.MaximumParallelTests` property is also available, but the scheduler reads it before `[Before(HookType.TestDiscovery)]` hooks run, so the CLI flag or environment variable is the recommended approach. See the [Programmatic Configuration](/docs/reference/programmatic-configuration) reference for details. +The `context.Settings.Parallelism.MaximumParallelTests` property is also available, but the scheduler reads it before `[Before(HookType.TestDiscovery)]` hooks run, so the CLI flag or environment variable is the recommended approach. See the [Programmatic Configuration](/docs/reference/programmatic-configuration) reference for details. ## When to Use Which diff --git a/docs/docs/reference/command-line-flags.md b/docs/docs/reference/command-line-flags.md index a9803ad2ef..2e86de5f3e 100644 --- a/docs/docs/reference/command-line-flags.md +++ b/docs/docs/reference/command-line-flags.md @@ -80,11 +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: TUnitSettings.Execution.FailFast + Programmatic equivalent: context.Settings.Execution.FailFast --maximum-parallel-tests Maximum Parallel Tests - Programmatic equivalent: TUnitSettings.Parallelism.MaximumParallelTests + Programmatic equivalent: context.Settings.Parallelism.MaximumParallelTests --no-ansi Disable outputting ANSI escape characters to screen. @@ -124,7 +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: TUnitSettings.Display.DetailedStackTrace + 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 8e5f8be67a..30ce7ab154 100644 --- a/docs/docs/reference/environment-variables.md +++ b/docs/docs/reference/environment-variables.md @@ -95,7 +95,7 @@ export TUNIT_MAX_PARALLEL_TESTS=0 # Unlimited parallelism **Equivalent to:** `--maximum-parallel-tests` -**Programmatic equivalent:** `TUnitSettings.Parallelism.MaximumParallelTests` (see [Programmatic Configuration](./programmatic-configuration.md)) +**Programmatic equivalent:** `context.Settings.Parallelism.MaximumParallelTests` (see [Programmatic Configuration](./programmatic-configuration.md)) **Note:** Command-line arguments take precedence over environment variables. @@ -255,7 +255,7 @@ 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. **`TUnitSettings` (code)** - Values set in `[Before(HookType.TestDiscovery)]` hooks (see [Programmatic Configuration](./programmatic-configuration.md)) +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 index 14feb9cb14..1ca7a79636 100644 --- a/docs/docs/reference/programmatic-configuration.md +++ b/docs/docs/reference/programmatic-configuration.md @@ -6,31 +6,30 @@ sidebar_position: 4 ## Overview -The `TUnitSettings` 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. +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: -- `TUnitSettings.Timeouts` — test and hook timeout durations -- `TUnitSettings.Parallelism` — concurrent test execution limits -- `TUnitSettings.Execution` — runtime behavior such as fail-fast -- `TUnitSettings.Display` — output and display options +- `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: +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; -using TUnit.Core.Settings; public class TestSetup { [Before(HookType.TestDiscovery)] public static Task Configure(BeforeTestDiscoveryContext context) { - TUnitSettings.Timeouts.DefaultTestTimeout = TimeSpan.FromMinutes(5); - TUnitSettings.Timeouts.DefaultHookTimeout = TimeSpan.FromMinutes(2); - TUnitSettings.Execution.FailFast = true; + context.Settings.Timeouts.DefaultTestTimeout = TimeSpan.FromMinutes(5); + context.Settings.Timeouts.DefaultHookTimeout = TimeSpan.FromMinutes(2); + context.Settings.Execution.FailFast = true; return Task.CompletedTask; } @@ -39,9 +38,11 @@ public class TestSetup 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 -### `TUnitSettings.Timeouts` +### `context.Settings.Timeouts` | Property | Type | Default | Description | |---|---|---|---| @@ -50,7 +51,7 @@ Place this class anywhere in your test project. TUnit will discover and run the | `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. | -### `TUnitSettings.Parallelism` +### `context.Settings.Parallelism` | Property | Type | Default | Description | |---|---|---|---| @@ -58,13 +59,13 @@ Place this class anywhere in your test project. TUnit will discover and run the > **Note:** `MaximumParallelTests` is read during scheduler initialization, which occurs before `[Before(HookType.TestDiscovery)]` hooks run. Use the `--maximum-parallel-tests` CLI flag or the `TUNIT_MAX_PARALLEL_TESTS` environment variable to override this setting. -### `TUnitSettings.Display` +### `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. | -### `TUnitSettings.Execution` +### `context.Settings.Execution` | Property | Type | Default | Description | |---|---|---|---| @@ -76,7 +77,7 @@ When the same setting is configured in multiple places, the following priority o 1. **Command-line flag** (e.g., `--maximum-parallel-tests 8`) 2. **Environment variable** (e.g., `TUNIT_MAX_PARALLEL_TESTS=8`) -3. **`TUnitSettings` (code)** — values set in a `[Before(HookType.TestDiscovery)]` hook +3. **`context.Settings` (code)** — values set in a `[Before(HookType.TestDiscovery)]` hook 4. **Built-in default** ### Example @@ -84,7 +85,7 @@ When the same setting is configured in multiple places, the following priority o Your test project sets a conservative parallelism limit in code: ```csharp -TUnitSettings.Parallelism.MaximumParallelTests = 1; +context.Settings.Parallelism.MaximumParallelTests = 1; ``` A developer on a powerful machine can override this for a local run without changing code: @@ -97,7 +98,7 @@ The command-line flag takes precedence, so 8 parallel tests will be used. ## When to Set -Set most `TUnitSettings` values 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. +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. Two settings are exceptions: From bf45f7e3225aa054021713c11c4f2486d7ac9b88 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:19:14 +0100 Subject: [PATCH 16/21] fix: allow TimeSpan.Zero for ProcessExitHookDelay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessExitHookDelay is a delay, not a timeout — zero is a valid value meaning "no delay". Only reject negative values, unlike the three actual timeout properties which reject zero (a zero-duration timeout would cause immediate cancellation). --- TUnit.Core/Settings/TimeoutSettings.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/TUnit.Core/Settings/TimeoutSettings.cs b/TUnit.Core/Settings/TimeoutSettings.cs index 8ec0a33539..4fa3caa26a 100644 --- a/TUnit.Core/Settings/TimeoutSettings.cs +++ b/TUnit.Core/Settings/TimeoutSettings.cs @@ -57,13 +57,19 @@ public TimeSpan ForcefulExitTimeout /// /// 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 { - ValidatePositive(value); + if (value < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(value), value, + "ProcessExitHookDelay cannot be negative."); + } + _processExitHookDelay = value; } } From 672246ef7beac8a2319f92b4dc813e2838fc60a4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:26:42 +0100 Subject: [PATCH 17/21] feat: defer MaximumParallelTests read with Lazy Use Lazy for _maxParallelism and Lazy for the semaphore so the value is computed on first access (during test execution) rather than at TestScheduler construction (before discovery hooks). Users can now set MaximumParallelTests in a [Before(HookType.TestDiscovery)] hook and have it take effect. Closes #5523 Removed timing caveats from ParallelismSettings XML doc and programmatic-configuration.md since the limitation no longer exists. --- TUnit.Core/Settings/ParallelismSettings.cs | 6 ----- TUnit.Engine/Scheduling/TestScheduler.cs | 23 ++++++++++--------- docs/docs/execution/parallelism.md | 2 +- .../reference/programmatic-configuration.md | 7 +----- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/TUnit.Core/Settings/ParallelismSettings.cs b/TUnit.Core/Settings/ParallelismSettings.cs index 6707225894..7c7baa1958 100644 --- a/TUnit.Core/Settings/ParallelismSettings.cs +++ b/TUnit.Core/Settings/ParallelismSettings.cs @@ -8,12 +8,6 @@ public sealed class 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. - /// - /// Note: This value is read during scheduler initialization, which occurs before - /// [Before(HookType.TestDiscovery)] hooks run. Set this value in a module initializer - /// or static constructor, or use the --maximum-parallel-tests CLI flag / - /// TUNIT_MAX_PARALLEL_TESTS environment variable instead. - /// /// public int? MaximumParallelTests { diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 03ef446ff7..31ad46840c 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -28,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, @@ -59,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 @@ -309,7 +310,7 @@ private async Task ExecuteTestsAsync( AbstractExecutableTest[] tests, CancellationToken cancellationToken) { - if (_maxParallelismSemaphore != null) + if (_maxParallelismSemaphore.Value != null) { await ExecuteWithGlobalLimitAsync(tests, cancellationToken).ConfigureAwait(false); } @@ -422,7 +423,7 @@ private async Task ExecuteWithGlobalLimitAsync( { SemaphoreSlim? parallelLimiterSemaphore = null; - await _maxParallelismSemaphore!.WaitAsync(cancellationToken).ConfigureAwait(false); + await _maxParallelismSemaphore.Value!.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (test.Context.ParallelLimiter != null) @@ -443,7 +444,7 @@ private async Task ExecuteWithGlobalLimitAsync( } finally { - _maxParallelismSemaphore.Release(); + _maxParallelismSemaphore.Value!.Release(); } }, CancellationToken.None); } @@ -461,7 +462,7 @@ await Parallel.ForEachAsync( tests, new ParallelOptions { - MaxDegreeOfParallelism = _maxParallelism, + MaxDegreeOfParallelism = _maxParallelism.Value, CancellationToken = cancellationToken }, async (test, ct) => @@ -491,7 +492,7 @@ await Parallel.ForEachAsync( tests, new ParallelOptions { - MaxDegreeOfParallelism = _maxParallelism, + MaxDegreeOfParallelism = _maxParallelism.Value, CancellationToken = cancellationToken }, async (test, ct) => diff --git a/docs/docs/execution/parallelism.md b/docs/docs/execution/parallelism.md index 0b63700a57..8ca7dc464b 100644 --- a/docs/docs/execution/parallelism.md +++ b/docs/docs/execution/parallelism.md @@ -179,7 +179,7 @@ dotnet run --project MyTests -- --maximum-parallel-tests 4 TUNIT_MAX_PARALLEL_TESTS=4 dotnet run --project MyTests ``` -The `context.Settings.Parallelism.MaximumParallelTests` property is also available, but the scheduler reads it before `[Before(HookType.TestDiscovery)]` hooks run, so the CLI flag or environment variable is the recommended approach. See the [Programmatic Configuration](/docs/reference/programmatic-configuration) reference for details. +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 diff --git a/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md index 1ca7a79636..e299db7846 100644 --- a/docs/docs/reference/programmatic-configuration.md +++ b/docs/docs/reference/programmatic-configuration.md @@ -57,8 +57,6 @@ Settings are accessed exclusively through `context.Settings` in the discovery ho |---|---|---|---| | `MaximumParallelTests` | `int?` | `null` (4 x CPU cores) | Maximum number of tests that can execute concurrently. Set to `null` to use the default heuristic. | -> **Note:** `MaximumParallelTests` is read during scheduler initialization, which occurs before `[Before(HookType.TestDiscovery)]` hooks run. Use the `--maximum-parallel-tests` CLI flag or the `TUNIT_MAX_PARALLEL_TESTS` environment variable to override this setting. - ### `context.Settings.Display` | Property | Type | Default | Description | @@ -100,7 +98,4 @@ The command-line flag takes precedence, so 8 parallel tests will be used. 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. -Two settings are exceptions: - -- **`Parallelism.MaximumParallelTests`** is read during scheduler initialization before discovery hooks run — use the CLI flag or environment variable for that setting (see the note above). -- **`Timeouts.DefaultHookTimeout`** is captured at hook registration time, which also occurs before discovery hooks run. Use the `[Timeout]` attribute on individual hook methods for reliable per-hook timeout control. +The exception is **`Timeouts.DefaultHookTimeout`**, which is captured at hook registration time before discovery hooks run. Use the `[Timeout]` attribute on individual hook methods for reliable per-hook timeout control. From 2f36facac44d70e5cf5d0ca0c8b7eae21bf892ca Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:39:06 +0100 Subject: [PATCH 18/21] refactor: remove redundant comments and resolve Lazy values once in hot paths --- .../Executors/DedicatedThreadExecutor.cs | 5 ++-- TUnit.Engine/Building/TestBuilder.cs | 3 +-- TUnit.Engine/Building/TestBuilderPipeline.cs | 10 ++++---- TUnit.Engine/Scheduling/TestScheduler.cs | 23 +++++++++++-------- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/TUnit.Core/Executors/DedicatedThreadExecutor.cs b/TUnit.Core/Executors/DedicatedThreadExecutor.cs index 23c36093c6..e7a35ad015 100644 --- a/TUnit.Core/Executors/DedicatedThreadExecutor.cs +++ b/TUnit.Core/Executors/DedicatedThreadExecutor.cs @@ -347,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(TUnitSettings.Default.Timeouts.DefaultTestTimeout); + 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 {TUnitSettings.Default.Timeouts.DefaultTestTimeout.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.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 871d434f56..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.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout // 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( diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 58e860e03e..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.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout // 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.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout // 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.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout // 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.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout // Default timeout + Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout }; var context = _contextProvider.CreateTestContext( diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 31ad46840c..6a25deefc2 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -310,9 +310,10 @@ private async Task ExecuteTestsAsync( AbstractExecutableTest[] tests, CancellationToken cancellationToken) { - if (_maxParallelismSemaphore.Value != null) + var semaphore = _maxParallelismSemaphore.Value; + if (semaphore != null) { - await ExecuteWithGlobalLimitAsync(tests, cancellationToken).ConfigureAwait(false); + await ExecuteWithGlobalLimitAsync(tests, semaphore, cancellationToken).ConfigureAwait(false); } else { @@ -383,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) @@ -405,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); @@ -423,7 +427,7 @@ private async Task ExecuteWithGlobalLimitAsync( { SemaphoreSlim? parallelLimiterSemaphore = null; - await _maxParallelismSemaphore.Value!.WaitAsync(cancellationToken).ConfigureAwait(false); + await globalSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (test.Context.ParallelLimiter != null) @@ -444,7 +448,7 @@ private async Task ExecuteWithGlobalLimitAsync( } finally { - _maxParallelismSemaphore.Value!.Release(); + globalSemaphore.Release(); } }, CancellationToken.None); } @@ -455,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 @@ -462,7 +467,7 @@ await Parallel.ForEachAsync( tests, new ParallelOptions { - MaxDegreeOfParallelism = _maxParallelism.Value, + MaxDegreeOfParallelism = maxParallelism, CancellationToken = cancellationToken }, async (test, ct) => @@ -485,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 @@ -492,7 +498,7 @@ await Parallel.ForEachAsync( tests, new ParallelOptions { - MaxDegreeOfParallelism = _maxParallelism.Value, + MaxDegreeOfParallelism = maxParallelism, CancellationToken = cancellationToken }, async (test, ct) => @@ -573,7 +579,6 @@ private static int GetMaxParallelism(ILogger logger, ICommandLineOptions command return int.MaxValue; } - // Setter guarantees no negative values logger.LogDebug($"Maximum parallel tests limit set to {codeLimit} (from TUnitSettings)"); return codeLimit; } From 03e2890685c95b39f5a2efe3ad22511c01f0b842 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:40:39 +0100 Subject: [PATCH 19/21] docs: remove DefaultHookTimeout from usage example It is captured at hook registration time before discovery hooks run, so setting it in a [Before(HookType.TestDiscovery)] hook has no effect. The "When to Set" section already documents this exception. --- docs/docs/reference/programmatic-configuration.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md index e299db7846..e21f716ad3 100644 --- a/docs/docs/reference/programmatic-configuration.md +++ b/docs/docs/reference/programmatic-configuration.md @@ -28,7 +28,6 @@ public class TestSetup 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; From 3bd82b6ae17b62f2e4488189732000ee90ebba6a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:46:32 +0100 Subject: [PATCH 20/21] fix: defer DefaultHookTimeout resolution to execution time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HookMethod.Timeout now defaults to null instead of eagerly capturing TUnitSettings.Default.Timeouts.DefaultHookTimeout at registration time. HookTimeoutHelper resolves the fallback lazily at execution time, so discovery-hook configuration is respected — matching the pattern already used for FailFast and MaximumParallelTests. --- TUnit.Core/Hooks/HookMethod.cs | 8 ++-- TUnit.Core/Settings/TimeoutSettings.cs | 5 --- TUnit.Engine/Helpers/HookTimeoutHelper.cs | 38 +++++++------------ .../reference/programmatic-configuration.md | 3 +- 4 files changed, 19 insertions(+), 35 deletions(-) diff --git a/TUnit.Core/Hooks/HookMethod.cs b/TUnit.Core/Hooks/HookMethod.cs index 86fb2410d4..4e3737fd0c 100644 --- a/TUnit.Core/Hooks/HookMethod.cs +++ b/TUnit.Core/Hooks/HookMethod.cs @@ -26,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; } = TUnitSettings.Default.Timeouts.DefaultHookTimeout; + public TimeSpan? Timeout { get; internal set; } private IHookExecutor _hookExecutor = DefaultExecutor.Instance; private bool _hookExecutorIsExplicit; diff --git a/TUnit.Core/Settings/TimeoutSettings.cs b/TUnit.Core/Settings/TimeoutSettings.cs index 4fa3caa26a..9d01c63eff 100644 --- a/TUnit.Core/Settings/TimeoutSettings.cs +++ b/TUnit.Core/Settings/TimeoutSettings.cs @@ -24,11 +24,6 @@ public TimeSpan DefaultTestTimeout /// /// Default timeout for hook methods (Before/After at every level). Default: 5 minutes. /// Overridden per-hook by . - /// - /// Note: Hook methods capture this value at registration time, which occurs before - /// [Before(HookType.TestDiscovery)] hooks run. Use the [Timeout] attribute - /// on individual hook methods for reliable per-hook timeout control. - /// /// public TimeSpan DefaultHookTimeout { 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/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md index e21f716ad3..266cc7f75b 100644 --- a/docs/docs/reference/programmatic-configuration.md +++ b/docs/docs/reference/programmatic-configuration.md @@ -28,6 +28,7 @@ public class TestSetup 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; @@ -96,5 +97,3 @@ 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. - -The exception is **`Timeouts.DefaultHookTimeout`**, which is captured at hook registration time before discovery hooks run. Use the `[Timeout]` attribute on individual hook methods for reliable per-hook timeout control. From 99549c6b4290b824e3135d03f322df8b5fa93477 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:54:44 +0100 Subject: [PATCH 21/21] refactor: make sub-settings constructors internal Users should access settings via context.Settings, not by constructing new instances directly. Internal constructors prevent creating orphaned instances that have no connection to the engine. --- TUnit.Core/Settings/DisplaySettings.cs | 2 ++ TUnit.Core/Settings/ExecutionSettings.cs | 2 ++ TUnit.Core/Settings/ParallelismSettings.cs | 2 ++ TUnit.Core/Settings/TimeoutSettings.cs | 2 ++ ...ts.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 4 ---- ...sts.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 4 ---- ...sts.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 4 ---- .../Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt | 4 ---- 8 files changed, 8 insertions(+), 16 deletions(-) diff --git a/TUnit.Core/Settings/DisplaySettings.cs b/TUnit.Core/Settings/DisplaySettings.cs index e118ed4153..6b74af96f1 100644 --- a/TUnit.Core/Settings/DisplaySettings.cs +++ b/TUnit.Core/Settings/DisplaySettings.cs @@ -5,6 +5,8 @@ namespace TUnit.Core.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. diff --git a/TUnit.Core/Settings/ExecutionSettings.cs b/TUnit.Core/Settings/ExecutionSettings.cs index 5608eda326..faf3fb1cd0 100644 --- a/TUnit.Core/Settings/ExecutionSettings.cs +++ b/TUnit.Core/Settings/ExecutionSettings.cs @@ -5,6 +5,8 @@ namespace TUnit.Core.Settings; /// 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. diff --git a/TUnit.Core/Settings/ParallelismSettings.cs b/TUnit.Core/Settings/ParallelismSettings.cs index 7c7baa1958..c3e4834cd5 100644 --- a/TUnit.Core/Settings/ParallelismSettings.cs +++ b/TUnit.Core/Settings/ParallelismSettings.cs @@ -5,6 +5,8 @@ namespace TUnit.Core.Settings; /// 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. diff --git a/TUnit.Core/Settings/TimeoutSettings.cs b/TUnit.Core/Settings/TimeoutSettings.cs index 9d01c63eff..74d006dff6 100644 --- a/TUnit.Core/Settings/TimeoutSettings.cs +++ b/TUnit.Core/Settings/TimeoutSettings.cs @@ -6,6 +6,8 @@ namespace TUnit.Core.Settings; /// public sealed class TimeoutSettings { + internal TimeoutSettings() { } + /// /// Default timeout for individual tests. Default: 30 minutes. /// Overridden per-test by . 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 2097952b16..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 @@ -2924,17 +2924,14 @@ namespace .Settings { public sealed class DisplaySettings { - public DisplaySettings() { } public bool DetailedStackTrace { get; set; } } public sealed class ExecutionSettings { - public ExecutionSettings() { } public bool FailFast { get; set; } } public sealed class ParallelismSettings { - public ParallelismSettings() { } public int? MaximumParallelTests { get; set; } } public sealed class TUnitSettings @@ -2946,7 +2943,6 @@ namespace .Settings } public sealed class TimeoutSettings { - public TimeoutSettings() { } public DefaultHookTimeout { get; set; } public DefaultTestTimeout { get; set; } public ForcefulExitTimeout { get; set; } 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 f7233c6dc7..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 @@ -2924,17 +2924,14 @@ namespace .Settings { public sealed class DisplaySettings { - public DisplaySettings() { } public bool DetailedStackTrace { get; set; } } public sealed class ExecutionSettings { - public ExecutionSettings() { } public bool FailFast { get; set; } } public sealed class ParallelismSettings { - public ParallelismSettings() { } public int? MaximumParallelTests { get; set; } } public sealed class TUnitSettings @@ -2946,7 +2943,6 @@ namespace .Settings } public sealed class TimeoutSettings { - public TimeoutSettings() { } public DefaultHookTimeout { get; set; } public DefaultTestTimeout { get; set; } public ForcefulExitTimeout { get; set; } 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 226483142c..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 @@ -2924,17 +2924,14 @@ namespace .Settings { public sealed class DisplaySettings { - public DisplaySettings() { } public bool DetailedStackTrace { get; set; } } public sealed class ExecutionSettings { - public ExecutionSettings() { } public bool FailFast { get; set; } } public sealed class ParallelismSettings { - public ParallelismSettings() { } public int? MaximumParallelTests { get; set; } } public sealed class TUnitSettings @@ -2946,7 +2943,6 @@ namespace .Settings } public sealed class TimeoutSettings { - public TimeoutSettings() { } public DefaultHookTimeout { get; set; } public DefaultTestTimeout { get; set; } public ForcefulExitTimeout { get; set; } 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 031e96c271..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 @@ -2849,17 +2849,14 @@ namespace .Settings { public sealed class DisplaySettings { - public DisplaySettings() { } public bool DetailedStackTrace { get; set; } } public sealed class ExecutionSettings { - public ExecutionSettings() { } public bool FailFast { get; set; } } public sealed class ParallelismSettings { - public ParallelismSettings() { } public int? MaximumParallelTests { get; set; } } public sealed class TUnitSettings @@ -2871,7 +2868,6 @@ namespace .Settings } public sealed class TimeoutSettings { - public TimeoutSettings() { } public DefaultHookTimeout { get; set; } public DefaultTestTimeout { get; set; } public ForcefulExitTimeout { get; set; }