Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a5d57a4
feat: add TUnitSettings static API classes (#5521)
thomhurst Apr 12, 2026
eb9c582
refactor: deprecate Defaults class and switch internal reads to TUnit…
thomhurst Apr 12, 2026
e562448
feat: wire TUnitSettings into engine for parallelism, fail-fast, and …
thomhurst Apr 12, 2026
e5aebc4
test: update public API snapshots for TUnitSettings (#5521)
thomhurst Apr 12, 2026
27f31fa
test: add TUnitSettings unit tests (#5521)
thomhurst Apr 12, 2026
970b8b6
docs: add programmatic configuration documentation (#5521)
thomhurst Apr 12, 2026
e745bf3
refactor: eliminate redundant defaults and simplify Settings access p…
thomhurst Apr 12, 2026
d90e152
fix: address review feedback — remove DisableLogo, lazy FailFast, val…
thomhurst Apr 12, 2026
51cbc33
fix: address second review — clean up FailFast redundancy, revert Def…
thomhurst Apr 12, 2026
c128dd7
docs: fix misleading parallelism example and document hook timeout or…
thomhurst Apr 12, 2026
521bf56
fix: address review round 5 — doc gap, test guard, dead code
thomhurst Apr 12, 2026
664da2b
fix: add thread-safe volatile reads/writes to boolean settings
thomhurst Apr 12, 2026
af43dd2
fix: remove Volatile from boolean settings for consistency
thomhurst Apr 12, 2026
35b898d
fix: Defaults delegates to TUnitSettings, add TimeoutSettings validation
thomhurst Apr 12, 2026
0035ffc
feat: make TUnitSettings non-static, expose via BeforeTestDiscoveryCo…
thomhurst Apr 12, 2026
bf45f7e
fix: allow TimeSpan.Zero for ProcessExitHookDelay
thomhurst Apr 12, 2026
672246e
feat: defer MaximumParallelTests read with Lazy<T>
thomhurst Apr 12, 2026
2f36fac
refactor: remove redundant comments and resolve Lazy<T> values once i…
thomhurst Apr 12, 2026
03e2890
docs: remove DefaultHookTimeout from usage example
thomhurst Apr 12, 2026
3bd82b6
fix: defer DefaultHookTimeout resolution to execution time
thomhurst Apr 12, 2026
99549c6
refactor: make sub-settings constructors internal
thomhurst Apr 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TUnit.Core.SourceGenerator.Tests/NuGetDownloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public static async Task<IEnumerable<MetadataReference>> 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"));

Expand Down
15 changes: 11 additions & 4 deletions TUnit.Core/Defaults.cs
Original file line number Diff line number Diff line change
@@ -1,32 +1,39 @@
using TUnit.Core.Settings;

namespace TUnit.Core;

/// <summary>
/// Default values shared across TUnit.Core and TUnit.Engine.
/// Centralizes magic numbers so they can be tuned in a single place.
/// </summary>
[Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)} instead.")]
public static class Defaults
{
/// <summary>
/// Default timeout applied to individual tests when no <c>[Timeout]</c> attribute is specified.
/// Can be overridden per-test via <see cref="TUnit.Core.TimeoutAttribute"/>.
/// </summary>
public static readonly TimeSpan TestTimeout = TimeSpan.FromMinutes(30);
[Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultTestTimeout)} instead.")]
public static TimeSpan TestTimeout => TUnitSettings.Default.Timeouts.DefaultTestTimeout;

/// <summary>
/// Default timeout applied to hook methods (Before/After at every level)
/// when no explicit timeout is configured.
/// </summary>
public static readonly TimeSpan HookTimeout = TimeSpan.FromMinutes(5);
[Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.DefaultHookTimeout)} instead.")]
public static TimeSpan HookTimeout => TUnitSettings.Default.Timeouts.DefaultHookTimeout;

/// <summary>
/// Time allowed for a graceful shutdown after a cancellation request (Ctrl+C / SIGTERM)
/// before the process is forcefully terminated.
/// </summary>
public static readonly TimeSpan ForcefulExitTimeout = TimeSpan.FromSeconds(30);
[Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ForcefulExitTimeout)} instead.")]
public static TimeSpan ForcefulExitTimeout => TUnitSettings.Default.Timeouts.ForcefulExitTimeout;

/// <summary>
/// Brief delay during process exit to allow After hooks registered via
/// <see cref="CancellationToken.Register"/> to execute before the process terminates.
/// </summary>
public static readonly TimeSpan ProcessExitHookDelay = TimeSpan.FromMilliseconds(500);
[Obsolete($"Use {nameof(TUnitSettings)}.{nameof(TUnitSettings.Timeouts)}.{nameof(TimeoutSettings.ProcessExitHookDelay)} instead.")]
public static TimeSpan ProcessExitHookDelay => TUnitSettings.Default.Timeouts.ProcessExitHookDelay;
}
8 changes: 5 additions & 3 deletions TUnit.Core/EngineCancellationToken.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace TUnit.Core;
using TUnit.Core.Settings;

namespace TUnit.Core;

/// <summary>
/// Represents a cancellation token for the engine.
Expand Down Expand Up @@ -62,7 +64,7 @@ private void Cancel()
_forcefulExitStarted = true;

// Start a new forceful exit timer
_ = Task.Delay(Defaults.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t =>
_ = Task.Delay(TUnitSettings.Default.Timeouts.ForcefulExitTimeout, CancellationToken.None).ContinueWith(t =>
{
if (!t.IsCanceled)
{
Expand All @@ -86,7 +88,7 @@ private void OnProcessExit(object? sender, EventArgs e)
// ProcessExit has limited time (~3s on Windows), so we can only wait briefly.
// Thread.Sleep is appropriate here: we're on a synchronous event handler thread
// and just need a simple delay — no need to involve the task scheduler.
Thread.Sleep(Defaults.ProcessExitHookDelay);
Thread.Sleep(TUnitSettings.Default.Timeouts.ProcessExitHookDelay);
}
}

Expand Down
6 changes: 4 additions & 2 deletions TUnit.Core/Executors/DedicatedThreadExecutor.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using TUnit.Core.Helpers;
using TUnit.Core.Interfaces;
using TUnit.Core.Settings;

namespace TUnit.Core;

Expand Down Expand Up @@ -346,15 +347,16 @@ public override void Send(SendOrPostCallback d, object? state)
// Use a more robust synchronous wait pattern to avoid deadlocks
// We use Task.Run to ensure we don't capture the current SynchronizationContext
// which is a common cause of deadlocks
var timeout = TUnitSettings.Default.Timeouts.DefaultTestTimeout;
var waitTask = Task.Run(async () =>
{
// For .NET Standard 2.0 compatibility, use Task.Delay for timeout
var timeoutTask = Task.Delay(Defaults.TestTimeout);
var timeoutTask = Task.Delay(timeout);
var completedTask = await Task.WhenAny(tcs.Task, timeoutTask).ConfigureAwait(false);

if (completedTask == timeoutTask)
{
throw new TimeoutException($"Synchronous operation on dedicated thread timed out after {Defaults.TestTimeout.TotalMinutes} minutes");
throw new TimeoutException($"Synchronous operation on dedicated thread timed out after {timeout.TotalMinutes} minutes");
}

// Await the actual task to get its result or exception
Expand Down
9 changes: 6 additions & 3 deletions TUnit.Core/Hooks/HookMethod.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Reflection;
using TUnit.Core.Extensions;
using TUnit.Core.Interfaces;
using TUnit.Core.Settings;

namespace TUnit.Core.Hooks;

Expand All @@ -25,10 +26,12 @@ public abstract record HookMethod
public TAttribute? GetAttribute<TAttribute>() where TAttribute : Attribute => Attributes.OfType<TAttribute>().FirstOrDefault();

/// <summary>
/// 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 <c>null</c>, the engine falls back to
/// <see cref="Settings.TUnitSettings.Timeouts"/>.<see cref="Settings.TimeoutSettings.DefaultHookTimeout"/>
/// at execution time, so discovery-hook configuration is respected.
/// Set explicitly by the <c>[Timeout]</c> attribute or event receiver infrastructure.
/// </summary>
public TimeSpan? Timeout { get; internal set; } = Defaults.HookTimeout;
public TimeSpan? Timeout { get; internal set; }

private IHookExecutor _hookExecutor = DefaultExecutor.Instance;
private bool _hookExecutorIsExplicit;
Expand Down
8 changes: 8 additions & 0 deletions TUnit.Core/Models/BeforeTestDiscoveryContext.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using TUnit.Core.Settings;

namespace TUnit.Core;

/// <summary>
Expand Down Expand Up @@ -29,6 +31,12 @@ internal BeforeTestDiscoveryContext() : base(GlobalContext.Current)
/// </summary>
public required string? TestFilter { get; init; }

/// <summary>
/// Programmatic settings for TUnit. Configure these here to establish project-level defaults
/// before any tests are discovered or executed.
/// </summary>
public TUnitSettings Settings => TUnitSettings.Default;

internal override void SetAsyncLocalContext()
{
Current = this;
Expand Down
15 changes: 15 additions & 0 deletions TUnit.Core/Settings/DisplaySettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace TUnit.Core.Settings;

/// <summary>
/// Controls visual output settings.
/// </summary>
public sealed class DisplaySettings
{
internal DisplaySettings() { }

/// <summary>
/// Whether to show full stack traces including TUnit internals. Default: <c>false</c>.
/// Precedence: <c>--detailed-stacktrace</c> → TUnitSettings → built-in default.
/// </summary>
public bool DetailedStackTrace { get; set; }
}
15 changes: 15 additions & 0 deletions TUnit.Core/Settings/ExecutionSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace TUnit.Core.Settings;

/// <summary>
/// Controls test run behavior.
/// </summary>
public sealed class ExecutionSettings
{
internal ExecutionSettings() { }

/// <summary>
/// Whether to cancel the test run after the first test failure. Default: <c>false</c>.
/// Precedence: <c>--fail-fast</c> → TUnitSettings → built-in default.
/// </summary>
public bool FailFast { get; set; }
}
30 changes: 30 additions & 0 deletions TUnit.Core/Settings/ParallelismSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace TUnit.Core.Settings;

/// <summary>
/// Controls concurrent test execution.
/// </summary>
public sealed class ParallelismSettings
{
internal ParallelismSettings() { }

/// <summary>
/// Maximum number of tests to run in parallel. Default: <c>null</c> (= 4× CPU cores).
/// Precedence: <c>--maximum-parallel-tests</c> → <c>TUNIT_MAX_PARALLEL_TESTS</c> → TUnitSettings → built-in default.
/// </summary>
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;
}
41 changes: 41 additions & 0 deletions TUnit.Core/Settings/TUnitSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace TUnit.Core.Settings;

/// <summary>
/// Programmatic configuration for TUnit. Access via <c>context.Settings</c> in a
/// <c>[Before(HookType.TestDiscovery)]</c> hook to establish project-level defaults.
/// <para>
/// Precedence: CLI flag → environment variable → <see cref="TUnitSettings"/> → built-in default.
/// </para>
/// <para>
/// <b>Threading:</b> All settings should be configured before test execution begins
/// (typically in a <c>[Before(HookType.TestDiscovery)]</c> 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.
/// </para>
/// </summary>
public sealed class TUnitSettings
{
internal static TUnitSettings Default { get; } = new();

internal TUnitSettings() { }

/// <summary>
/// Default timeouts for tests and hooks.
/// </summary>
public TimeoutSettings Timeouts { get; } = new();

/// <summary>
/// Controls concurrent test execution.
/// </summary>
public ParallelismSettings Parallelism { get; } = new();

/// <summary>
/// Controls visual output.
/// </summary>
public DisplaySettings Display { get; } = new();

/// <summary>
/// Controls test run behavior.
/// </summary>
public ExecutionSettings Execution { get; } = new();
}
87 changes: 87 additions & 0 deletions TUnit.Core/Settings/TimeoutSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
namespace TUnit.Core.Settings;

/// <summary>
/// Default timeouts applied when no <c>[Timeout]</c> attribute is specified.
/// These are project-level defaults — CLI flags and environment variables take precedence.
/// </summary>
public sealed class TimeoutSettings
{
internal TimeoutSettings() { }

/// <summary>
/// Default timeout for individual tests. Default: 30 minutes.
/// Overridden per-test by <see cref="TimeoutAttribute"/>.
/// Precedence: CLI/env var (N/A for test timeout) → TUnitSettings → built-in default.
/// </summary>
public TimeSpan DefaultTestTimeout
{
get => _defaultTestTimeout;
set
{
ValidatePositive(value);
_defaultTestTimeout = value;
}
}

/// <summary>
/// Default timeout for hook methods (Before/After at every level). Default: 5 minutes.
/// Overridden per-hook by <see cref="TimeoutAttribute"/>.
/// </summary>
public TimeSpan DefaultHookTimeout
{
get => _defaultHookTimeout;
set
{
ValidatePositive(value);
_defaultHookTimeout = value;
}
}

/// <summary>
/// Time allowed for graceful shutdown after cancellation (Ctrl+C / SIGTERM)
/// before the process is forcefully terminated. Default: 30 seconds.
/// </summary>
public TimeSpan ForcefulExitTimeout
{
get => _forcefulExitTimeout;
set
{
ValidatePositive(value);
_forcefulExitTimeout = value;
}
}

/// <summary>
/// Brief delay during process exit to allow After hooks registered via
/// <see cref="CancellationToken.Register"/> to execute. Default: 500ms.
/// Set to <see cref="TimeSpan.Zero"/> to disable the delay.
/// </summary>
public TimeSpan ProcessExitHookDelay
{
get => _processExitHookDelay;
set
{
if (value < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(value), value,
"ProcessExitHookDelay cannot be negative.");
}

_processExitHookDelay = value;
}
}

private TimeSpan _defaultTestTimeout = TimeSpan.FromMinutes(30);
private TimeSpan _defaultHookTimeout = TimeSpan.FromMinutes(5);
private TimeSpan _forcefulExitTimeout = TimeSpan.FromSeconds(30);
private TimeSpan _processExitHookDelay = TimeSpan.FromMilliseconds(500);

private static void ValidatePositive(TimeSpan value)
{
if (value <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(value), value,
"Timeout must be a positive duration.");
}
}
}
5 changes: 2 additions & 3 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1078,8 +1078,7 @@ private async ValueTask<TestContext> CreateTestContextAsync(string testId, TestM
AttributesByType = attributes.ToAttributeDictionary(),
MethodGenericArguments = testData.ResolvedMethodGenericArguments,
ClassGenericArguments = testData.ResolvedClassGenericArguments,
Timeout = Core.Defaults.TestTimeout // Default timeout (can be overridden by TimeoutAttribute)
// Don't set RetryLimit here - let discovery event receivers set it
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout
};

var context = _contextProvider.CreateTestContext(
Expand Down Expand Up @@ -1172,7 +1171,7 @@ private TestDetails CreateFailedTestDetails(TestMetadata metadata, string testId
ReturnType = typeof(Task),
MethodMetadata = metadata.MethodMetadata,
AttributesByType = AttributeDictionaryHelper.Empty,
Timeout = Core.Defaults.TestTimeout
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout
};
}

Expand Down
10 changes: 4 additions & 6 deletions TUnit.Engine/Building/TestBuilderPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,7 @@ private async Task<AbstractExecutableTest[]> GenerateDynamicTests(TestMetadata m
ReturnType = typeof(Task),
MethodMetadata = metadata.MethodMetadata,
AttributesByType = attributes.ToAttributeDictionary(),
Timeout = Core.Defaults.TestTimeout // Default timeout (can be overridden by TimeoutAttribute)
// Don't set RetryLimit here - let discovery event receivers set it
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout
};

var testBuilderContext = CreateTestBuilderContext(metadata);
Expand Down Expand Up @@ -382,8 +381,7 @@ private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetad
ReturnType = typeof(Task),
MethodMetadata = resolvedMetadata.MethodMetadata,
AttributesByType = attributes.ToAttributeDictionary(),
Timeout = Core.Defaults.TestTimeout // Default timeout (can be overridden by TimeoutAttribute)
// Don't set Timeout and RetryLimit here - let discovery event receivers set them
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout
};

var context = _contextProvider.CreateTestContext(
Expand Down Expand Up @@ -462,7 +460,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada
ReturnType = typeof(Task),
MethodMetadata = metadata.MethodMetadata,
AttributesByType = AttributeDictionaryHelper.Empty,
Timeout = Core.Defaults.TestTimeout // Default timeout
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout
};

var context = _contextProvider.CreateTestContext(
Expand Down Expand Up @@ -515,7 +513,7 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet
ReturnType = typeof(Task),
MethodMetadata = metadata.MethodMetadata,
AttributesByType = AttributeDictionaryHelper.Empty,
Timeout = Core.Defaults.TestTimeout // Default timeout
Timeout = Core.Settings.TUnitSettings.Default.Timeouts.DefaultTestTimeout
};

var context = _contextProvider.CreateTestContext(
Expand Down
Loading
Loading